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

Agent process (untrusted) holds only muse-sgt.<svc>.<hex> Connector backend gmail / drive / notion / github / … MuseBoundarySession · MuseHttp requests- / httplib2-compatible shims Request carries the surrogate, never a real token. muse-auth daemon (trusted) Unix socket · SO_PEERCRED same-uid Sentinel — policy authority allow / deny / ask · host allowlist grants (once/session/ttl/perpetual) · audit authd — credential vault real OAuth/bearer tokens (0600), 0700 dir surrogate → real swap at the boundary Egress executor per-hop redirect re-authorization Service API googleapis.com api.notion.com api.github.com only the real token arrives here surrogate prompt-injection read of the token ↯ yields only a useless surrogate
Request path: the agent signs with a surrogate; Sentinel authorizes the exact call; authd swaps in the real token at the boundary; only the service API ever sees the real credential. A leak of what the agent holds yields a placeholder.

Connectors covered

ConnectorSeamMuse-mode credential
Gmailgoogleapiclient.build(http=MuseHttp)surrogate; token vaulted
Google Drive / Calendar / Docs / Sheetsgoogle_api_session()MuseBoundarySessionsurrogate; token vaulted
Google Chat (user OAuth)build(http=MuseHttp); service-account stays legacysurrogate; leftover token.json migrated into the vault
NotionMuseBoundarySessionsurrogate over a vaulted bearer token
GitHubMuseBoundarySessionsurrogate 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

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:

FindingFix
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 boundaryOutgoing headers built from the caller's headers, dropping only hop-by-hop + all Authorization variants
Blanket no-redirect broke Drive downloadsFollow ≤5 redirects with per-hop Sentinel checks; token only to allowlisted/same host
Service-name path traversal / trailing newlinere.fullmatch + non-string rejection
Daemon double-spawn / idle-thread exhaustionflock held through listen(); bounded worker semaphore + 120s connection timeout
Token rotation ignored an already-enrolled vault entryRe-authenticate clears the vault entry then re-enrolls
NaN/Inf TTL grants never expired; duplicate Authorization smugglingTTL must be finite>0; exactly one real Authorization header emitted

Verification

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.