Repository navigation
daemon (remote): client-declared tenant is trusted when the auth hook does not attest tenantId (header on aux routes, meta on RPC) #2095
Copy link
Copy link
Closed
Labels
Description
Activity
Remote-daemon hardening cluster (from the correctness & security audit). Suggested pickup order:
- daemon (remote): client-declared tenant is trusted when the auth hook does not attest tenantId (header on aux routes, meta on RPC) #2095 — trusted-tenant posture (keystone; daemon (remote): artifacts --provider-session forwards an unowned provider session id to the cloud provider with no tenant ownership check #2096 depends on it)
- daemon (remote): artifacts --provider-session forwards an unowned provider session id to the cloud provider with no tenant ownership check #2096 — provider-session artifact ownership
- daemon (remote): install_from_source kind:"path" is an unconfined daemon-host file read on the HTTP surface #2097 — install_from_source path confinement
- daemon (remote): Maestro runScript http.* bypasses the install-source public-address allowlist (SSRF) #2098 — Maestro http.* SSRF (shares the trust-boundary helper with daemon (remote): install_from_source kind:"path" is an unconfined daemon-host file read on the HTTP surface #2097)
- daemon: durable-capture recovery Promise.race drops a late successful reattach, orphaning the handle #2099 — durable-capture late-reattach leak (independent correctness bug)
All target the shared remote/proxy daemon, not the local loopback CLI. Verified against
848c6ea733.Triage confirmed against current main d44df9b. Ready for an evidence-first agent slice with one explicit posture decision: when an auth hook is configured and does not attest a tenant, any tenant-scoped RPC or auxiliary request fails closed as UNAUTHORIZED; client meta and headers never become identity. With no hook, preserve the local loopback behavior. Do not add a trustClientDeclaredTenant compatibility escape hatch without separate approval. Plant both HTTP surfaces red, and land this before #2096.
- added 6 commits that reference this issue
on Aug 27, 2026 - added a commit that references this issue
on Aug 28, 2026
Severity: security (tenant isolation). Surface: shared remote/proxy daemon HTTP, not the local loopback CLI.
Problem
When an auth hook is configured but does not attest a
tenantId, the daemon falls back to a client-declared tenant as identity. This is exploitable in shared-token multi-tenant deployments: a holder of one valid token can present any tenant id and act as that tenant on the auxiliary routes (diagnostics, uploads, downloadable artifacts) and on/rpc.The audit framed this as an aux-routes-only header issue. It is broader: the RPC path has the identical hole via the request body.
Evidence (at
848c6ea733)authorizeAuxiliaryHttpRequestreturnsauthResult.tenantId ?? tenantId, wheretenantIdisnormalizeTenantId(x-agent-device-tenant)read straight off the request. The comment there documents this as intentional parity with RPC.toDaemonRequestcopiesparams.metaverbatim — including a client-suppliedmeta.tenantId— intodaemonRequest.meta. On the RPC path the hook result only overrides tenant when the hook returns one; when the hook is silent, the client'smeta.tenantIdsurvives untouched intohandleRequest.So header (aux) and body-meta (RPC) are two faces of one posture: client-declared tenant is trusted whenever the hook is silent.
Why this is the keystone
The audit's "tenant-less artifacts in every inventory" item was correctly rejected as by-design (
canReadArtifact: notenantId⇒ public bucket). But that means isolation depends entirely ontenantIdbeing trustworthy — which is exactly what this issue is about. Fix this and that rejection stays sound; leave it and the public-bucket model is impersonable.Design: one trusted-tenant seam, one posture
Both paths currently derive tenant ad hoc. Introduce a single resolver used by both the RPC handler and
authorizeAuxiliaryHttpRequest:Posture (fail-closed when a hook exists):
trustClientDeclaredTenant: true) that re-enables the fallback deliberately, so it is a choice and not a default.Refactor required
resolveTrustedTenantand call it from bothauthorizeAuxiliaryHttpRequestand the/rpchandler.meta.tenantIdas identity: intoDaemonRequest, carry a client-declared tenant separately (or strip it frommeta) so the posture resolver is the only place that promotes a client value to identity. Today the value slips in throughmetabefore any policy runs.Acceptance criteria
{ ok: true }(no tenantId): a request presentingx-agent-device-tenant: victim(aux) and a request presentingmeta.tenantId: victim(RPC), both with a valid token, are refused / untenanted — not run asvictim. New tests on both surfaces.trustClientDeclaredTenantopt-in (if added) restores the old behavior and is documented in the auth-hook docs and CHANGELOG as a deliberate multi-tenant footgun.Effort / risk
S (few lines + tests), LOW risk — but it is a behavior change for any deployment that runs a hook and relies on the header/body fallback today. Call it out in the auth-hook docs and CHANGELOG.
Blocks #S1PLACEHOLDER (the provider-session ownership check is only meaningful once
leaseScope.tenantIdis trustworthy).