Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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: {
Expand Down
59 changes: 59 additions & 0 deletions src/daemon/agent-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions src/daemon/providers/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string | undefined> = process.env): Record<string, string> {
export function buildAgentEnv(
base: Record<string, string | undefined> = process.env,
opts: { gatewayCredential?: string } = {},
): Record<string, string> {
// 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 {
Expand Down
67 changes: 67 additions & 0 deletions src/daemon/providers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
credential: string | undefined,
): Record<string, string> {
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<string, string> = { ...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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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,
);
Expand Down
81 changes: 81 additions & 0 deletions src/tests/agent-identity-conductor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading
Loading