Meta-Muse-style authentication for KISS connectors
Credential-vault + surrogate-token + policy-gate isolation for Gmail, Drive, Calendar, Docs, Sheets, Google Chat, Notion and GitHub — plus inline OAuth for remote machines.
What was built and why
Meta's Muse personal agent keeps real credentials out of the model's reach: OAuth tokens live in a separate authd vault, the agent process only ever holds opaque surrogate tokens, and a Sentinel process swaps the surrogate for the real credential at the network boundary while enforcing an allow/deny/ask egress policy (Meta AI, "How We Built Safety Into Muse", Sep 2026). This work ports that architecture to the KISS connectors. The point is concrete: a prompt-injected agent has nothing real to exfiltrate — the token it can see is a placeholder that is worthless off the daemon boundary and worthless against any host outside the connector's allowlist.
Update (September 2026): the layer is now on by default on platforms that can
run the daemon (Linux and other systems with fcntl + SO_PEERCRED). Set
KISS_MUSE_AUTH=0 to opt out and restore the legacy plaintext transport; legacy
token.json files and config tokens are migrated into the vault on first connect,
and export <service> recovers a credential for a legacy config.
Architecture
Connectors covered
| Connector | Seam | Muse-mode credential |
|---|---|---|
| Gmail | googleapiclient.build(http=MuseHttp) | surrogate; token vaulted |
| Google Drive / Calendar / Docs / Sheets | google_api_session() → MuseBoundarySession | surrogate; token vaulted |
| Google Chat (user OAuth) | build(http=MuseHttp); service-account stays legacy | surrogate; leftover token.json migrated into the vault |
| Notion | MuseBoundarySession | surrogate over a vaulted bearer token |
| GitHub | MuseBoundarySession | surrogate over a vaulted PAT; read_only preserved |
Slack (owns its own slack_sdk HTTP stack) and URL-embedded-token
channels (Telegram, Discord, …) are intentionally left on the legacy path; converting them
would require rewriting their transport rather than swapping a Bearer header.
Remote / headless machines: inline consent in the chat webview
On a remote box the user cannot see a local browser, so
InstalledAppFlow.run_local_server (which blocks on a local browser) is useless.
The new RemoteOAuthSession starts the loopback redirect server in the background
and returns the authorization URL to the agent. The agent drives the consent pages in
its own built-in browser — which runs on the same machine, so the
localhost redirect completes locally — and screenshots each page to
./tmp/<service>_auth_*.png. Because those paths are named in the tool result,
the chat webview inlines the images on every surface, so the user sees the live login page and
supplies email / password / 2FA through ask_user_question(). A
finish_<service>_auth() tool then collects the token straight into the vault.
authenticate_gmail() → {status:"consent_required", auth_url, instructions}
go_to_url(auth_url) + screenshot(./tmp/gmail_auth_1.png) # inlined in chat
finish_gmail_auth() → {ok:true} # token stored in the vault
Security properties
- Surrogate isolation. The agent never holds a real token in Muse mode (except transiently during first-time OAuth enrollment, which is inherent to the flow and documented).
- Per-service ACL. A Drive surrogate cannot obtain Gmail's credential or reach a Gmail host — services are bound at mint time and re-checked at the boundary.
- Deterministic egress. Sentinel evaluates every call (host allowlist, HTTPS-only off loopback, no URL userinfo) and every redirect hop; the real token is stripped on cross-host redirects.
- Least privilege. Reads default to allow, writes to ask; approvals are strict capabilities scoped once / session / time-bounded / perpetual, and every decision is appended to a token-free audit log.
- Kernel-authenticated IPC. Unix socket with
SO_PEERCREDsame-uid check; vault files 0600 inside a 0700 directory.
Honest scope: both processes run as the same OS user, so this is a process
boundary, not an OS security domain. It removes real secrets from agent memory and enforces
egress policy; it does not stop an agent allowed to run arbitrary shell commands from reading
the vault directly — deny $KISS_HOME/muse_auth and the muse_auth CLI
in tool permissions for that.
Independent review (gpt-5.6-sol, read-only) and the fixes it drove
A separate model reviewed the implementation read-only and found concrete defects, all now fixed and covered by regression tests:
| Finding | Fix |
|---|---|
| URL parser-differential host bypass (urlparse vs. the real transport) | Daemon normalizes with requests.prepare(); Sentinel checks userinfo/scheme on the raw URL and the host on the effective URL |
Caller headers (Notion-Version, Accept, multipart boundary) dropped at the boundary | Outgoing headers built from the caller's headers, dropping only hop-by-hop + all Authorization variants |
| Blanket no-redirect broke Drive downloads | Follow ≤5 redirects with per-hop Sentinel checks; token only to allowlisted/same host |
| Service-name path traversal / trailing newline | re.fullmatch + non-string rejection |
| Daemon double-spawn / idle-thread exhaustion | flock held through listen(); bounded worker semaphore + 120s connection timeout |
| Token rotation ignored an already-enrolled vault entry | Re-authenticate clears the vault entry then re-enrolls |
| NaN/Inf TTL grants never expired; duplicate Authorization smuggling | TTL must be finite>0; exactly one real Authorization header emitted |
Verification
- New E2E suite
test_muse_auth.py— 25 tests, SEA style (real daemon subprocess, real emulated Google/GitHub/Notion servers and a real emulated OAuth provider; no mocks). - Impacted existing suites — 484 tests pass (Gmail, Drive, Calendar, Docs, Sheets, workspace-utils, Notion, GitHub, auth-flow, channel-agent suites).
- Branch coverage (subprocess-measured) — sentinel 98%, client 96%, daemon 92%, vault 89%; remaining lines are documented-unreachable without test doubles (real Google token refresh, second-OS-user peer check, blocking foreground daemon, 48 MiB frame overflow).
uv run check --full— ruff, mypy, and pyright all pass.
Operating it
# Muse-auth is on by default; export KISS_MUSE_AUTH=0 to opt out python -m kiss.agents.third_party_agents.muse_auth status python -m kiss.agents.third_party_agents.muse_auth import github # move an existing PAT into the vault python -m kiss.agents.third_party_agents.muse_auth grant gmail write --scope session python -m kiss.agents.third_party_agents.muse_auth audit --tail 20
Grants are meant to be issued by the human at a terminal, mirroring Muse's client-side approval prompts.