Extending Muse-auth to Slack, Firecrawl, and Brave Search

The first Muse-auth milestone put eight connectors (Gmail, Drive, Calendar, Docs, Sheets, Google Chat, Notion, GitHub) behind a credential vault: the agent process holds an opaque surrogate token, and a local daemon swaps it for the real credential at the network boundary after a Sentinel policy check. This report covers the second milestone: the remaining token-authenticated connectors, each of which broke one of the original design's assumptions.

ConnectorWhy the original design did not fitResolution
Slack Uses slack_sdk.WebClient (urllib, not requests); the entire Web API is RPC over POST, so HTTP methods carry no read/write signal; supports multiple workspaces per machine; files_upload_v2 POSTs file bytes to a server-supplied URL outside any client seam. MuseWebClient transport overriding the two innermost slack_sdk seams; per-API-method action classes; workspace-keyed vault services slack-<slug>-<hash>.
Firecrawl Self-hosted instances live on arbitrary hosts, but the Sentinel allowlist was compiled in; its data-retrieval endpoints (/v2/scrape, /v2/map, /v2/search) are POSTs that would classify as writes. Enrollment-time hosts stored with the vault credential; path-suffix read classification.
Brave Search Authenticates with an X-Subscription-Token header, not Authorization: Bearer — the boundary only knew how to swap bearers. New {"kind": "header"} vault credential; the daemon places the real token in the credential's declared header and strips every caller-supplied copy.

Boundary flow with header-kind credentials

Agent process holds only surrogates: muse-sgt.slack.9f2c… muse-sgt.firecrawl.a01d… muse-sgt.brave_search.77e0… MuseWebClient (slack_sdk) MuseBoundarySession (requests) every call, incl. files_upload_v2's POST of file bytes to the server- supplied upload URL, ships one JSON frame over the Unix socket SO_PEERCRED Muse-auth daemon (protocol 2) 1. bind surrogate → service 2. Sentinel: host allowlist (built-in + policy extra_hosts + enrolled hosts), action = request_action(service, path) 3. vault.resolve_header(service): bearer → Authorization: Bearer … header → X-Subscription-Token: … 4. strip caller copies, set exactly one real credential header 5. redirects re-authorized per hop; cross-host off-allowlist hops only followed if bodyless GET/HEAD, always credential-stripped HTTPS Network slack.com files.slack.com api.firecrawl.dev self-hosted host (enrolled) api.search.brave.com sees only real tokens; surrogates never leave the box

Slack: a boundary-compatible WebClient transport

MuseWebClient (new module muse_auth/slack_transport.py) subclasses slack_sdk.WebClient and overrides exactly two seams of slack_sdk 3.40.1:

Because Slack sends every call as POST, read/write classification uses the API method name (the last URL path segment): auth.test, conversations.history, users.list and friends are reads; anything unknown defaults to write and needs a grant. Workspaces map to vault services — slack for the default workspace, slack-<slug>-<sha256[:16]> otherwise (review found a genuine birthday collision with an 8-hex digest, so it is 16). Authentication in Muse mode enrolls the token first and validates auth.test through the boundary, so the check is audited and no plaintext token.json is ever written; legacy token files are deleted after their one-time migration into the vault.

Firecrawl: consent-time host enrollment

A self-hosted Firecrawl base URL is captured when the user supplies the API key — the one moment the agent legitimately holds the plaintext — and stored with the vault credential as hosts. Sentinel merges these into the allowlist, so no daemon-side policy edit is needed. After enrollment the agent scrubs api_key out of config.json while keeping base_url, so an enrolled process never again reads a file containing the secret and the endpoint metadata survives the migration.

Brave Search: header-kind credentials

The vault gained a second plain-token kind: {"kind": "header", "header": "X-Subscription-Token", "token": …}. The agent still presents its surrogate as a bearer (so the daemon can bind it to a service), but the boundary swap places the real token in the declared header, drops the Authorization header entirely, and removes any caller-supplied copy of the credential header so nothing can be smuggled past the swap. Enrollment validates the header name against a syntax check and a hop-by-hop/framing denylist.

Daemon protocol handshake

Header-kind credentials and enrollment hosts are meaningless to the previous daemon, and the daemon is a detached process that can outlive a code upgrade. The daemon now reports protocol: 2 in its status reply; ensure_daemon() handshakes before first use, stops any older survivor, and spawns the current version. The verification is cached per socket identity (st_dev, st_ino, st_ctime_ns) — the ctime matters because tests demonstrated inode-number reuse after unlink, which would have let a replacement listener slip past a (dev, ino) cache.

Independent review

Two read-only review rounds by gpt-5.6-sol (run via run_parallel, capped below half the task budget, instructed to report only demonstrable problems) drove most of the hardening above. Round one found five majors — the upload egress bypass, a plaintext authentication path that skipped the audit log, base-URL/key coupling that silently re-pointed self-hosted instances at the cloud API, the missing protocol handshake, and non-vault-aware workspace management — plus four minors (poll retries swallowing boundary errors, reverse-proxy read misclassification, IPv6 hosts rejected, the digest collision). Round two verified those fixes and caught five residual gaps, the sharpest being a 307/308 redirect that would have carried an upload body to a host Sentinel had just denied; the boundary now refuses to follow any off-allowlist redirect that carries a request body. Every finding was reproduced before fixing and has a dedicated regression test.

Verification

Scope note, unchanged from milestone one: agent and daemon run as the same OS user, so this is a process boundary, not an OS security domain. It removes real credentials from the agent process and enforces deterministic egress policy; it does not defend against an agent allowed to run arbitrary shell commands.