fix: a sandboxed agent presents its own identity, and its children die with the sandbox - #310
Merged
Merged
Conversation
…e with the sandbox Two findings from a prod audit of Forge sandboxes (forge#111, forge#110). They share a root: the per-sandbox badge was being used for things it was never scoped for, and nothing downstream checked. ## 1. Every LLM call from a sandbox was denied (forge#111) Forge wires the sandbox BADGE in as the gateway credential — `x-highflame-apikey`, plus `ANTHROPIC_AUTH_TOKEN` when the user brought no key. The badge is deliberately narrow: `nhi:manage` so this daemon can register its own identities, plus the `session:*` / `fs:read` / `pipeline:*` web-operator set. It holds no `tools:*` at all. Shield checks the privilege ceiling before Cedar and before any detector, and `process_prompt` requires `tools:read`. So every request was refused with "token missing required scope tools:read" — while the right credential, the per-session identity registered with the full `tools:*` set, sat unused a few hundred milliseconds away. Every `llm.route` event from a `forge-sbx-*` identity in prod and dev1 was denied; no sandbox LLM call has ever succeeded. This surfaced only recently because firehog#528 began forwarding `X-Agent-Scopes`, which made the ceiling reachable for gateway traffic for the first time. The mismatch was always there. The credential moves rather than the scopes widening: putting `tools:*` on the badge would grant them sandbox-wide for its whole 8-hour life, which is exactly what a per-session identity exists to avoid. `withGatewayCredential` swaps in the session's own api_key at query-build time. Two rules worth reviewing: - It only rewrites a header that is already there. No `x-highflame-apikey` means this is not a gateway launch, and nothing is added — the function cannot start a credential flowing somewhere one was not. - `ANTHROPIC_AUTH_TOKEN` moves only when it EQUALS the badge. Forge sets it to the badge only in the key-free case; a different value is the user's own BYOK or subscription credential, riding through to the provider untouched. Equality is what distinguishes them, so a user's key is never silently swapped for an agent identity. Resolved per query build, not at construction: the session identity is registered on the first send, so reading it earlier would leave every call on the badge — the bug, not a partial fix for it. ## 2. Children outlived the sandbox that minted them (forge#110) `POST /agents/register` takes the caller's `expires_at` at face value and defaults to none, so identities this daemon registered never expired. `system:expired_sweep` deactivated a sandbox's badge and left its child `active`: a permanent `tools:*` credential in the user's project, attributed to the launching human, surviving teardown. A prompt-injected agent could mint one deliberately. Forge now injects `ZEROID_IDENTITY_EXPIRES_AT` (the badge's own expiry) and every registration path here — session agent, worker, sub-agent, conductor — stamps it. A test asserts all four, because one uncapped path is all that is needed. Outside a sandbox the field is omitted rather than sent empty: an explicit null would be this daemon asserting "no expiry", which is the behaviour being fixed. This bounds the problem, it does not close it. ZeroID still applies no register-time scope ceiling — `allowed_scopes` passes through unvalidated and the tenant `default` credential policy's empty `allowed_scopes` reads as "unrestricted" — so a badge can still register a child more privileged than itself. That fix belongs in ZeroID and is tracked on forge#110; what changes here is that the over-grant can no longer outlive the sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
akhiljavelin
approved these changes
Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes forge#111 and the containable half of forge#110 — two findings from a prod audit of Forge sandboxes (account
757038846364, 2026-08-28).Pairs with highflame-forge PR (ZEROID_IDENTITY_EXPIRES_AT injection); merge order below.
1. No sandbox LLM call has ever succeeded (forge#111)
Forge wires the per-sandbox badge in as the gateway credential —
x-highflame-apikey, plusANTHROPIC_AUTH_TOKENwhen the user brought no key of their own. The badge is deliberately narrow (zeroid_mint.py:58):nhi:manageso this daemon can register its own identities, plus thesession:*/fs:read/pipeline:*web-operator set. Notools:*at all.Shield enforces the privilege ceiling before Cedar and before any detector, and
process_promptrequirestools:read(privilege_catalog.gen.go:216,internal/scheduler/nhi.go:45). So:From the issue's timeline — the correct credential existed 600ms before the call:
Every
llm.routeevent from aforge-sbx-*identity in prod and dev1 was denied. This only started being visible when firehog#528 began forwardingX-Agent-Scopes(prod 2026-08-26), which made the ceiling reachable for gateway traffic for the first time — the mismatch predates it.Why the credential moves instead of the scopes widening
Adding
tools:*toDEFAULT_BADGE_SCOPESwould grant them sandbox-wide for the badge's full 8-hour life, which is precisely what the per-session identity design exists to avoid. The issue says not to, and it's right.withGatewayCredentialswaps in the session identity's own api_key. Two rules are the interesting part:x-highflame-apikey→ not a gateway launch (local run, direct-to-provider) → untouched. This function cannot start a credential flowing somewhere one was not already flowing.ANTHROPIC_AUTH_TOKENmoves only when it equals the badge. Forge sets it to the badge only in the key-free case, where the badge doubles as the CLI's boot credential. A different value is the user's BYOK or subscription token, which rides through the gateway to the provider untouched — equality is what tells them apart, so a user's own credential is never silently swapped for an agent identity.The api_key rather than the stored access token, because that is the shape
x-highflame-apikeyalready carries — the gateway exchanges it. Keeps this a substitution, not a protocol change.Resolved at query build, not at construction:
#ensureAgentIdentityregisters on the first send (gated on!provider.hasQueried), so reading it earlier would leave every call on the badge — the bug, not a partial fix.2. Children outlived the sandbox that minted them (forge#110)
POST /agents/registertakesexpires_atat face value and defaults to none, so identities registered here never expired. From the issue:codeoid/My_Testis stillactiveaftersystem:expired_sweepdeactivated its sandbox badgeforge-sbx-6b5f31d7…. The sweep takes the parent and leaves the child — a permanenttools:*credential in the user's project, attributed to the launching human, surviving teardown. A prompt-injected agent can mint one deliberately.Forge now injects
ZEROID_IDENTITY_EXPIRES_AT(the badge's own expiry — this daemon never sees the launch request, so it has to be told), and every registration path stamps it: session agent, worker, sub-agent, conductor. A test asserts all four, because one uncapped path is all an attacker needs.Outside a sandbox the field is omitted, not sent empty — an explicit null would be this daemon asserting "no expiry", which is the behaviour being fixed rather than ZeroID's default staying in charge. Tested.
What this does NOT fix — please read
The scope escalation in forge#110 is still open. I checked the ZeroID code rather than assuming, and the issue's proposed fix #1 as written would not close it:
allowed_scopespasses through unvalidated at register (internal/service/agent.go:196→RegisterIdentity).EnforcePolicy(credential_policy.go:353) is a token-issuance check — TTL, grant type, requested scopes — against the identity's own policy. It is not a register-time ceiling on a child'sallowed_scopes.credential_policy_idand inherits the tenantdefault— whose emptyallowed_scopesthe subset check reads as "unrestricted".Closing it needs a ZeroID change: cap a newly-registered identity's
allowed_scopesby the registering caller's own ceiling. That is a shared-service change affecting every register caller (cerberus, forge, discovery, this daemon), so I have left it for a scoped PR rather than smuggling it in here.What changes today: the over-grant is bounded to the life of the sandbox that opened it — the difference between a transient over-grant and a standing one.
Verify
New tests: 7 in
provider-env.test.ts(credential swap, BYOK passthrough, non-gateway no-op, missing-credential no-op, case-insensitive header match, no mutation, end-to-end throughbuildAgentEnv), 3 inagent-identity-conductor.test.ts(session cap, all-four-paths cap, omitted outside a sandbox).Not verified live. Both fixes need a real sandbox against the gateway to confirm — the unit tests prove the credential that gets built, not that Shield then accepts it. Worth a dev1 launch before this rides to prod.
Merge order
The Forge PR first (or together). Without
ZEROID_IDENTITY_EXPIRES_ATthe expiry cap is inert; the forge#111 fix is independent and works either way.🤖 Generated with Claude Code