diff --git a/src/config.ts b/src/config.ts index f828cf6..6d7558b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -414,6 +414,7 @@ const AgentIdentitySchema = z accountId: z.string().default("personal"), projectId: z.string().default("dev"), registrarKey: z.string().optional(), + identityExpiresAt: z.string().optional(), }) .default({ accountId: "personal", projectId: "dev" }); @@ -920,6 +921,14 @@ export interface CodeoidConfig { * Distinct from apiKey (the human-operator client token). */ registrarKey?: string; + /** + * RFC3339 expiry of the identity `registrarKey` belongs to — the sandbox + * badge, when Forge injected one. Every identity this daemon registers is + * capped to it, so a child cannot outlive the sandbox that created it + * (forge#110). Absent outside a sandbox, where there is no such ceiling to + * inherit and children expire with the session teardown instead. + */ + identityExpiresAt?: string; }; /** Memory / recall config — when enabled, stores episodes and exposes recall() to Claude. */ memory?: { @@ -1154,6 +1163,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "ZEROID_ACCOUNT_ID", path: "agentIdentity.accountId", kind: "string" }, { env: "ZEROID_PROJECT_ID", path: "agentIdentity.projectId", kind: "string" }, { env: "ZEROID_REGISTRAR_KEY", path: "agentIdentity.registrarKey", kind: "string" }, + { env: "ZEROID_IDENTITY_EXPIRES_AT", path: "agentIdentity.identityExpiresAt", kind: "string" }, { env: "CODEOID_MEMORY", path: "memory.enabled", kind: "boolean" }, { env: "CODEOID_MEMORY_DB_PATH", path: "memory.dbPath", kind: "string" }, { env: "CODEOID_MEMORY_MODEL", path: "memory.model", kind: "string" }, @@ -1379,6 +1389,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { accountId: parsed.agentIdentity.accountId, projectId: parsed.agentIdentity.projectId, registrarKey: parsed.agentIdentity.registrarKey, + identityExpiresAt: parsed.agentIdentity.identityExpiresAt, } : undefined, memory: { diff --git a/src/daemon/agent-identity.ts b/src/daemon/agent-identity.ts index 205ce12..f0635b7 100644 --- a/src/daemon/agent-identity.ts +++ b/src/daemon/agent-identity.ts @@ -35,6 +35,29 @@ export interface AgentIdentityConfig { * per-agent api_key: the #clientForAgent clients are NOT given this key. */ registrarKey?: string; + /** + * RFC3339 expiry of the identity `registrarKey` belongs to — the per-sandbox + * badge, when Forge injected one (`ZEROID_IDENTITY_EXPIRES_AT`). + * + * Every identity registered here is stamped with it, because ZeroID applies + * no ceiling of its own: `allowed_scopes` passes through unvalidated at + * register, and the tenant `default` credential policy carries an empty + * `allowed_scopes`, which the subset check reads as "unrestricted". So a + * badge holding `nhi:manage` and no `tools:*` could register a child holding + * the full `tools:*` set — and, with no expiry, one that survived the + * sandbox: `system:expired_sweep` deactivated the badge and left the child + * `active`, a permanent credential in the user's project attributed to the + * launching human (forge#110). + * + * Capping the lifetime does not close the scope gap — that needs the + * register-time ceiling in ZeroID — but it does bound it to the sandbox that + * opened it, which is the difference between a transient over-grant and a + * standing one. + * + * Absent outside a sandbox: there is no badge to inherit from, and children + * are already torn down with their session. + */ + identityExpiresAt?: string; /** * Prefix for the conductor's ZeroID external_id. Defaults to * "codeoid-conductor"; integration tests override it so their throwaway @@ -176,6 +199,20 @@ export class AgentIdentityManager { this.#store = store; } + /** + * The lifetime ceiling stamped on every identity registered here — see + * `AgentIdentityConfig.identityExpiresAt` for why (forge#110). + * + * Spread into the register body so an absent ceiling omits the field + * entirely: sending an explicit null/empty would be a caller asserting "no + * expiry", which is the behaviour being fixed rather than the default being + * inherited. + */ + #expiry(): { expires_at?: string } { + const at = this.#config.identityExpiresAt; + return at ? { expires_at: at } : {}; + } + /** * A ZeroID client authed as a specific agent (by its api_key) — used as the * delegation *subject* (orchestrator) when minting delegated sub-agent @@ -210,6 +247,7 @@ export class AgentIdentityManager { const registerReq = { name: `codeoid/${sessionName}`, external_id: externalId, + ...this.#expiry(), // A coding agent — `identity_type=agent` types the node in the ZeroID // registry / delegation explorer, and `sub_type=code_agent` (accepted // by ZeroID's register enum) is the accurate role, matching how @@ -294,6 +332,7 @@ export class AgentIdentityManager { const registerReq = { name: `codeoid/worker/${shape}/${sessionName}`, external_id: externalId, + ...this.#expiry(), identity_type: "agent" as const, sub_type: "tool_agent" as const, trust_level: "first_party" as const, @@ -372,6 +411,7 @@ export class AgentIdentityManager { const registerReq = { name: `codeoid/${agentType}/${agentId.slice(0, 8)}`, external_id: externalId, + ...this.#expiry(), identity_type: "agent" as const, sub_type: "tool_agent" as const, trust_level: "first_party" as const, @@ -589,6 +629,24 @@ export class AgentIdentityManager { return this.#agents.get(sessionId)?.wimseUri; } + /** + * The credential a session's agent should present on LLM-gateway calls + * (forge#111) — its own api_key, which ZeroID resolves to a token carrying + * `AGENT_TOOL_SCOPES`. + * + * The api_key rather than the stored access token, because that is the shape + * the gateway's `x-highflame-apikey` header already carries: Forge puts the + * sandbox badge's api_key there and the gateway exchanges it. Handing back + * the same shape keeps the swap a substitution rather than a protocol change. + * + * Undefined when no identity is registered for the session — registration is + * best-effort by design, and the caller leaves the launch-time credential + * alone rather than inventing one. + */ + getGatewayCredential(sessionId: string): string | undefined { + return this.#agents.get(sessionId)?.apiKey; + } + // ── Conductor identity (durable, owner-delegated — design R1/R2) ────── /** The conductor's stable WIMSE URI, when one is registered/resumed. */ @@ -624,6 +682,7 @@ export class AgentIdentityManager { const registerReq = { name: "codeoid/conductor", external_id: externalId, + ...this.#expiry(), identity_type: "agent" as const, sub_type: "orchestrator" as const, trust_level: "first_party" as const, diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index abcf254..1a5a8ec 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -43,7 +43,7 @@ import type { CodeoidConfig } from "../../../config.js"; import type { AuthContext } from "../../../protocol/types.js"; import type { SessionProvider, ModelInfo, NormalizedTurnResult, ProviderEvent, SessionScopedEvent, TurnOpts, TurnRun } from "../interface.js"; import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from "../canonical.js"; -import { buildSubprocessEnv } from "../env.js"; +import { buildSubprocessEnv, withGatewayCredential } from "../env.js"; import type { LLMCallUsage } from "../../context-math.js"; import type { PackSubagent } from "../../pipeline/subagents.js"; @@ -521,7 +521,16 @@ export class ClaudeProvider implements SessionProvider { // Explicit env allowlist — never inherit the daemon's full process.env // (which holds the root ZeroID key + control-channel secrets). See // buildAgentEnv (GHSA-38vh vector 3). - env: buildAgentEnv(), + // + // The gateway credential is resolved HERE rather than at construction: + // the session identity is registered on the first send + // (`#ensureAgentIdentity`, gated on `!provider.hasQueried`), so it does + // not exist when this provider is built — and reading it too early + // would silently leave every call on the badge, which is the bug + // (forge#111) rather than a partial fix for it. + env: buildAgentEnv(process.env, { + gatewayCredential: init.identityManager?.getGatewayCredential(init.sessionId), + }), allowedTools: [ // Every EXACT TOOL NAME here is auto-approved by the SDK BEFORE // canUseTool is consulted, so it never reaches our gate — the SDK @@ -1492,13 +1501,21 @@ export function parseMcpServerConfig(value: unknown): McpServerConfig | null { * Vertex) can extend the allowlist via `CODEOID_AGENT_ENV_ALLOW` (comma- * separated names) without reopening the hole for unrelated secrets. * + * `gatewayCredential` re-points a Highflame-gateway launch at the session's own + * identity instead of the sandbox badge Forge wired in (forge#111) — see + * `withGatewayCredential`, which is a no-op on every other deployment. + * * Pure + exported for unit testing. */ -export function buildAgentEnv(base: Record = process.env): Record { +export function buildAgentEnv( + base: Record = process.env, + opts: { gatewayCredential?: string } = {}, +): Record { // The CLI's own namespaces (auth token, base url, model, feature flags) // plus POSIX locale categories (LC_ALL, LC_CTYPE, …). Shared basics + // CODEOID_AGENT_ENV_ALLOW come from buildSubprocessEnv. - return buildSubprocessEnv({ prefixes: ["ANTHROPIC_", "CLAUDE_", "LC_"] }, base); + const env = buildSubprocessEnv({ prefixes: ["ANTHROPIC_", "CLAUDE_", "LC_"] }, base); + return withGatewayCredential(env, opts.gatewayCredential); } export function extractToolResultText(content: unknown): string { diff --git a/src/daemon/providers/env.ts b/src/daemon/providers/env.ts index 2fa9177..b0d44a2 100644 --- a/src/daemon/providers/env.ts +++ b/src/daemon/providers/env.ts @@ -80,6 +80,73 @@ export function buildSubprocessEnv( return out; } +/** + * The header Highflame's AI gateway (firehog) authenticates a sandboxed agent + * on. Forge injects it at launch carrying the SANDBOX BADGE; see + * `withGatewayCredential` for why that is the wrong credential to keep. + */ +const GATEWAY_HEADER = "x-highflame-apikey"; + +/** + * Re-point the LLM-gateway credential at the identity actually doing the work. + * + * Forge launches a sandbox with the per-sandbox BADGE wired 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 — `nhi:manage` + * so codeoid can register its own identities, plus the `session:*` / `fs:read` + * / `pipeline:*` web-operator set — and it holds no `tools:*` at all. + * + * Shield checks the privilege ceiling before Cedar and before any detector: + * `process_prompt` requires `tools:read`. So every LLM call a sandbox made was + * denied outright — "token missing required scope tools:read" — while the + * correct credential, the per-session identity registered with the full + * `tools:*` set, sat unused a few hundred milliseconds away (forge#111). + * + * Widening the badge would have been the wrong repair: those scopes would then + * be held sandbox-wide for the badge's whole life, which is exactly what the + * per-session identity exists to avoid. The credential moves instead. + * + * Two deliberate rules: + * + * - **Only rewrites what is already there.** No `x-highflame-apikey` header + * means this is not a gateway launch — a local run, or direct-to-provider + * mode — and nothing is added. This function cannot put a credential + * somewhere one was not already flowing. + * - **`ANTHROPIC_AUTH_TOKEN` moves only when it is the badge.** Forge sets it + * to the badge only in the key-free case, where the badge doubles as the + * CLI's boot credential; when the user brought BYOK or a subscription token + * it is *theirs* and rides through the gateway to the provider untouched. + * Equality with the gateway header is what tells the two apart, so a user's + * own credential is never silently swapped for an agent identity. + * + * Pure; `env` is not mutated. + */ +export function withGatewayCredential( + env: Record, + credential: string | undefined, +): Record { + const headers = env.ANTHROPIC_CUSTOM_HEADERS; + if (!credential || !headers) return env; + + let badge: string | undefined; + // Claude Code takes newline-separated `Name: value` pairs. + const rewritten = headers + .split("\n") + .map((line) => { + const sep = line.indexOf(":"); + if (sep < 0) return line; + if (line.slice(0, sep).trim().toLowerCase() !== GATEWAY_HEADER) return line; + badge = line.slice(sep + 1).trim(); + return `${line.slice(0, sep)}: ${credential}`; + }) + .join("\n"); + if (badge === undefined) return env; + + const out: Record = { ...env, ANTHROPIC_CUSTOM_HEADERS: rewritten }; + if (out.ANTHROPIC_AUTH_TOKEN === badge) out.ANTHROPIC_AUTH_TOKEN = credential; + return out; +} + /** * Environment for the `pi --mode rpc` subprocess. * diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 1b288f5..2b1400f 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -158,6 +158,9 @@ export interface DaemonConfig { projectId: string; /** ZeroID registrar key (zid_sk_*) authenticating agent registration. */ registrarKey?: string; + /** RFC3339 expiry of the identity `registrarKey` belongs to — every child + * identity is capped to it, so none outlives the sandbox (forge#110). */ + identityExpiresAt?: string; }; /** Memory config — when present, episodes are stored and recall() is exposed to Claude. */ memory?: { @@ -267,6 +270,7 @@ export class DaemonServer { accountId: config.agentIdentity.accountId, projectId: config.agentIdentity.projectId, registrarKey: config.agentIdentity.registrarKey, + identityExpiresAt: config.agentIdentity.identityExpiresAt, }, this.#store, ); diff --git a/src/tests/agent-identity-conductor.test.ts b/src/tests/agent-identity-conductor.test.ts index f2feb23..1039808 100644 --- a/src/tests/agent-identity-conductor.test.ts +++ b/src/tests/agent-identity-conductor.test.ts @@ -583,3 +583,84 @@ describe("AgentIdentityManager session + sub-agent registration", () => { expect(rootFallback).toBeUndefined(); }); }); + +/** + * forge#110 — a sandbox badge holding `nhi:manage` could register children with + * arbitrary `allowed_scopes` and NO expiry. `system:expired_sweep` deactivated + * the badge and left the child `active`: a permanent `tools:*` credential in + * the user's project, attributed to the launching human, outliving the sandbox + * it was minted inside. + * + * ZeroID applies no register-time ceiling of its own (`allowed_scopes` passes + * through unvalidated, and the tenant `default` credential policy's empty + * `allowed_scopes` reads as "unrestricted"), so the lifetime cap is what bounds + * the over-grant to the sandbox that opened it. + */ +describe("child identities expire with the sandbox (forge#110)", () => { + let tmpDir: string; + let store: Store; + let zeroid: FakeZeroID; + + const EXPIRES = "2026-08-28T22:05:00Z"; + const sandboxConfig = { + auth: { baseUrl: BASE_URL }, + accountId: ACCOUNT, + projectId: PROJECT, + // No registrarKey: the cap is independent of how admin calls authenticate, + // and the fake's api_key grant only knows keys it minted itself. + identityExpiresAt: EXPIRES, + }; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "codeoid-child-expiry-")); + store = new Store(join(tmpDir, "store.db")); + zeroid = new FakeZeroID(); + zeroid.install(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + store.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("a session agent is capped at the badge's expiry", async () => { + const manager = new AgentIdentityManager(sandboxConfig, store); + await manager.registerSessionAgent("sess-1234abcd", "My_Test", "user:owner@test"); + + expect(zeroid.registerCalls[0]!.expires_at).toBe(EXPIRES); + }); + + test("so are workers, sub-agents and the conductor — every registration path", async () => { + const manager = new AgentIdentityManager(sandboxConfig, store); + await manager.registerConductor("user:owner@test"); + await manager.registerWorker("sess-worker01", "ship-it", "ship"); + const parent = await manager.registerSessionAgent( + "sess-1234abcd", + "My_Test", + "user:owner@test", + ); + expect(parent.token).toBeTruthy(); + await manager.registerSubagent("sess-1234abcd", "agentABCDEF012345", "Explore"); + + // Every one of them, not just the first — a single uncapped path is all a + // prompt-injected agent needs. + expect(zeroid.registerCalls.length).toBeGreaterThanOrEqual(4); + for (const call of zeroid.registerCalls) { + expect(call.expires_at).toBe(EXPIRES); + } + }); + + test("outside a sandbox the field is omitted, not sent empty", async () => { + // No badge to inherit a ceiling from. Sending an explicit null/"" would be + // this daemon asserting "no expiry" — the behaviour being fixed — rather + // than leaving ZeroID's own default in charge. + const manager = new AgentIdentityManager( + { auth: { baseUrl: BASE_URL }, accountId: ACCOUNT, projectId: PROJECT }, + store, + ); + await manager.registerSessionAgent("sess-1234abcd", "My_Test", "user:owner@test"); + + expect(zeroid.registerCalls[0]).not.toHaveProperty("expires_at"); + }); +}); diff --git a/src/tests/provider-env.test.ts b/src/tests/provider-env.test.ts index 7a25dd5..53f924b 100644 --- a/src/tests/provider-env.test.ts +++ b/src/tests/provider-env.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from "bun:test"; -import { buildPiEnv, buildSubprocessEnv } from "../daemon/providers/env.js"; +import { buildPiEnv, buildSubprocessEnv, withGatewayCredential } from "../daemon/providers/env.js"; import { buildAgentEnv } from "../daemon/providers/claude/index.js"; const DAEMON_ENV: Record = { @@ -105,3 +105,76 @@ describe("buildAgentEnv (claude) delegation", () => { expect(env.TELEGRAM_BOT_TOKEN).toBeUndefined(); }); }); + +/** + * forge#111 — a sandbox sent the BADGE on gateway LLM calls, and the badge + * holds no `tools:*`, so Shield denied every request before detectors or Cedar + * ran. The per-session identity with the right scopes existed and went unused. + */ +describe("withGatewayCredential (forge#111)", () => { + const SANDBOX_ENV: Record = { + ANTHROPIC_BASE_URL: "https://gateway.highflame.ai", + ANTHROPIC_CUSTOM_HEADERS: "x-highflame-apikey: zid_sk_badge", + ANTHROPIC_AUTH_TOKEN: "zid_sk_badge", + }; + + it("sends the session credential, not the badge", () => { + const env = withGatewayCredential(SANDBOX_ENV, "zid_sk_session"); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-highflame-apikey: zid_sk_session"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("zid_sk_session"); + }); + + it("leaves a BYOK auth token alone", () => { + // Forge sets ANTHROPIC_AUTH_TOKEN to the badge ONLY when the user brought + // no key. A different value means it is the user's own credential, riding + // through the gateway to the provider — swapping it for an agent identity + // would break the launch mode it belongs to. + const env = withGatewayCredential( + { ...SANDBOX_ENV, ANTHROPIC_AUTH_TOKEN: "sk-ant-users-own-key" }, + "zid_sk_session", + ); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-highflame-apikey: zid_sk_session"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-ant-users-own-key"); + }); + + it("adds nothing when the launch is not gateway-routed", () => { + // No x-highflame-apikey = a local run or direct-to-provider mode. This must + // never CREATE a credential path that was not already there. + const plain = { ANTHROPIC_API_KEY: "ant-key" }; + expect(withGatewayCredential(plain, "zid_sk_session")).toEqual(plain); + }); + + it("is a no-op without a session credential", () => { + // Identity registration is best-effort; when it failed there is nothing + // better to send, so the launch-time credential is left exactly as-is. + expect(withGatewayCredential(SANDBOX_ENV, undefined)).toEqual(SANDBOX_ENV); + }); + + it("rewrites only the gateway header, and matches it case-insensitively", () => { + const env = withGatewayCredential( + { + ANTHROPIC_CUSTOM_HEADERS: "X-Highflame-Apikey: zid_sk_badge\nX-Trace-Id: abc123", + ANTHROPIC_AUTH_TOKEN: "zid_sk_badge", + }, + "zid_sk_session", + ); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe( + "X-Highflame-Apikey: zid_sk_session\nX-Trace-Id: abc123", + ); + }); + + it("does not mutate the env it was given", () => { + const original = { ...SANDBOX_ENV }; + withGatewayCredential(SANDBOX_ENV, "zid_sk_session"); + expect(SANDBOX_ENV).toEqual(original); + }); + + it("reaches the claude subprocess through buildAgentEnv", () => { + const env = buildAgentEnv( + { ...SANDBOX_ENV, PATH: "/usr/bin" }, + { gatewayCredential: "zid_sk_session" }, + ); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-highflame-apikey: zid_sk_session"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("zid_sk_session"); + }); +});