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.
| Connector | Why the original design did not fit | Resolution |
|---|---|---|
| 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. |
MuseWebClient (new module muse_auth/slack_transport.py) subclasses
slack_sdk.WebClient and overrides exactly two seams of slack_sdk 3.40.1:
_perform_urllib_http_request_internal(url, req) — receives the fully built
urllib request (JSON or multipart body, surrogate bearer header) and returns
{status, headers, body}. Overriding here preserves all of slack_sdk's request
building, retry handling, charset decoding, and binary application/gzip downloads._upload_file(...) — step two of files_upload_v2, which the stock
SDK performs with a raw urlopen to a URL taken from Slack's API response. Routing
it through the daemon means Sentinel confirms the destination host before any file content
leaves the machine; review testing showed the stock path would happily deliver file bytes to a
rogue URL injected into the API response.
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.
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.
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.
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.
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.
test_muse_auth_channels.py: 21 tests
against a real daemon subprocess and recording HTTP emulators for Slack, Firecrawl, and Brave —
including an emulated protocol-1 relic daemon and a connection-refusing port for transport
failures. No mocks.uv run check --full passes: ruff, mypy, pyright, compileall, docs.