From 35d764076e09a8d7f9c4e2c3495eb213415e69db Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:19:17 -0500 Subject: [PATCH] fix(audit): ignore inherited execution identity evidence (#122418) * fix(audit): require owned admission data * docs(agents): preserve owned admission invariant --- AGENTS.md | 2 +- src/audit/audit-event-writer.test.ts | 192 ++++++-- .../execution-identity-admission.test.ts | 455 ++++++++++++++++++ src/audit/execution-identity-admission.ts | 245 +++++----- 4 files changed, 753 insertions(+), 141 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6780fca92ed..ce6f603f0d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m - Execution identity is opt-in diagnostic provenance, never authorization or enforcement. Unknown facts stay unknown; record ingress or invoker facts only at their authoritative producer. Never infer identity from session keys, `runId`, or routing metadata. - Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence. - Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent. -- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. +- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. - Raw identity references are transient worker-message data. Never persist, export, inspect, or log them. Public Plugin SDK ingress must strip private recovery/admission authority, including JavaScript extra and inherited properties. - Default or disabled collection creates and propagates no identity token and does not create optional storage. Existing-storage maintenance may continue. Reads enforce expiry before projection; missing or expired evidence never proves no run occurred. - `audit.run.inspect` intentionally uses `operator.read` within one trusted Gateway domain. Reader isolation requires separate domains. Ask before changing this scope, default-off behavior, retained fields, 30-day cutoff, maintenance/row bounds, or schema/protocol contract. diff --git a/src/audit/audit-event-writer.test.ts b/src/audit/audit-event-writer.test.ts index b811c80313ed..586476ee05b5 100644 --- a/src/audit/audit-event-writer.test.ts +++ b/src/audit/audit-event-writer.test.ts @@ -19,6 +19,11 @@ import { processExecutionIdentityAdmissionWork, } from "./execution-identity-context.js"; +function defineObjectPrototypeProperties(descriptors: PropertyDescriptorMap): void { + // oxlint-disable-next-line no-extend-native -- Exercise hostile prototype pollution across the real worker boundary. + Object.defineProperties(Object.prototype, descriptors); +} + function captureExecutionIdentityAdmissionEnvelope( facts: ExecutionIdentityAdmissionFacts, options: { @@ -229,45 +234,151 @@ describe("audit event worker", () => { } }); - it("preserves explicit unknown invoker evidence through the worker clone boundary", async () => { + it("persists owned unknown and omits inherited evidence through the worker clone boundary", async () => { const stateDir = tempDirs.make("openclaw-audit-writer-"); const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; const errors: string[] = []; const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); const clearSink = configureExecutionIdentityAdmissionSink(writer.recordExecutionIdentity); const admittedAt = Date.now(); - - expect( - enqueueExecutionIdentityContextAtAdmission( - { - runId: "unknown-invoker-run", - agentId: "main", - ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, - runtime: { kind: "embedded" }, - invoker: { state: "unknown" }, - }, - { - enabled: true, - contextId: "unknown-invoker-context", - executionId: "unknown-invoker-execution", - now: admittedAt, - runtimeInstanceId: "private-runtime-reference", - }, - ), - ).toEqual({ - candidateContextId: "unknown-invoker-context", - candidateExecutionId: "unknown-invoker-execution", - accepted: true, - }); - clearSink(); - await writer.stop(); - - const inspected = inspectExecutionIdentityRun( - { executionId: "unknown-invoker-execution" }, - { ...database, now: admittedAt }, + const inheritedRefs = { + invoker: "raw-inherited-principal", + applicableGrants: "raw-inherited-grant", + assurance: "raw-inherited-assurance", + rawSourceRef: "raw-inherited-source", + } as const; + const prior = new Map( + Object.keys(inheritedRefs).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ]), ); + let inheritedInvokerReads = 0; + + try { + try { + defineObjectPrototypeProperties({ + invoker: { + configurable: true, + enumerable: false, + get: () => { + inheritedInvokerReads += 1; + return { + state: "present", + kind: "local-account", + rawPrincipalRef: inheritedRefs.invoker, + }; + }, + }, + applicableGrants: { + configurable: true, + enumerable: false, + value: [{ rawGrantRef: inheritedRefs.applicableGrants, state: "present" }], + }, + assurance: { + configurable: true, + enumerable: false, + value: [ + { + kind: "other", + rawEvidenceRef: inheritedRefs.assurance, + strength: "self-asserted", + }, + ], + }, + rawSourceRef: { + configurable: true, + enumerable: false, + value: inheritedRefs.rawSourceRef, + }, + }); + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "absent-invoker-run", + agentId: "main", + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + }, + runtime: { kind: "embedded" }, + }, + { + enabled: true, + contextId: "absent-invoker-context", + executionId: "absent-invoker-execution", + now: admittedAt, + runtimeInstanceId: "private-absent-runtime-reference", + }, + ), + ).toEqual({ + candidateContextId: "absent-invoker-context", + candidateExecutionId: "absent-invoker-execution", + accepted: true, + }); + } finally { + for (const [key, descriptor] of prior) { + if (descriptor) { + defineObjectPrototypeProperties({ [key]: descriptor }); + } else { + delete (Object.prototype as Record)[key]; + } + } + } + + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "unknown-invoker-run", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + runtime: { kind: "embedded" }, + invoker: { state: "unknown" }, + }, + { + enabled: true, + contextId: "unknown-invoker-context", + executionId: "unknown-invoker-execution", + now: admittedAt + 1, + runtimeInstanceId: "private-unknown-runtime-reference", + }, + ), + ).toEqual({ + candidateContextId: "unknown-invoker-context", + candidateExecutionId: "unknown-invoker-execution", + accepted: true, + }); + } finally { + clearSink(); + await writer.stop(); + } + + const absentInspection = inspectExecutionIdentityRun( + { executionId: "absent-invoker-execution" }, + { ...database, now: admittedAt + 1 }, + ); + const unknownInspection = inspectExecutionIdentityRun( + { executionId: "unknown-invoker-execution" }, + { ...database, now: admittedAt + 1 }, + ); + expect(inheritedInvokerReads).toBe(0); expect(errors).toEqual([]); - expect(inspected).toMatchObject({ + expect(absentInspection).toMatchObject({ + identity: { + state: "present", + context: { + invoker: { state: "absent" }, + ingress: { state: "present" }, + applicableGrants: [], + assurance: [{ kind: "runtime-binding", strength: "boundary-verified" }], + coverageState: "unattributed", + missingEvidence: ["invoker.principal"], + }, + }, + coverage: { state: "unattributed", missingEvidence: ["invoker.principal"] }, + }); + expect(unknownInspection).toMatchObject({ identity: { state: "present", context: { @@ -278,7 +389,24 @@ describe("audit event worker", () => { }, coverage: { state: "unknown", missingEvidence: ["invoker.principal"] }, }); - expect(JSON.stringify(inspected)).not.toContain("private-runtime-reference"); + const persisted = openOpenClawStateDatabase(database) + .db.prepare( + "SELECT context_json FROM execution_identity_contexts WHERE execution_id IN (?, ?) ORDER BY execution_id", + ) + .all("absent-invoker-execution", "unknown-invoker-execution") as Array<{ + context_json: string; + }>; + const publicAndStored = JSON.stringify({ + errors, + absentInspection, + unknownInspection, + persisted, + }); + for (const rawRef of Object.values(inheritedRefs)) { + expect(publicAndStored).not.toContain(rawRef); + } + expect(publicAndStored).not.toContain("private-absent-runtime-reference"); + expect(publicAndStored).not.toContain("private-unknown-runtime-reference"); }); it("prunes expired identity contexts before preserving exact-envelope conflicts", async () => { diff --git a/src/audit/execution-identity-admission.test.ts b/src/audit/execution-identity-admission.test.ts index 62b15df291bd..662be64231a8 100644 --- a/src/audit/execution-identity-admission.test.ts +++ b/src/audit/execution-identity-admission.test.ts @@ -5,6 +5,7 @@ import { enqueueExecutionIdentityContextAtAdmission, hasExecutionIdentityAdmissionSink, parseExecutionIdentityAdmissionEnvelope, + parseExecutionIdentityAdmissionWork, type ExecutionIdentityAdmissionEnvelope, type ExecutionIdentityAdmissionFacts, type ExecutionIdentityAdmissionWork, @@ -13,6 +14,22 @@ import { const ADMISSION_MAX_BYTES = 16 * 1024; const ADMISSION_MAX_ITEMS = 16; +function defineObjectPrototypeProperty(key: string, descriptor: PropertyDescriptor): void { + // oxlint-disable-next-line no-extend-native -- Exercise hostile prototype pollution at the admission boundary. + Object.defineProperty(Object.prototype, key, descriptor); +} + +function restoreObjectPrototypeProperty( + key: string, + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor) { + defineObjectPrototypeProperty(key, descriptor); + } else { + delete (Object.prototype as Record)[key]; + } +} + function facts(overrides: Partial = {}) { return { runId: "run-1", @@ -150,6 +167,379 @@ describe("execution identity admission envelope", () => { } }); + it("omits inherited outer evidence instead of projecting it", () => { + const inheritedRefs = { + invoker: { state: "unknown" }, + applicableGrants: [{ rawGrantRef: "inherited-grant", state: "present" }], + assurance: [ + { + kind: "other", + rawEvidenceRef: "inherited-assurance", + strength: "self-asserted", + }, + ], + } as const; + const prior = new Map( + Object.keys(inheritedRefs).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ]), + ); + let envelope: ExecutionIdentityAdmissionEnvelope; + try { + for (const [key, value] of Object.entries(inheritedRefs)) { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + value, + writable: true, + }); + } + envelope = captureEnvelope(facts(), { + contextId: "context-inherited", + executionId: "execution-inherited", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + } finally { + for (const [key, descriptor] of prior) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + + expect(Object.hasOwn(envelope!, "invoker")).toBe(false); + expect(envelope!.applicableGrants).toEqual([]); + expect(envelope!.assurance).toEqual([ + { + kind: "runtime-binding", + rawEvidenceRef: "runtime-owned", + strength: "boundary-verified", + }, + ]); + }); + + it("never reads inherited accessors while treating optional evidence as omitted", () => { + const keys = ["invoker", "applicableGrants", "assurance"] as const; + const prior = new Map( + keys.map((key) => [key, Object.getOwnPropertyDescriptor(Object.prototype, key)]), + ); + const getterReads = new Map(keys.map((key) => [key, 0])); + let envelope: ExecutionIdentityAdmissionEnvelope; + try { + for (const key of keys) { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + getterReads.set(key, getterReads.get(key)! + 1); + return key === "invoker" + ? { state: "unknown" } + : key === "applicableGrants" + ? [{ rawGrantRef: "inherited-grant", state: "present" }] + : [ + { + kind: "other", + rawEvidenceRef: "inherited-assurance", + strength: "self-asserted", + }, + ]; + }, + }); + } + envelope = captureEnvelope(facts(), { + contextId: "context-inherited-getter", + executionId: "execution-inherited-getter", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + } finally { + for (const [key, descriptor] of prior) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + + expect(Object.fromEntries(getterReads)).toEqual({ + invoker: 0, + applicableGrants: 0, + assurance: 0, + }); + expect(Object.hasOwn(envelope!, "invoker")).toBe(false); + expect(envelope!.applicableGrants).toEqual([]); + expect(envelope!.assurance).toEqual([ + { + kind: "runtime-binding", + rawEvidenceRef: "runtime-owned", + strength: "boundary-verified", + }, + ]); + }); + + it.each([ + { + name: "ingress state", + key: "state", + value: "unknown", + admissionFacts: () => facts(), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(envelope.ingress.state).toBe("present"); + }, + }, + { + name: "ingress source", + key: "rawSourceRef", + value: "inherited-source", + admissionFacts: () => facts(), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(Object.hasOwn(envelope.ingress, "rawSourceRef")).toBe(false); + }, + }, + { + name: "invoker label", + key: "displayLabel", + value: "inherited-label", + admissionFacts: () => + facts({ + invoker: { + state: "present", + kind: "local-account", + rawPrincipalRef: "owned-principal", + }, + }), + assertOmitted: (envelope: ExecutionIdentityAdmissionEnvelope) => { + expect(envelope.invoker?.state).toBe("present"); + expect(Object.hasOwn(envelope.invoker!, "displayLabel")).toBe(false); + }, + }, + ])("omits inherited optional $name data", ({ key, value, admissionFacts, assertOmitted }) => { + const prior = Object.getOwnPropertyDescriptor(Object.prototype, key); + let dataEnvelope: ExecutionIdentityAdmissionEnvelope; + let getterEnvelope: ExecutionIdentityAdmissionEnvelope; + let getterReads = 0; + try { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + value, + writable: true, + }); + dataEnvelope = captureEnvelope(admissionFacts(), { + contextId: `context-${key}`, + executionId: `execution-${key}`, + now: 1, + runtimeInstanceId: "runtime-owned", + }); + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + getterReads += 1; + return value; + }, + }); + getterEnvelope = captureEnvelope(admissionFacts(), { + contextId: `context-${key}-getter`, + executionId: `execution-${key}-getter`, + now: 2, + runtimeInstanceId: "runtime-owned", + }); + } finally { + restoreObjectPrototypeProperty(key, prior); + } + expect(getterReads).toBe(0); + assertOmitted(dataEnvelope!); + assertOmitted(getterEnvelope!); + }); + + it.each([ + ["outer run id", "runId", "inherited-run", () => omitOwn(facts(), "runId")], + ["outer agent id", "agentId", "inherited-agent", () => omitOwn(facts(), "agentId")], + ["outer ingress", "ingress", facts().ingress, () => omitOwn(facts(), "ingress")], + ["outer runtime", "runtime", facts().runtime, () => omitOwn(facts(), "runtime")], + [ + "ingress kind", + "kind", + "local-cli", + () => facts({ ingress: { boundary: "agent-command.local" } as never }), + ], + [ + "ingress boundary", + "boundary", + "agent-command.local", + () => facts({ ingress: { kind: "local-cli" } as never }), + ], + ["invoker state", "state", "unknown", () => facts({ invoker: {} as never })], + [ + "invoker kind", + "kind", + "local-account", + () => facts({ invoker: { state: "present", rawPrincipalRef: "owned" } as never }), + ], + [ + "invoker principal", + "rawPrincipalRef", + "inherited-principal", + () => facts({ invoker: { state: "present", kind: "local-account" } as never }), + ], + [ + "grant reference", + "rawGrantRef", + "inherited-grant", + () => facts({ applicableGrants: [{ state: "present" } as never] }), + ], + [ + "grant state", + "state", + "present", + () => facts({ applicableGrants: [{ rawGrantRef: "owned-grant" } as never] }), + ], + [ + "assurance kind", + "kind", + "other", + () => + facts({ + assurance: [{ rawEvidenceRef: "owned-evidence", strength: "self-asserted" } as never], + }), + ], + [ + "assurance reference", + "rawEvidenceRef", + "inherited-evidence", + () => facts({ assurance: [{ kind: "other", strength: "self-asserted" } as never] }), + ], + [ + "assurance strength", + "strength", + "self-asserted", + () => facts({ assurance: [{ kind: "other", rawEvidenceRef: "owned-evidence" } as never] }), + ], + ] as const)( + "rejects inherited required $0 before allocation and enqueue", + (_name, key, inheritedValue, admissionFacts) => { + const prior = Object.getOwnPropertyDescriptor(Object.prototype, key); + let inheritedReads = 0; + let allocationReads = 0; + const sink = vi.fn(() => true); + const clear = configureExecutionIdentityAdmissionSink(sink); + try { + defineObjectPrototypeProperty(key, { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return inheritedValue; + }, + }); + const options = { enabled: true, runtimeInstanceId: "runtime-owned" }; + Object.defineProperty(options, "contextId", { + enumerable: true, + get: () => { + allocationReads += 1; + return "must-not-allocate"; + }, + }); + expect( + enqueueExecutionIdentityContextAtAdmission(admissionFacts() as never, options), + ).toBeUndefined(); + } finally { + clear(); + restoreObjectPrototypeProperty(key, prior); + } + expect(inheritedReads).toBe(0); + expect(allocationReads).toBe(0); + expect(sink).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + name: "outer ingress", + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts, key: "ingress" }; + }, + }, + ...["invoker", "applicableGrants", "assurance"].map((key) => ({ + name: `outer ${key}`, + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts, key }; + }, + })), + ...["kind", "boundary", "state", "rawSourceRef"].map((key) => ({ + name: `ingress ${key}`, + prepare: () => { + const admissionFacts = facts(); + return { admissionFacts, target: admissionFacts.ingress, key }; + }, + })), + ...["state", "kind", "rawPrincipalRef", "displayLabel"].map((key) => ({ + name: `invoker ${key}`, + prepare: () => { + const invoker = { + state: "present" as const, + kind: "local-account" as const, + rawPrincipalRef: "owned-principal", + displayLabel: "owned-label", + }; + const admissionFacts = facts({ invoker }); + return { admissionFacts, target: invoker, key }; + }, + })), + ...["rawGrantRef", "state"].map((key) => ({ + name: `grant ${key}`, + prepare: () => { + const grant = { rawGrantRef: "owned-grant", state: "present" as const }; + const admissionFacts = facts({ applicableGrants: [grant] }); + return { admissionFacts, target: grant, key }; + }, + })), + ...["kind", "rawEvidenceRef", "strength"].map((key) => ({ + name: `assurance ${key}`, + prepare: () => { + const assurance = { + kind: "other" as const, + rawEvidenceRef: "owned-evidence", + strength: "self-asserted" as const, + }; + const admissionFacts = facts({ assurance: [assurance] }); + return { admissionFacts, target: assurance, key }; + }, + })), + ])("rejects an own accessor at $name without reading it or allocating", ({ prepare }) => { + const { admissionFacts, target, key } = prepare(); + let accessorReads = 0; + let allocationReads = 0; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get: () => { + accessorReads += 1; + return "must-not-read"; + }, + }); + const options = { enabled: true, runtimeInstanceId: "runtime-owned" }; + Object.defineProperty(options, "contextId", { + enumerable: true, + get: () => { + allocationReads += 1; + return "must-not-allocate"; + }, + }); + const sink = vi.fn(() => true); + const clear = configureExecutionIdentityAdmissionSink(sink); + try { + expect( + enqueueExecutionIdentityContextAtAdmission(admissionFacts as never, options), + ).toBeUndefined(); + } finally { + clear(); + } + expect(accessorReads).toBe(0); + expect(allocationReads).toBe(0); + expect(sink).not.toHaveBeenCalled(); + }); + it("rejects malformed, ambiguous, oversized, and noncanonical invoker variants", () => { const present = captureEnvelope( facts({ @@ -271,6 +661,65 @@ describe("execution identity admission envelope", () => { expect(accessorReads).toBe(0); }); + it("revalidates envelopes and worker messages from owned data only", () => { + const envelope = captureEnvelope(facts(), { + contextId: "context-revalidation", + executionId: "execution-revalidation", + now: 1, + runtimeInstanceId: "runtime-owned", + }); + const priorInvoker = Object.getOwnPropertyDescriptor(Object.prototype, "invoker"); + const priorIngress = Object.getOwnPropertyDescriptor(Object.prototype, "ingress"); + const priorKind = Object.getOwnPropertyDescriptor(Object.prototype, "kind"); + let inheritedReads = 0; + let parsed: ExecutionIdentityAdmissionEnvelope; + try { + defineObjectPrototypeProperty("invoker", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return { state: "unknown" }; + }, + }); + parsed = parseExecutionIdentityAdmissionEnvelope(envelope); + + defineObjectPrototypeProperty("ingress", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return envelope.ingress; + }, + }); + expect(() => parseExecutionIdentityAdmissionEnvelope(omitOwn(envelope, "ingress"))).toThrow( + "execution identity admission envelope violates its bounded contract", + ); + + defineObjectPrototypeProperty("kind", { + configurable: true, + enumerable: false, + get: () => { + inheritedReads += 1; + return "capture"; + }, + }); + expect(() => parseExecutionIdentityAdmissionWork({ envelope } as never)).toThrow( + "execution identity admission work violates its bounded contract", + ); + } finally { + for (const [key, descriptor] of [ + ["invoker", priorInvoker], + ["ingress", priorIngress], + ["kind", priorKind], + ] as const) { + restoreObjectPrototypeProperty(key, descriptor); + } + } + expect(inheritedReads).toBe(0); + expect(Object.hasOwn(parsed!, "invoker")).toBe(false); + }); + it("rejects invalid owned facts, excess items, and oversized encoded envelopes", () => { expect(() => captureEnvelope(facts({ runId: "" }), { @@ -394,3 +843,9 @@ describe("execution identity admission envelope", () => { expect(JSON.stringify(work.mock.calls)).not.toContain("raw-private-reference"); }); }); + +function omitOwn(value: T, key: K): Omit { + const copy = { ...value }; + delete copy[key]; + return copy; +} diff --git a/src/audit/execution-identity-admission.ts b/src/audit/execution-identity-admission.ts index 1f437ccd5b95..910f8980752a 100644 --- a/src/audit/execution-identity-admission.ts +++ b/src/audit/execution-identity-admission.ts @@ -25,6 +25,58 @@ const evidenceState = () => const closedObject = [0]>(properties: T) => Type.Object(properties, { additionalProperties: false }); +const ingressKind = () => + Type.Union([ + Type.Literal("local-cli"), + Type.Literal("gateway-client"), + Type.Literal("channel"), + Type.Literal("api"), + Type.Literal("schedule"), + Type.Literal("webhook"), + Type.Literal("task"), + Type.Literal("subagent"), + Type.Literal("acp"), + Type.Literal("worker"), + Type.Literal("plugin"), + Type.Literal("recovery"), + Type.Literal("system"), + ]); +const runtimeKind = () => + Type.Union([ + Type.Literal("gateway"), + Type.Literal("embedded"), + Type.Literal("worker"), + Type.Literal("plugin-harness"), + Type.Literal("acp"), + ]); +const admissionGrant = () => closedObject({ rawGrantRef: rawRef(), state: evidenceState() }); +const admissionGrants = () => + Type.Array(admissionGrant(), { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }); +const admissionAssurance = () => + Type.Array( + closedObject({ + kind: Type.Union([ + Type.Literal("durable-profile"), + Type.Literal("trusted-proxy"), + Type.Literal("tailscale-whois"), + Type.Literal("device-proof"), + Type.Literal("channel-admission"), + Type.Literal("local-process"), + Type.Literal("spawn-lineage"), + Type.Literal("worker-admission"), + Type.Literal("runtime-binding"), + Type.Literal("other"), + ]), + rawEvidenceRef: rawRef(), + strength: Type.Union([ + Type.Literal("self-asserted"), + Type.Literal("boundary-verified"), + Type.Literal("cryptographic"), + ]), + }), + { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }, + ); + const ExecutionIdentityAdmissionInvokerSchema = Type.Union([ closedObject({ state: Type.Literal("present"), @@ -53,61 +105,32 @@ const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({ runtimeInstanceId: rawRef(), agentId: boundedRef(), ingress: closedObject({ - kind: Type.Union([ - Type.Literal("local-cli"), - Type.Literal("gateway-client"), - Type.Literal("channel"), - Type.Literal("api"), - Type.Literal("schedule"), - Type.Literal("webhook"), - Type.Literal("task"), - Type.Literal("subagent"), - Type.Literal("acp"), - Type.Literal("worker"), - Type.Literal("plugin"), - Type.Literal("recovery"), - Type.Literal("system"), - ]), + kind: ingressKind(), boundary: boundedRef(), state: evidenceState(), rawSourceRef: Type.Optional(rawRef()), }), runtime: closedObject({ - kind: Type.Union([ - Type.Literal("gateway"), - Type.Literal("embedded"), - Type.Literal("worker"), - Type.Literal("plugin-harness"), - Type.Literal("acp"), - ]), + kind: runtimeKind(), }), invoker: Type.Optional(ExecutionIdentityAdmissionInvokerSchema), - applicableGrants: Type.Array(closedObject({ rawGrantRef: rawRef(), state: evidenceState() }), { - maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS, + applicableGrants: admissionGrants(), + assurance: admissionAssurance(), +}); + +const ExecutionIdentityAdmissionFactsSchema = closedObject({ + runId: boundedRef(), + agentId: boundedRef(), + ingress: closedObject({ + kind: ingressKind(), + boundary: boundedRef(), + state: Type.Optional(evidenceState()), + rawSourceRef: Type.Optional(rawRef()), }), - assurance: Type.Array( - closedObject({ - kind: Type.Union([ - Type.Literal("durable-profile"), - Type.Literal("trusted-proxy"), - Type.Literal("tailscale-whois"), - Type.Literal("device-proof"), - Type.Literal("channel-admission"), - Type.Literal("local-process"), - Type.Literal("spawn-lineage"), - Type.Literal("worker-admission"), - Type.Literal("runtime-binding"), - Type.Literal("other"), - ]), - rawEvidenceRef: rawRef(), - strength: Type.Union([ - Type.Literal("self-asserted"), - Type.Literal("boundary-verified"), - Type.Literal("cryptographic"), - ]), - }), - { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS }, - ), + runtime: closedObject({ kind: runtimeKind() }), + invoker: Type.Optional(ExecutionIdentityAdmissionInvokerSchema), + applicableGrants: Type.Optional(admissionGrants()), + assurance: Type.Optional(admissionAssurance()), }); const ExecutionIdentityAdmissionTokenSchema = closedObject({ @@ -166,9 +189,11 @@ function freezeEnvelope(value: T, seen = new WeakSet()): T { return Object.freeze(value); } -function assertPlainCloneData(value: unknown, ancestors = new WeakSet()): void { +// Snapshot descriptors before schema or projection: TypeBox accepts inherited +// keys, which would otherwise turn prototype data into diagnostic provenance. +function copyOwnedData(value: T, ancestors = new WeakSet()): T { if (value === null || ["string", "number", "boolean"].includes(typeof value)) { - return; + return value; } if (typeof value !== "object" || isProxy(value)) { throw new Error("execution identity admission data must be clone-safe plain data"); @@ -180,10 +205,15 @@ function assertPlainCloneData(value: unknown, ancestors = new WeakSet()) try { const prototype = Object.getPrototypeOf(value); const keys = Reflect.ownKeys(value); + const array = Array.isArray(value); if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); if ( prototype !== Array.prototype || - keys.length !== value.length + 1 || + !lengthDescriptor || + !("value" in lengthDescriptor) || + typeof lengthDescriptor.value !== "number" || + keys.length !== lengthDescriptor.value + 1 || keys.at(-1) !== "length" ) { throw new Error("execution identity admission data must be clone-safe plain data"); @@ -191,51 +221,63 @@ function assertPlainCloneData(value: unknown, ancestors = new WeakSet()) } else if (prototype !== Object.prototype && prototype !== null) { throw new Error("execution identity admission data must be clone-safe plain data"); } + const copy: unknown[] | Record = array ? [] : Object.create(null); for (const [index, key] of keys.entries()) { - if (key === "length" && Array.isArray(value)) { + if (key === "length" && array) { continue; } - if (typeof key !== "string" || (Array.isArray(value) && key !== String(index))) { + if (typeof key !== "string" || (array && key !== String(index))) { throw new Error("execution identity admission data must be clone-safe plain data"); } const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) { throw new Error("execution identity admission data must be clone-safe plain data"); } - assertPlainCloneData(descriptor.value, ancestors); + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: copyOwnedData(descriptor.value, ancestors), + writable: true, + }); } + return copy as T; } finally { ancestors.delete(value); } } -function validateEnvelope(value: unknown): asserts value is ExecutionIdentityAdmissionEnvelope { - assertPlainCloneData(value); +function validateEnvelope(value: unknown): ExecutionIdentityAdmissionEnvelope { + const owned = copyOwnedData(value); if ( - !Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, value) || - !Number.isSafeInteger(value.createdAt) + !Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, owned) || + !Number.isSafeInteger(owned.createdAt) ) { throw new Error("execution identity admission envelope violates its bounded contract"); } - const encoded = JSON.stringify(value); + const encoded = JSON.stringify(owned); if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_ADMISSION_MAX_BYTES) { throw new Error("execution identity admission envelope exceeds 16 KiB"); } + return owned; } -function validateRawInvoker(value: unknown): void { - if (value !== undefined && !Value.Check(ExecutionIdentityAdmissionInvokerSchema, value)) { - throw new Error("execution identity admission invoker violates its bounded contract"); +function validateFacts(value: unknown): ExecutionIdentityAdmissionFacts { + const owned = copyOwnedData(value); + if (!Value.Check(ExecutionIdentityAdmissionFactsSchema, owned)) { + throw new Error("execution identity admission facts violate their bounded contract"); } + return owned; } -function validateToken(value: unknown): asserts value is ExecutionIdentityAdmissionToken { +function validateToken(value: unknown): ExecutionIdentityAdmissionToken { + const owned = copyOwnedData(value); if ( - !Value.Check(ExecutionIdentityAdmissionTokenSchema, value) || - !Number.isSafeInteger(value.createdAt) + !Value.Check(ExecutionIdentityAdmissionTokenSchema, owned) || + !Number.isSafeInteger(owned.createdAt) ) { throw new Error("execution identity admission token violates its bounded contract"); } + return owned; } /** Allocate the immutable correlation owned by one outer admitted turn. */ @@ -250,15 +292,13 @@ export function createExecutionIdentityAdmissionToken( runId, createdAt: options.now ?? Date.now(), }; - validateToken(token); - return freezeEnvelope(token); + return freezeEnvelope(validateToken(token)); } export function parseExecutionIdentityAdmissionToken( value: unknown, ): ExecutionIdentityAdmissionToken { - validateToken(value); - return freezeEnvelope({ ...value }); + return freezeEnvelope(validateToken(value)); } function redactDisplayLabel(value: string): string { @@ -274,22 +314,12 @@ function redactDisplayLabel(value: string): string { function captureExecutionIdentityAdmissionEnvelope( facts: ExecutionIdentityAdmissionFacts, options: { - contextId?: string; - executionId?: string; - now?: number; runtimeInstanceId?: string; - token?: ExecutionIdentityAdmissionToken; - } = {}, + token: ExecutionIdentityAdmissionToken; + }, ): ExecutionIdentityAdmissionEnvelope { - const token = - options.token ?? - createExecutionIdentityAdmissionToken(facts.runId, { - contextId: options.contextId, - executionId: options.executionId, - now: options.now, - }); - validateToken(token); - if (token.runId !== facts.runId) { + const ownedToken = validateToken(options.token); + if (ownedToken.runId !== facts.runId) { throw new Error("execution identity admission token disagrees with the admitted run"); } const runtimeInstanceId = options.runtimeInstanceId ?? PROCESS_RUNTIME_INSTANCE_ID; @@ -302,10 +332,10 @@ function captureExecutionIdentityAdmissionEnvelope( ]; const envelope = { envelopeVersion: 1 as const, - contextId: token.contextId, - executionId: token.executionId, - runId: token.runId, - createdAt: token.createdAt, + contextId: ownedToken.contextId, + executionId: ownedToken.executionId, + runId: ownedToken.runId, + createdAt: ownedToken.createdAt, runtimeInstanceId, agentId: facts.agentId, ingress: { ...facts.ingress, state: facts.ingress.state ?? "present" }, @@ -337,24 +367,23 @@ function captureExecutionIdentityAdmissionEnvelope( strength: item.strength, })), }; - validateEnvelope(envelope); - return freezeEnvelope(envelope); + return freezeEnvelope(validateEnvelope(envelope)); } /** Revalidate a structured-cloned worker message before any persistence work. */ export function parseExecutionIdentityAdmissionEnvelope( value: unknown, ): ExecutionIdentityAdmissionEnvelope { - validateEnvelope(value); - const parsed = captureExecutionIdentityAdmissionEnvelope(value, { - token: createExecutionIdentityAdmissionToken(value.runId, { - contextId: value.contextId, - executionId: value.executionId, - now: value.createdAt, + const envelope = validateEnvelope(value); + const parsed = captureExecutionIdentityAdmissionEnvelope(envelope, { + token: createExecutionIdentityAdmissionToken(envelope.runId, { + contextId: envelope.contextId, + executionId: envelope.executionId, + now: envelope.createdAt, }), - runtimeInstanceId: value.runtimeInstanceId, + runtimeInstanceId: envelope.runtimeInstanceId, }); - if (JSON.stringify(parsed) !== JSON.stringify(value)) { + if (JSON.stringify(parsed) !== JSON.stringify(envelope)) { throw new Error("execution identity admission envelope is not canonical"); } return parsed; @@ -364,10 +393,11 @@ export function parseExecutionIdentityAdmissionEnvelope( export function parseExecutionIdentityAdmissionWork( value: unknown, ): ExecutionIdentityAdmissionWork { - if (!value || typeof value !== "object") { + const owned = copyOwnedData(value); + if (!owned || typeof owned !== "object") { throw new Error("execution identity admission work violates its bounded contract"); } - const work = value as { kind?: unknown; envelope?: unknown; token?: unknown }; + const work = owned as { kind?: unknown; envelope?: unknown; token?: unknown }; if (work.kind === "capture") { return freezeEnvelope({ kind: "capture" as const, @@ -424,21 +454,20 @@ export function enqueueExecutionIdentityContextAtAdmission( return undefined; } try { - assertPlainCloneData(facts); - validateRawInvoker(facts.invoker); - const token = + const ownedFacts = validateFacts(facts); + const token = validateToken( options.token ?? - createExecutionIdentityAdmissionToken(facts.runId, { - contextId: options.contextId, - executionId: options.executionId, - now: options.now, - }); - validateToken(token); + createExecutionIdentityAdmissionToken(ownedFacts.runId, { + contextId: options.contextId, + executionId: options.executionId, + now: options.now, + }), + ); const work: ExecutionIdentityAdmissionWork = options.retryOnly ? { kind: "retry-reference", token } : { kind: "capture", - envelope: captureExecutionIdentityAdmissionEnvelope(facts, { + envelope: captureExecutionIdentityAdmissionEnvelope(ownedFacts, { token, runtimeInstanceId: options.runtimeInstanceId, }),