Muse-auth for non-Bearer connectors: Discord, Home Assistant, ntfy, Govee

KISS Sorcar — third task in the Muse-auth series. Development model: claude-fable-5. Independent read-only review and debugging: gpt-5.6-sol (eight rounds, all via run_parallel, final verdict APPROVED).

The first two tasks in this series built a Meta-Muse-style credential isolation layer for KISS connectors: a daemon-owned vault holds real tokens, agent processes hold only opaque surrogates (muse-sgt.<service>.<hex>), and a Sentinel policy engine authorizes every request at the network boundary where the surrogate is swapped for the real credential. This task extends that boundary to the four remaining credentialed connectors whose APIs do not use plain Authorization: Bearer semantics, and hardens the URL/host validation stack that self-hosted enrollment depends on.

1. What each connector needed

ConnectorCredential schemeVault representationNew boundary capability
Discord Authorization: Bot <token> {"kind":"header","header":"Authorization","token":"Bot <tok>"} Header-kind credentials (built for Brave Search in task 2) carry the scheme prefix inside the stored value, so the daemon emits the exact non-Bearer header. POSTs to /typing classify as reads: the ephemeral typing indicator must not burn a one-shot write grant meant for the message send that follows.
Home Assistant Bearer, but always self-hosted, often plain http:// on a LAN {"kind":"bearer",...} + enrolled origin host:port Consent-scoped insecure hosts. The Sentinel normally refuses plain-HTTP egress for a credential. A host enrolled from an http:// base URL at consent time is recorded in the vault as an insecure host; only that exact origin may be reached over HTTP. Daemon protocol bumped to v3 so a pre-upgrade daemon (which would silently drop the field and deny every request) is detected and replaced.
ntfy Optional Bearer; public ntfy.sh works tokenless {"kind":"bearer",...} + enrolled origin Tokenless configs stay on the legacy direct path (nothing to protect). A token is strictly origin-bound — a private-server token can never be spent against public ntfy.sh, and vice versa. Explicit token: "" in config is authoritative: it clears a stale vault entry rather than reviving it.
Govee Govee-API-Key: <key> header, key from $GOVEE_API_KEY {"kind":"header","header":"Govee-API-Key",...} The CLI (govee.py) enrolls the environment key once, then removes it from its own process environment; POST /device/state (a query that merely names the device) classifies as a read while /device/control (actuation) stays a write. Ambiguous failures (daemon reply lost) are never replayed for writes.

2. Request flow for a header-kind credential

Agent process holds only the surrogate: muse-sgt.govee.4f2a… MuseBoundarySession.post(…) authd daemon (Unix socket, SO_PEERCRED) 1. resolve surrogate → service "govee" 2. pin credential generation nonce 3. Sentinel: host ∈ allowlist? scheme ok? action = request_action("govee", "POST", "/device/state") = read 4. swap: drop surrogate bearer, emit Govee-API-Key: <real key> 5. send; re-authorize every redirect hop 6. strip credential from response, append token-free audit record vault/govee.json (0600, daemon-only) openapi.api.govee.com sees only the real key surrogate real key
Boundary flow for Govee. Discord is identical except the swapped header is Authorization: Bot <token>; Home Assistant and ntfy use bearer-kind credentials plus a per-credential enrolled origin.

3. Origin binding and the host-validation stack

Self-hosted services (Home Assistant, ntfy, Firecrawl) have no built-in host: SERVICE_HOSTS maps them to () and the credential is bound at consent time to exactly one host:port origin. That made the URL/host validation stack security-relevant, and most review findings across the eight rounds concentrated there. The final stack, applied identically at the CLI, the connector authenticate tools, the daemon enrollment path, and Sentinel request matching:

valid_http_url(url)        # http(s) scheme, no userinfo, well-formed port,
                           # hostname validated per DNS label
canonical_host(host)       # lowercase + removesuffix(".")  — exactly ONE root dot
canonical_host_entry(e)    # host[:port] normalization (leading-zero ports, brackets)
url_origin_entry(url)      # "host:port" with scheme-default port applied
_invalid_hosts_reason(hs)  # daemon-side gate before anything is stored

Host classes verified to agree across every layer: single-label names (localhost), multi-label FQDNs, one trailing DNS root dot (example.com.), IPv4, bracketed IPv6, IPv4-mapped IPv6 ([::ffff:127.0.0.1]), and scoped IPv6 with RFC-6874 ZoneIDs ([fe80::1%25eth-0]). Malformed classes rejected at every layer: empty DNS labels (bad..example, localhost..), 64-char labels, 254-char hostnames, label-edge hyphens, userinfo URLs, malformed ports, and bracketed non-IP bodies.

4. Review process: eight rounds, every finding reproduced

gpt-5.6-sol ran read-only over the staged tree with instructions to report only demonstrable problems (command + observed output). Every accepted finding came with a working reproduction; none was speculative. Highlights per round:

RoundRepresentative real findings (all fixed with regression tests)
1Non-transactional HA/ntfy enrollment left plaintext behind; vault replacement not generation-bound (redirect race could spend an old token under a new host scope); ntfy credential valid for public ntfy.sh; Govee kept its real key in the process environment; header values reflected into error text.
2In-flight old-generation requests could spend rotated credentials (fixed with a per-request generation nonce pinned across redirect hops); host-vs-origin binding gaps; Google refresh obeyed HTTPS_PROXY.
3Malformed ports raised raw ValueError mid-migration (Firecrawl destructive partial migration); Govee replayed a control POST after a lost daemon reply; ntfy exposed tools despite failed wiring.
4Firecrawl's builtin cloud host let a rejected self-host key reach the cloud API (now strictly origin-bound); valid_http_url accepted userinfo; IPv4-mapped IPv6 layer disagreement.
5–6CLI import bypassed URL validation; scoped-IPv6 ZoneID and DNS-label edge cases; JSON null stringified to "None" and rejected.
7(1) rstrip(".") collapsed localhost.. into a valid name that then failed deep inside urllib3; (2) a null ntfy server imported without enrolling the ntfy.sh:443 default origin, leaving the migrated token unusable; (3) the null-URL exception was a broad truthiness test, accepting false/0/[] and Home Assistant's required null base_url.
8None. Verdict: APPROVED.

Round-7 fixes in detail

5. Verification

6. Files changed

13 staged paths, +3,535 / −158 lines: muse_auth/ core (_common.py, vault.py, sentinel.py, daemon.py, client.py, __main__.py), four connectors (discord_agent.py, homeassistant_agent.py, ntfy_agent.py, govee.py), shared _channel_agent_utils.py, prior-task connector touch-ups (firecrawl_agent.py), and the new 1,889-line device test suite.