Skip to content

fix: a sandboxed agent presents its own identity, and its children die with the sandbox - #310

Merged
saucam merged 2 commits into
mainfrom
fix/sandbox-gateway-credential
Aug 29, 2026
Merged

fix: a sandboxed agent presents its own identity, and its children die with the sandbox#310
saucam merged 2 commits into
mainfrom
fix/sandbox-gateway-credential

Conversation

@saucam

@saucam saucam commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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, plus ANTHROPIC_AUTH_TOKEN when the user brought no key of their own. The badge is deliberately narrow (zeroid_mint.py:58): nhi:manage so this daemon can register its own identities, plus the session:* / fs:read / pipeline:* web-operator set. No tools:* at all.

Shield enforces the privilege ceiling before Cedar and before any detector, and process_prompt requires tools:read (privilege_catalog.gen.go:216, internal/scheduler/nhi.go:45). So:

Highflame Security: token missing required scope "tools:read" for action "process_prompt"

From the issue's timeline — the correct credential existed 600ms before the call:

14:05:22.514  session identity created   codeoid-session-375c5d4d
14:05:22.557  session token              tools:read/write/execute/agent   <- right one
14:05:23.148  badge token                16 scopes, no tools:*            <- the one sent
14:05:23.203  DENY

Every llm.route event from a forge-sbx-* identity in prod and dev1 was denied. This only started being visible when firehog#528 began forwarding X-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:* to DEFAULT_BADGE_SCOPES would 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.

withGatewayCredential swaps in the session identity's own api_key. Two rules are the interesting part:

  • It only rewrites a header that is already present. No 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_TOKEN moves 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-apikey already carries — the gateway exchanges it. Keeps this a substitution, not a protocol change.

Resolved at query build, not at construction: #ensureAgentIdentity registers 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/register takes expires_at at face value and defaults to none, so identities registered here never expired. From the issue: codeoid/My_Test is still active after system:expired_sweep deactivated its sandbox badge forge-sbx-6b5f31d7…. The sweep takes the parent and leaves the child — a permanent tools:* 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_scopes passes through unvalidated at register (internal/service/agent.go:196RegisterIdentity).
  • 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's allowed_scopes.
  • Giving the badge a dedicated credential policy does not help by itself, because the child is registered with no credential_policy_id and inherits the tenant default — whose empty allowed_scopes the subset check reads as "unrestricted".

Closing it needs a ZeroID change: cap a newly-registered identity's allowed_scopes by 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

bun run typecheck   # clean
bun run lint        # clean
bun run test        # 2397 pass, 12 skip, 0 fail

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 through buildAgentEnv), 3 in agent-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_AT the expiry cap is inert; the forge#111 fix is independent and works either way.

🤖 Generated with Claude Code

…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>
@saucam
saucam merged commit 3272dd8 into main Aug 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants