diff --git a/AGENTS.md b/AGENTS.md index ce6f603f0d95..f39b3bd65cb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m ## Execution Identity Audit - 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. +- Frozen ingress identity facts are diagnostic audit input, not session-ownership state. Session provenance must use the current canonical authenticated profile ID and never retain a profile display label; only explicitly enabled execution-identity audit storage may retain its bounded, redacted form. - 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. 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. diff --git a/docs/cli/audit.md b/docs/cli/audit.md index 85bde77e3dde..8f7075f6094a 100644 --- a/docs/cli/audit.md +++ b/docs/cli/audit.md @@ -124,6 +124,16 @@ ingress, an absent invoker, and `unattributed` coverage. Its admission receipt says `not-applicable` because no identity-aware policy or grant evaluation was proven. +For Gateway runs, a resolved authenticated profile can make the invoker +`present` and coverage `attribution-only`. Paired devices and shared credentials +do not establish a person: without a durable profile the invoker stays absent, +or `unknown` when authenticated user evidence promised a profile that could not +be resolved. Session creation retains the live canonical durable profile id so +profile linking does not orphan ownership, while run inspection consumes the +immutable connection-time audit fact. Ordinary session provenance stores no +display label. An optional bounded, secret-redacted label can be retained only +in execution identity after that audit storage is explicitly enabled. + JSON output is the Gateway result without lossy reformatting. An exact result contains one bounded V1 context (maximum 16 KiB), up to 100 decision receipts, coverage and missing-evidence codes, and an optional `nextDecisionCursor`. An ambiguous run diff --git a/docs/gateway/audit.md b/docs/gateway/audit.md index 4593687798d3..0d725cbac00e 100644 --- a/docs/gateway/audit.md +++ b/docs/gateway/audit.md @@ -91,6 +91,20 @@ this boundary. A run becomes `attribution-only` only when an authoritative ingress supplies an invoker fact. Neither state means that identity affected an allow or deny decision. +Authenticated Gateway attach records immutable audit facts once. Session +creation separately reads the live canonical durable profile id so a profile +link performed after attach cannot orphan session ownership. Ordinary session +provenance retains that id only; it does not retain a profile display label. +When execution identity recording is explicitly enabled, its audit context may +also retain the prepared display label after secret redaction and the +128-character bound. A resolved durable profile, including one established by +verified trusted-proxy or Tailscale identity, supplies a pseudonymized person +invoker. A paired device adds device assurance but never becomes a person. +Shared tokens, passwords, auth-none connections, and other profileless clients +remain unattributed. If authenticated user evidence promises a durable profile +but profile resolution fails, the invoker is `unknown` rather than guessed from +headers, device ids, connection ids, or credentials. + Each present context currently projects one run-admission receipt. Its outcome is `not-applicable`, its policy and grant references are empty, and its reason states that no identity-aware policy or grant evaluation was proven. This is diff --git a/src/agents/agent-command-admission-facts.ts b/src/agents/agent-command-admission-facts.ts new file mode 100644 index 000000000000..0da29dec541e --- /dev/null +++ b/src/agents/agent-command-admission-facts.ts @@ -0,0 +1,20 @@ +import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js"; + +type AgentCommandAdmissionFacts = Readonly< + Pick +>; + +const factsByIngress = new WeakMap(); + +export function attachAgentCommandAdmissionFacts( + ingress: object, + facts: AgentCommandAdmissionFacts, +): void { + factsByIngress.set(ingress, facts); +} + +export function getAgentCommandAdmissionFacts( + ingress: object, +): AgentCommandAdmissionFacts | undefined { + return factsByIngress.get(ingress); +} diff --git a/src/agents/agent-command-execution-identity.test.ts b/src/agents/agent-command-execution-identity.test.ts index 994018a3d198..8fe2e3ddbd59 100644 --- a/src/agents/agent-command-execution-identity.test.ts +++ b/src/agents/agent-command-execution-identity.test.ts @@ -1,7 +1,22 @@ -import { describe, expect, it } from "vitest"; -import { sanitizePublicAgentCommandIngressOpts } from "./agent-command-execution-identity.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + configureExecutionIdentityAdmissionSink, + type ExecutionIdentityAdmissionWork, +} from "../audit/execution-identity-admission.js"; +import { attachAgentCommandAdmissionFacts } from "./agent-command-admission-facts.js"; +import { + prepareAgentCommandExecutionIdentity, + sanitizePublicAgentCommandIngressOpts, +} from "./agent-command-execution-identity.js"; import type { AgentCommandIngressOpts } from "./command/types.js"; +let cleanupSink: (() => void) | undefined; + +afterEach(() => { + cleanupSink?.(); + cleanupSink = undefined; +}); + describe("sanitizePublicAgentCommandIngressOpts", () => { it("removes a forged cron creator authority capability from plain-JavaScript ingress", () => { const forgedCapability = { @@ -22,3 +37,123 @@ describe("sanitizePublicAgentCommandIngressOpts", () => { }); }); }); + +describe("Gateway agent command execution identity", () => { + it("carries only the prepared bounded, redacted label into opt-in run admission", async () => { + let work: ExecutionIdentityAdmissionWork | undefined; + const displayLabel = "Operator OPENAI_API_KEY=***".padEnd(128, "x"); + cleanupSink = configureExecutionIdentityAdmissionSink((candidate) => { + work = candidate; + return true; + }); + + const opts: AgentCommandIngressOpts = { + message: "attribute this run", + allowModelOverride: false, + }; + attachAgentCommandAdmissionFacts(opts, { + ingress: { + kind: "gateway-client", + boundary: "gateway.ws.authenticated-connect", + state: "present", + rawSourceRef: "profile-ada", + }, + invoker: { + state: "present", + kind: "person", + rawPrincipalRef: "profile-ada", + displayLabel, + }, + assurance: [ + { + kind: "durable-profile", + rawEvidenceRef: "profile-ada", + strength: "boundary-verified", + }, + ], + }); + const prepared = prepareAgentCommandExecutionIdentity({ + opts, + prepared: { + cfg: { logging: { audit: { enabled: true, executionIdentity: true } } }, + runId: "run-profiled", + sessionAgentId: "main", + sessionId: "session-profiled", + }, + ingress: { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" }, + lifecycleGeneration: "generation-1", + }); + + await prepared.admit("embedded"); + + expect(work).toMatchObject({ + kind: "capture", + envelope: { + ingress: { + kind: "gateway-client", + boundary: "gateway.ws.authenticated-connect", + state: "present", + }, + invoker: { + state: "present", + kind: "person", + rawPrincipalRef: "profile-ada", + displayLabel: "Operator OPENAI_API_KEY=***", + }, + assurance: [ + { + kind: "durable-profile", + rawEvidenceRef: "profile-ada", + strength: "boundary-verified", + }, + ], + }, + }); + if (work?.kind !== "capture" || work.envelope.invoker?.state !== "present") { + throw new Error("expected captured present invoker"); + } + expect(work.envelope.invoker.displayLabel).toBe("Operator OPENAI_API_KEY=***"); + expect(work.envelope.invoker.displayLabel?.length).toBeLessThanOrEqual(128); + }); + + it("does not offer the prepared profile label to storage without execution audit opt-in", async () => { + let work: ExecutionIdentityAdmissionWork | undefined; + cleanupSink = configureExecutionIdentityAdmissionSink((candidate) => { + work = candidate; + return true; + }); + + const opts: AgentCommandIngressOpts = { + message: "do not retain this label", + allowModelOverride: false, + }; + attachAgentCommandAdmissionFacts(opts, { + ingress: { + kind: "gateway-client", + boundary: "gateway.ws.authenticated-connect", + state: "present", + }, + invoker: { + state: "present", + kind: "person", + rawPrincipalRef: "profile-ada", + displayLabel: "Ada", + }, + }); + const prepared = prepareAgentCommandExecutionIdentity({ + opts, + prepared: { + cfg: { logging: { audit: { enabled: true, executionIdentity: false } } }, + runId: "run-profiled-disabled", + sessionAgentId: "main", + sessionId: "session-profiled-disabled", + }, + ingress: { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" }, + lifecycleGeneration: "generation-1", + }); + + await prepared.admit("embedded"); + + expect(work).toBeUndefined(); + }); +}); diff --git a/src/agents/agent-command-execution-identity.ts b/src/agents/agent-command-execution-identity.ts index 865c89b7997c..7816b418e342 100644 --- a/src/agents/agent-command-execution-identity.ts +++ b/src/agents/agent-command-execution-identity.ts @@ -8,6 +8,10 @@ import { prepareAgentRunAdmission, type OperationalRunInstanceRef, } from "./admitted-run-context.js"; +import { + attachAgentCommandAdmissionFacts, + getAgentCommandAdmissionFacts, +} from "./agent-command-admission-facts.js"; import type { AgentCommandGatewayIngressOpts, AgentCommandIngressOpts, @@ -38,13 +42,16 @@ function prepareAgentCommandRunAdmission(params: { runId: string; onAdmitted?: Parameters[0]["onAdmitted"]; }) { + const admissionFacts = getAgentCommandAdmissionFacts(params.operationalRunInstance) ?? { + ingress: params.ingress, + }; return prepareAgentRunAdmission({ cfg: params.cfg, operationalRunInstance: params.operationalRunInstance, facts: { runId: params.runId, agentId: params.agentId, - ingress: params.ingress, + ...admissionFacts, }, ...(params.admission ? { recovery: params.admission } : {}), ...(params.onAdmitted ? { onAdmitted: params.onAdmitted } : {}), @@ -96,13 +103,18 @@ export function prepareAgentCommandExecutionIdentity(params: { lifecycleGeneration: string; }) { const { opts, prepared } = params; + const operationalRunInstance = + opts.operationalRunInstance ?? createOperationalRunInstanceRef(prepared.runId); + const admissionFacts = getAgentCommandAdmissionFacts(params.opts.runContext ?? params.opts); + if (admissionFacts) { + attachAgentCommandAdmissionFacts(operationalRunInstance, admissionFacts); + } return executionIdentity.prepare({ admission: opts.executionIdentityAdmission, agentId: prepared.sessionAgentId, cfg: prepared.cfg, ingress: params.ingress, - operationalRunInstance: - opts.operationalRunInstance ?? createOperationalRunInstanceRef(prepared.runId), + operationalRunInstance, runId: prepared.runId, onAdmitted: async (admittedRunContext) => { await opts.onAdmittedRunContext?.(admittedRunContext); diff --git a/src/gateway/agent-turn/agent-run-execution-phase.ts b/src/gateway/agent-turn/agent-run-execution-phase.ts index 3a2fc9266abe..82c129a17cc7 100644 --- a/src/gateway/agent-turn/agent-run-execution-phase.ts +++ b/src/gateway/agent-turn/agent-run-execution-phase.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { getAdmittedRunDelegatedAuthority } from "../../agents/admitted-run-context.js"; +import { attachAgentCommandAdmissionFacts } from "../../agents/agent-command-admission-facts.js"; import type { AgentRunTerminalOutcome } from "../../agents/agent-run-terminal-outcome.js"; import { claimExecApprovalFollowupRuntimeHandoff, @@ -41,6 +42,7 @@ import { buildRunUserTurnIdempotencyKey, createUserTurnTranscriptRecorder, } from "../../sessions/user-turn-transcript.js"; +import { getGatewayLocalUserIngress } from "../local-user-ingress.js"; import type { AgentRunRequest } from "../server-methods/agent-request-types.js"; import { createAgentRunModelSelectionHandler } from "../server-methods/agent-run-model-selection.js"; import { resolveSessionRuntimeCwd } from "../server-methods/agent-session-reset.js"; @@ -366,6 +368,10 @@ export function startAgentRunExecution(params: { restartRecoveryChannelContext?.sameChannelThreadRequired, ); + const localUserIngress = getGatewayLocalUserIngress(params.client); + if (localUserIngress) { + attachAgentCommandAdmissionFacts(runContext, localUserIngress.facts); + } dispatchAgentRunFromGateway({ cronCreatorAuthority: prepared.cronCreatorAuthority, ingressOpts: { diff --git a/src/gateway/agent-turn/principal.ts b/src/gateway/agent-turn/principal.ts index 33cf4f96468d..e4745467c3d4 100644 --- a/src/gateway/agent-turn/principal.ts +++ b/src/gateway/agent-turn/principal.ts @@ -2,6 +2,7 @@ import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap, } from "../../../packages/gateway-protocol/src/client-info.js"; +import { transferGatewayLocalUserIngress } from "../local-user-ingress.js"; import type { GatewayClient, GatewayRequestContext } from "../server-methods/shared-types.js"; import type { AgentTurnPrincipal } from "./types.js"; @@ -10,7 +11,7 @@ export function captureAgentTurnPrincipal(client: GatewayClient | null): AgentTu if (!client) { return null; } - return { + const principal: AgentTurnPrincipal = { authenticatedUserId: client.authenticatedUserId, authenticatedUserProfile: client.authenticatedUserProfile, connId: client.connId, @@ -18,6 +19,8 @@ export function captureAgentTurnPrincipal(client: GatewayClient | null): AgentTu internal: client.internal, isDeviceTokenAuth: client.isDeviceTokenAuth, }; + transferGatewayLocalUserIngress(client, principal); + return principal; } /** Preserve capability-gated tool-event observation across agent turn entry paths. */ diff --git a/src/gateway/local-user-ingress.ts b/src/gateway/local-user-ingress.ts new file mode 100644 index 000000000000..ba3b282a5a14 --- /dev/null +++ b/src/gateway/local-user-ingress.ts @@ -0,0 +1,124 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js"; +import { redactSensitiveText } from "../logging/redact.js"; +import type { GatewayAuthResult } from "./auth.js"; + +type GatewayLocalUserIngressFacts = Readonly< + Pick +>; + +type GatewayLocalUserIngress = Readonly<{ + facts: GatewayLocalUserIngressFacts; +}>; + +const ingressByOwner = new WeakMap(); + +function freezeLocalUserIngress(facts: GatewayLocalUserIngressFacts): GatewayLocalUserIngress { + Object.freeze(facts.ingress); + Object.freeze(facts.invoker); + for (const item of facts.assurance ?? []) { + Object.freeze(item); + } + Object.freeze(facts.assurance); + return Object.freeze({ facts: Object.freeze(facts) }); +} + +function safeDisplayLabel(value: string | null | undefined): string | undefined { + const label = value?.trim(); + return label + ? truncateUtf16Safe( + redactSensitiveText(redactSensitiveText(label, { mode: "tools" }), { mode: "tools" }), + 128, + ) + : undefined; +} + +/** Prepare attribution once from authenticated connection facts; credentials never become people. */ +export function prepareGatewayLocalUserIngress(params: { + authMethod?: GatewayAuthResult["method"]; + authenticatedUserExpected: boolean; + profile?: { profileId: string; displayName?: string | null }; + pairedDeviceId?: string; + isLocalClient: boolean; +}): GatewayLocalUserIngress { + const profileId = params.profile?.profileId.trim(); + const pairedDeviceId = params.pairedDeviceId?.trim(); + const displayLabel = safeDisplayLabel(params.profile?.displayName); + const assurance: NonNullable = []; + if (profileId) { + assurance.push({ + kind: "durable-profile", + rawEvidenceRef: profileId, + strength: "boundary-verified", + }); + } + if (params.authMethod === "trusted-proxy") { + assurance.push({ + kind: "trusted-proxy", + rawEvidenceRef: profileId ?? "gateway-auth:trusted-proxy", + strength: "boundary-verified", + }); + } else if (params.authMethod === "tailscale") { + assurance.push({ + kind: "tailscale-whois", + rawEvidenceRef: profileId ?? "gateway-auth:tailscale", + strength: "boundary-verified", + }); + } + if (pairedDeviceId) { + assurance.push({ + kind: "device-proof", + rawEvidenceRef: pairedDeviceId, + strength: "cryptographic", + }); + } + if (params.isLocalClient) { + assurance.push({ + kind: "local-process", + rawEvidenceRef: "gateway-transport:local", + strength: "boundary-verified", + }); + } + const rawSourceRef = profileId ?? pairedDeviceId; + return freezeLocalUserIngress({ + ingress: { + kind: "gateway-client", + boundary: "gateway.ws.authenticated-connect", + state: "present", + ...(rawSourceRef ? { rawSourceRef } : {}), + }, + ...(profileId + ? { + invoker: { + state: "present", + kind: "person", + rawPrincipalRef: profileId, + ...(displayLabel ? { displayLabel } : {}), + }, + } + : params.authenticatedUserExpected + ? { invoker: { state: "unknown" } } + : {}), + ...(assurance.length > 0 ? { assurance } : {}), + }); +} + +export function attachGatewayLocalUserIngress( + owner: object, + ingress: GatewayLocalUserIngress, +): void { + ingressByOwner.set(owner, ingress); +} + +export function getGatewayLocalUserIngress( + owner: object | null | undefined, +): GatewayLocalUserIngress | undefined { + return owner ? ingressByOwner.get(owner) : undefined; +} + +export function transferGatewayLocalUserIngress(source: object, target: object): void { + const ingress = ingressByOwner.get(source); + if (ingress) { + ingressByOwner.set(target, ingress); + } +} diff --git a/src/gateway/server-methods/session-creation-provenance.test.ts b/src/gateway/server-methods/session-creation-provenance.test.ts index cc8dbf777563..123fab7db8ab 100644 --- a/src/gateway/server-methods/session-creation-provenance.test.ts +++ b/src/gateway/server-methods/session-creation-provenance.test.ts @@ -1,16 +1,162 @@ import { describe, expect, it } from "vitest"; +import { + attachGatewayLocalUserIngress, + prepareGatewayLocalUserIngress, +} from "../local-user-ingress.js"; import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js"; +function resolveWithIngress( + localUserIngress: ReturnType, + profileId?: string, +) { + const client = profileId ? { authenticatedUserProfile: { profileId } } : {}; + attachGatewayLocalUserIngress(client, localUserIngress); + return resolveAgentRunSessionCreation(client); +} + describe("agent run session creation provenance", () => { - it("uses a proven Gateway profile id", () => { - expect( - resolveAgentRunSessionCreation({ - authenticatedUserProfile: { profileId: "profile-ada" }, - }), - ).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } }); + it("uses a proven Gateway profile id without retaining its display label", () => { + const localUserIngress = prepareGatewayLocalUserIngress({ + authenticatedUserExpected: true, + profile: { profileId: "profile-ada", displayName: "Ada" }, + isLocalClient: false, + }); + + expect(resolveWithIngress(localUserIngress, "profile-ada")).toEqual({ + via: "run", + actor: { type: "human", id: "profile-ada" }, + }); + expect(localUserIngress.facts.invoker).toEqual({ + state: "present", + kind: "person", + rawPrincipalRef: "profile-ada", + displayLabel: "Ada", + }); + }); + + it("uses the live canonical profile id after a connection profile merge", () => { + const localUserIngress = prepareGatewayLocalUserIngress({ + authenticatedUserExpected: true, + profile: { profileId: "profile-before-merge", displayName: "Ada" }, + isLocalClient: false, + }); + + expect(resolveWithIngress(localUserIngress, "profile-after-merge")).toEqual({ + via: "run", + actor: { type: "human", id: "profile-after-merge" }, + }); + expect(localUserIngress.facts.invoker).toMatchObject({ + state: "present", + rawPrincipalRef: "profile-before-merge", + }); }); it("does not infer an actor for a profile-less wire client", () => { expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" }); }); + + it.each([ + { + name: "paired device", + input: { + authMethod: "device-token" as const, + authenticatedUserExpected: false, + pairedDeviceId: "device-browser", + isLocalClient: false, + }, + expected: { + ingress: expect.objectContaining({ rawSourceRef: "device-browser" }), + assurance: [ + { + kind: "device-proof", + rawEvidenceRef: "device-browser", + strength: "cryptographic", + }, + ], + }, + }, + { + name: "shared secret", + input: { + authMethod: "token" as const, + authenticatedUserExpected: false, + isLocalClient: false, + }, + expected: { ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }) }, + }, + ])("keeps a $name profile-less and unattributed", ({ input, expected }) => { + const localUserIngress = prepareGatewayLocalUserIngress(input); + + expect(localUserIngress.facts).toEqual(expect.objectContaining(expected)); + expect(localUserIngress.facts.invoker).toBeUndefined(); + expect(resolveWithIngress(localUserIngress)).toEqual({ via: "run" }); + }); + + it("keeps a trusted-proxy identity unknown when durable profile resolution is missing", () => { + const localUserIngress = prepareGatewayLocalUserIngress({ + authMethod: "trusted-proxy", + authenticatedUserExpected: true, + isLocalClient: false, + }); + + expect(localUserIngress.facts).toMatchObject({ + ingress: { kind: "gateway-client", state: "present" }, + invoker: { state: "unknown" }, + assurance: [ + { + kind: "trusted-proxy", + rawEvidenceRef: "gateway-auth:trusted-proxy", + strength: "boundary-verified", + }, + ], + }); + expect(resolveWithIngress(localUserIngress)).toEqual({ via: "run" }); + }); + + it("records both durable-profile and trusted-proxy assurance for a profiled proxy user", () => { + const localUserIngress = prepareGatewayLocalUserIngress({ + authMethod: "trusted-proxy", + authenticatedUserExpected: true, + profile: { profileId: "profile-proxy", displayName: "Proxy User" }, + isLocalClient: false, + }); + + expect(localUserIngress.facts.assurance).toEqual([ + { + kind: "durable-profile", + rawEvidenceRef: "profile-proxy", + strength: "boundary-verified", + }, + { + kind: "trusted-proxy", + rawEvidenceRef: "profile-proxy", + strength: "boundary-verified", + }, + ]); + }); + + it("keeps a bounded, redacted profile label transient for opt-in run auditing", () => { + const secret = "sk-1234567890abcdef"; + const localUserIngress = prepareGatewayLocalUserIngress({ + authenticatedUserExpected: true, + profile: { + profileId: "profile-redacted", + displayName: `Operator OPENAI_API_KEY=${secret} ${"x".repeat(256)}`, + }, + isLocalClient: false, + }); + + const invoker = localUserIngress.facts.invoker; + expect(invoker).toMatchObject({ state: "present" }); + if (invoker?.state !== "present") { + throw new Error("expected present invoker"); + } + expect(invoker.displayLabel).toContain("OPENAI_API_KEY=***"); + expect(invoker.displayLabel).not.toContain(secret); + expect(invoker.displayLabel?.length).toBeLessThanOrEqual(128); + expect(resolveWithIngress(localUserIngress, "profile-redacted")).toEqual({ + via: "run", + actor: { type: "human", id: "profile-redacted" }, + }); + }); }); diff --git a/src/gateway/server-methods/session-creation-provenance.ts b/src/gateway/server-methods/session-creation-provenance.ts index 05a4477cc160..8408c750de9e 100644 --- a/src/gateway/server-methods/session-creation-provenance.ts +++ b/src/gateway/server-methods/session-creation-provenance.ts @@ -52,9 +52,8 @@ export function resolveOperatorSessionCreation( }; } const profileId = client?.authenticatedUserProfile?.profileId; - // Actor only when proven: a profile-less wire connection may be an agent-tool - // client on a remote topology, so claiming a human actor would misattribute - // agent-caused creations. Absent actor means unknown, never inferred. + // Profile linking can canonicalize this id after connection attach, so session + // ownership follows the live trusted profile while audit keeps its frozen facts. return { via: "operator", ...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}), diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 399ed2d1d44c..a8761d05b228 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -38,6 +38,10 @@ import { import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + attachGatewayLocalUserIngress, + prepareGatewayLocalUserIngress, +} from "./local-user-ingress.js"; import { resolveGatewaySessionStoreTarget } from "./session-utils.js"; import { agentCommandMock, @@ -3088,8 +3092,25 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat }); test("sessions.create stamps trusted operator provenance and records created", async () => { - await createSessionStoreDir(); + const { storePath } = await createSessionStoreDir(); const profileId = "profile-session-creator"; + const client = { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId, + displayName: "Test Operator", + hasAvatar: false, + updatedAt: 1, + }, + }; + attachGatewayLocalUserIngress( + client, + prepareGatewayLocalUserIngress({ + authenticatedUserExpected: true, + profile: { profileId, displayName: "Test Operator" }, + isLocalClient: false, + }), + ); const created = await directSessionReq<{ key?: string; entry?: { @@ -3097,21 +3118,7 @@ test("sessions.create stamps trusted operator provenance and records created", a createdActor?: { type: string; id?: string }; createdAt?: number; }; - }>( - "sessions.create", - { agentId: "main" }, - { - client: { - connect: { scopes: ["operator.write"] }, - authenticatedUserProfile: { - profileId, - displayName: "Test Operator", - hasAvatar: false, - updatedAt: 1, - }, - } as never, - }, - ); + }>("sessions.create", { agentId: "main" }, { client: client as never }); expect(created.ok).toBe(true); expect(created.payload?.entry).toMatchObject({ @@ -3119,7 +3126,9 @@ test("sessions.create stamps trusted operator provenance and records created", a createdActor: { type: "human", id: profileId }, createdAt: expect.any(Number), }); + expect(created.payload?.entry).not.toHaveProperty("createdActor.label"); const key = requireNonEmptyString(created.payload?.key, "created session key"); + expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("createdActor.label"); expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual( expect.objectContaining({ kind: "created", diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index eaef0409ff7b..9c8ff9bb0ba0 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -33,6 +33,10 @@ import { import { resolveRuntimeServiceVersion } from "../../../version.js"; import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; import { buildAuthenticatedPresenceUser } from "../../authenticated-presence-user.js"; +import { + attachGatewayLocalUserIngress, + prepareGatewayLocalUserIngress, +} from "../../local-user-ingress.js"; import { APPROVALS_SCOPE } from "../../method-scopes.js"; import { serializeEventPayload } from "../../node-registry.js"; import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js"; @@ -317,6 +321,20 @@ export async function attachAuthenticatedGatewayConnect( : {}), } : undefined; + const localUserIngress = prepareGatewayLocalUserIngress({ + authMethod, + authenticatedUserExpected: Boolean(authenticatedUserId), + ...(authenticatedUserProfile + ? { + profile: { + profileId: authenticatedUserProfile.profileId, + displayName: authenticatedUserProfile.displayName, + }, + } + : {}), + ...(device?.id ? { pairedDeviceId: device.id } : {}), + isLocalClient, + }); if (usesLegacyNodeProtocol) { logWsControl.warn( `legacy node protocol accepted conn=${connId} client=${formatForLog(clientLabel)} v${formatForLog(connectParams.client.version)} min=${minProtocol} max=${maxProtocol} current=${PROTOCOL_VERSION}; upgrade recommended`, @@ -352,6 +370,7 @@ export async function attachAuthenticatedGatewayConnect( ? { pluginNodeCapabilitySurfaces } : {}), }; + attachGatewayLocalUserIngress(nextClient, localUserIngress); for (const entry of pendingPluginNodeCapabilities) { setClientPluginNodeCapability({ client: nextClient, diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 7df04175c5f9..7a96dea62eb9 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -20,6 +20,7 @@ import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-toke import type { AuthRateLimiter } from "../../auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; import type { HealthSummary } from "../../health/types.js"; +import { getGatewayLocalUserIngress } from "../../local-user-ingress.js"; import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js"; import { handleGatewayRequest } from "../../server-methods.js"; import { resolveGatewayCronCreatorAuthorityAdmission } from "../../server-methods/cron-creator-authority-admission.js"; @@ -89,6 +90,12 @@ vi.mock("../../../config/config.js", () => ({ loadConfig: loadConfigMock, })); +function localUserIngressFor(client: unknown) { + return typeof client === "object" && client !== null + ? getGatewayLocalUserIngress(client) + : undefined; +} + vi.mock("../../../config/io.js", () => ({ getRuntimeConfig: loadConfigMock, })); @@ -726,6 +733,25 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { hasAvatar: false, }, }); + expect(localUserIngressFor(first.harness.client)).toMatchObject({ + facts: { + ingress: { + kind: "gateway-client", + rawSourceRef: profileId, + state: "present", + }, + invoker: { + state: "present", + kind: "person", + rawPrincipalRef: profileId, + displayLabel: "alice", + }, + assurance: expect.arrayContaining([ + expect.objectContaining({ kind: "durable-profile" }), + expect.objectContaining({ kind: "trusted-proxy" }), + ]), + }, + }); expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true); const second = await connect("second"); @@ -817,6 +843,15 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { authenticatedUserIsTailscaleProvider: true, authenticatedUserProfile: { displayName: "Ada Lovelace", hasAvatar: false }, }); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { + invoker: { state: "present", kind: "person", displayLabel: "Ada Lovelace" }, + assurance: expect.arrayContaining([ + expect.objectContaining({ kind: "durable-profile" }), + expect.objectContaining({ kind: "tailscale-whois" }), + ]), + }, + }); expect(adoptTailscaleProfileAvatarMock).toHaveBeenCalledOnce(); }); expect(harness.socketSend).toHaveBeenCalled(); @@ -843,7 +878,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }); }); - it("falls back to email identity when durable profile resolution fails", async () => { + it("keeps presence fallback but records unknown invoker when profile resolution fails", async () => { ensureProfileForEmailMock.mockImplementationOnce(() => { throw new Error("profile store unavailable"); }); @@ -858,6 +893,19 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { ); }); expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" }); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { + ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }), + invoker: { state: "unknown" }, + assurance: [ + { + kind: "trusted-proxy", + rawEvidenceRef: "gateway-auth:trusted-proxy", + strength: "boundary-verified", + }, + ], + }, + }); expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() }); expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1); expect(harness.logWsControl.warn).toHaveBeenCalledWith( @@ -903,6 +951,11 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }); expect(upsertPresenceMock).not.toHaveBeenCalled(); expect(harness.client).not.toMatchObject({ authenticatedUserId: expect.anything() }); + const localUserIngress = localUserIngressFor(harness.client); + expect(localUserIngress).toMatchObject({ + facts: { ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }) }, + }); + expect(localUserIngress?.facts.invoker).toBeUndefined(); expect(ensureProfileForEmailMock).not.toHaveBeenCalled(); }); diff --git a/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts index 4870cfbef578..2c8fa353c102 100644 --- a/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts +++ b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts @@ -2,16 +2,35 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { setTimeout as sleep } from "node:timers/promises"; import { pathToFileURL } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { WebSocket, type ClientOptions, type RawData } from "ws"; import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/src/evidence-summary.js"; import { startQaGatewayChild } from "../../../../extensions/qa-lab/src/gateway-child.js"; import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/src/providers/mock-openai/server.js"; +import { buildDeviceAuthPayloadV3 } from "../../../../packages/gateway-client/src/device-auth.js"; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../../../packages/gateway-protocol/src/client-info.js"; import type { AuditRunInspectResult } from "../../../../packages/gateway-protocol/src/index.js"; +import { + MIN_CLIENT_PROTOCOL_VERSION, + PROTOCOL_VERSION, +} from "../../../../packages/gateway-protocol/src/index.js"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, + type DeviceIdentity, +} from "../../../../src/infra/device-identity.js"; import { formatErrorMessage } from "../../../../src/infra/errors.js"; import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js"; @@ -37,6 +56,13 @@ const IDENTITY_FIELDS = [ "Applicable grants", "Assurance", ] as const; +const FRAME_TIMEOUT_MS = 20_000; +const GATEWAY_SCOPES = [ + "operator.admin", + "operator.pairing", + "operator.read", + "operator.write", +] as const; type ProducerOptions = { artifactBase: string; @@ -50,6 +76,211 @@ type ProofResult = { status: QaScriptEvidenceStatus; }; +type RawGatewayClient = { + frames: unknown[]; + socket: WebSocket; +}; + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8"); +} + +async function openRawGatewayClient( + url: string, + headers?: Record, +): Promise { + const socket = new WebSocket(url, headers ? ({ headers } satisfies ClientOptions) : undefined); + const frames: unknown[] = []; + socket.on("message", (data) => frames.push(parseJson(rawDataText(data), "Gateway frame"))); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + return { frames, socket }; +} + +async function waitForFrame( + client: RawGatewayClient, + predicate: (frame: unknown) => boolean, + startIndex = 0, +): Promise> { + const deadline = Date.now() + FRAME_TIMEOUT_MS; + while (Date.now() < deadline) { + const frame = client.frames.slice(startIndex).find(predicate); + if (isRecord(frame)) { + return frame; + } + await sleep(20); + } + throw new Error(`timed out waiting for Gateway frame: ${JSON.stringify(client.frames)}`); +} + +function responseFor(id: string) { + return (frame: unknown) => isRecord(frame) && frame.type === "res" && frame.id === id; +} + +async function closeRawGatewayClient(client: RawGatewayClient): Promise { + if (client.socket.readyState === WebSocket.CLOSED) { + return; + } + await new Promise((resolve) => { + client.socket.once("close", () => resolve()); + client.socket.close(); + setTimeout(resolve, 1_000).unref(); + }); +} + +async function connectRawDevice(params: { + device: DeviceIdentity; + headers?: Record; + token?: string; + wsUrl: string; +}): Promise<{ client: RawGatewayClient; connected: boolean }> { + const client = await openRawGatewayClient(params.wsUrl, params.headers); + const challenge = await waitForFrame( + client, + (frame) => isRecord(frame) && frame.type === "event" && frame.event === "connect.challenge", + ); + const challengePayload = challenge.payload; + if (!isRecord(challengePayload) || typeof challengePayload.nonce !== "string") { + throw new Error("Gateway connect challenge omitted its nonce"); + } + const clientInfo = { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + platform: "linux", + version: "qa-local-user-ingress", + } as const; + const signedAt = Date.now(); + const devicePayload = buildDeviceAuthPayloadV3({ + deviceId: params.device.deviceId, + clientId: clientInfo.id, + clientMode: clientInfo.mode, + role: "operator", + scopes: [...GATEWAY_SCOPES], + signedAtMs: signedAt, + token: params.token, + nonce: challengePayload.nonce, + platform: clientInfo.platform, + }); + const requestId = `connect-${randomUUID()}`; + const startIndex = client.frames.length; + client.socket.send( + JSON.stringify({ + type: "req", + id: requestId, + method: "connect", + params: { + minProtocol: MIN_CLIENT_PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: clientInfo, + role: "operator", + scopes: [...GATEWAY_SCOPES], + caps: [], + ...(params.token ? { auth: { token: params.token } } : {}), + device: { + id: params.device.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(params.device.publicKeyPem), + signature: signDevicePayload(params.device.privateKeyPem, devicePayload), + signedAt, + nonce: challengePayload.nonce, + }, + }, + }), + ); + const response = await waitForFrame(client, responseFor(requestId), startIndex); + return { client, connected: response.ok === true }; +} + +async function rawGatewayRequest( + client: RawGatewayClient, + method: string, + params: unknown, +): Promise { + const requestId = `request-${randomUUID()}`; + const startIndex = client.frames.length; + client.socket.send(JSON.stringify({ type: "req", id: requestId, method, params })); + const response = await waitForFrame(client, responseFor(requestId), startIndex); + if (response.ok !== true) { + throw new Error(`${method} failed: ${JSON.stringify(response.error)}`); + } + return response.payload as T; +} + +async function approveDeviceIfNeeded( + gateway: Awaited>, + deviceId: string, +): Promise { + const deadline = Date.now() + FRAME_TIMEOUT_MS; + while (Date.now() < deadline) { + const pairings = (await gateway.call("device.pair.list", {})) as { + pending?: Array<{ deviceId?: string; requestId?: string }>; + }; + const pending = pairings.pending?.find((candidate) => candidate.deviceId === deviceId); + if (pending?.requestId) { + await gateway.call("device.pair.approve", { requestId: pending.requestId }); + return; + } + await sleep(50); + } + throw new Error(`device pairing request was not visible for ${deviceId}`); +} + +async function createFakeTailscaleBinary(): Promise<{ + binaryDir: string; + cleanup: () => Promise; +}> { + const binaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-i1-tailscale-")); + try { + const binaryPath = path.join(binaryDir, "tailscale"); + await fs.writeFile( + binaryPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "qa-tailscale 1.0" + exit 0 +fi +echo '{"UserProfile":{"LoginName":"operator@example.com","DisplayName":"Operator"}}' +`, + { encoding: "utf8", mode: 0o755 }, + ); + return { + binaryDir, + cleanup: async () => await fs.rm(binaryDir, { force: true, recursive: true }), + }; + } catch (error) { + await fs.rm(binaryDir, { force: true, recursive: true }); + throw error; + } +} + +async function runGatewayTurn( + client: RawGatewayClient, + message: string, + sessionKey: string, +): Promise { + const started = await rawGatewayRequest<{ runId?: unknown; status?: unknown }>(client, "agent", { + sessionKey, + message, + deliver: false, + idempotencyKey: randomUUID(), + }); + if (started.status !== "accepted" || typeof started.runId !== "string") { + throw new Error(`profiled Gateway run did not start: ${JSON.stringify(started)}`); + } + const terminal = await rawGatewayRequest<{ status?: unknown }>(client, "agent.wait", { + runId: started.runId, + timeoutMs: 60_000, + }); + if (terminal.status !== "ok") { + throw new Error(`profiled Gateway run did not finish: ${JSON.stringify(terminal)}`); + } + return started.runId; +} + async function updateExecutionIdentityConfig( configPath: string, values: { enabled?: boolean; executionIdentity: boolean }, @@ -143,6 +374,41 @@ function assertJsonProjection(result: AuditRunInspectResult, runId: string) { } } +function assertGatewayIdentityProjection( + result: AuditRunInspectResult, + expected: { coverage: "attribution-only" | "unattributed"; invoker: "absent" | "present" }, +) { + const context = requireIdentityContext(result); + if ( + context.ingress.kind !== "gateway-client" || + context.ingress.state !== "present" || + context.ingress.boundary !== "gateway.ws.authenticated-connect" + ) { + throw new Error("Gateway run did not retain its authenticated connection ingress"); + } + if ( + context.invoker.state !== expected.invoker || + context.coverageState !== expected.coverage || + context.representedSubject !== undefined + ) { + throw new Error( + `Gateway identity projection fabricated or lost a subject: ${JSON.stringify(context)}`, + ); + } + if (expected.invoker === "present") { + if ( + context.invoker.principal?.kind !== "person" || + context.invoker.principal.displayLabel !== "Operator" || + !context.assurance.some((item) => item.kind === "durable-profile") || + !context.assurance.some((item) => item.kind === "tailscale-whois") + ) { + throw new Error("profiled Gateway run omitted its durable Tailscale attribution"); + } + } else if (context.assurance.some((item) => item.kind === "durable-profile")) { + throw new Error("profileless Gateway run fabricated durable profile assurance"); + } +} + function findLocalRunId(gateway: Awaited>) { const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR; if (!stateDir) { @@ -198,6 +464,42 @@ function inspectExecutionIdentityStorage(gateway: Awaited>, + sessionKey: string, +) { + const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR; + const agentId = sessionKey.split(":")[1]; + if (!stateDir || !agentId) { + throw new Error("QA Gateway did not expose the session creator database owner"); + } + const database = new DatabaseSync( + path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"), + { readOnly: true }, + ); + try { + const row = database + .prepare( + "SELECT created_actor_type, created_actor_id, entry_json FROM session_nodes WHERE session_key = ?", + ) + .get(sessionKey) as + | { created_actor_id: string | null; created_actor_type: string | null; entry_json: string } + | undefined; + if (!row) { + throw new Error(`persisted session creator row is missing: ${sessionKey}`); + } + const entry = parseJson(row.entry_json, `persisted session ${sessionKey}`); + const actor = isRecord(entry) && isRecord(entry.createdActor) ? entry.createdActor : undefined; + return { + id: row.created_actor_id, + labelPersisted: actor ? Object.hasOwn(actor, "label") : false, + type: row.created_actor_type, + }; + } finally { + database.close(); + } +} + async function runLocalTurn( gateway: Awaited>, message: string, @@ -246,6 +548,17 @@ function findRunExecutions( } } +function assertPersistedContextBytes( + gateway: Awaited>, + runId: string, + expectedContext: string, +): void { + const rows = findRunExecutions(gateway, runId); + if (rows.length !== 1 || rows[0]?.context_json !== expectedContext) { + throw new Error(`RPC context bytes differ from persisted bytes: ${runId}`); + } +} + async function runRepeatedIngressTurns( gateway: Awaited>, repoRoot: string, @@ -291,8 +604,10 @@ async function runRepeatedIngressTurns( async function runProof(options: ProducerOptions): Promise { const mock = await startQaMockOpenAiServer(); + let fakeTailscale: Awaited> | undefined; let gateway: Awaited> | undefined; try { + fakeTailscale = await createFakeTailscaleBinary(); gateway = await startQaGatewayChild({ repoRoot: options.repoRoot, useRepoCli: true, @@ -300,20 +615,33 @@ async function runProof(options: ProducerOptions): Promise { providerMode: "mock-openai", transportBaseUrl: "http://127.0.0.1", controlUiEnabled: false, + mutateConfig: (cfg) => ({ + ...cfg, + gateway: { + ...cfg.gateway, + auth: { ...cfg.gateway?.auth, allowTailscale: true }, + }, + }), + runtimeEnvPatch: { + PATH: `${fakeTailscale.binaryDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }); + await gateway.restartAfterStateMutation(async () => { + await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-FRESH"); }); - await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-FRESH"); if (inspectExecutionIdentityStorage(gateway).tablePresent) { throw new Error("fresh-install default unexpectedly created execution identity storage"); } - await gateway.restartAfterStateMutation(async () => {}); - await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-UPGRADE"); + await gateway.restartAfterStateMutation(async () => { + await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-UPGRADE"); + }); if (inspectExecutionIdentityStorage(gateway).tablePresent) { throw new Error("existing-install restart unexpectedly created execution identity storage"); } await gateway.restartAfterStateMutation(async ({ configPath }) => { await updateExecutionIdentityConfig(configPath, { executionIdentity: true }); + await runLocalTurn(gateway!, "Reply exactly: IDENTITY-INSPECTION-OK"); }); - await runLocalTurn(gateway, "Reply exactly: IDENTITY-INSPECTION-OK"); const runId = findLocalRunId(gateway); const beforeText = await gateway.runCli(["audit", "--run", runId, "--explain"]); assertTextProjection(beforeText); @@ -323,10 +651,150 @@ async function runProof(options: ProducerOptions): Promise { ) as AuditRunInspectResult; assertJsonProjection(before, runId); const beforeContext = normalizedContextJson(before); + assertPersistedContextBytes(gateway, runId, beforeContext); + + const profilelessSessionKey = `agent:qa:i1-profileless-${randomUUID()}`; + const profilelessStarted = (await gateway.call("agent", { + sessionKey: profilelessSessionKey, + message: "Reply exactly: I1-PROFILELESS", + deliver: false, + idempotencyKey: randomUUID(), + })) as { runId?: unknown; status?: unknown }; + if (profilelessStarted.status !== "accepted" || typeof profilelessStarted.runId !== "string") { + throw new Error( + `profileless Gateway run did not start: ${JSON.stringify(profilelessStarted)}`, + ); + } + const profilelessTerminal = (await gateway.call("agent.wait", { + runId: profilelessStarted.runId, + timeoutMs: 60_000, + })) as { status?: unknown }; + if (profilelessTerminal.status !== "ok") { + throw new Error( + `profileless Gateway run did not finish: ${JSON.stringify(profilelessTerminal)}`, + ); + } + const profilelessRunId = profilelessStarted.runId; + + const device = loadOrCreateDeviceIdentity({ + path: path.join(gateway.tempRoot, "i1-profiled-device.sqlite"), + }); + const tailscaleHeaders = { + "tailscale-user-login": "operator@example.com", + "tailscale-user-name": "Operator", + "x-forwarded-for": "100.64.0.11", + "x-forwarded-host": "gateway.qa.test", + "x-forwarded-proto": "https", + }; + let profiled = await connectRawDevice({ + device, + headers: tailscaleHeaders, + wsUrl: gateway.wsUrl, + }); + if (!profiled.connected) { + await approveDeviceIfNeeded(gateway, device.deviceId); + await closeRawGatewayClient(profiled.client); + profiled = await connectRawDevice({ + device, + headers: tailscaleHeaders, + wsUrl: gateway.wsUrl, + }); + } + if (!profiled.connected) { + throw new Error( + `Tailscale-profiled Gateway client failed: ${JSON.stringify(profiled.client.frames)}`, + ); + } + const profiledSessionKey = `agent:qa:i1-profiled-${randomUUID()}`; + const profiledRunId = await runGatewayTurn( + profiled.client, + "Reply exactly: I1-PROFILED", + profiledSessionKey, + ); + await closeRawGatewayClient(profiled.client); + + const profilelessText = await gateway.runCli(["audit", "--run", profilelessRunId, "--explain"]); + const profiledText = await gateway.runCli(["audit", "--run", profiledRunId, "--explain"]); + assertTextProjection(profilelessText); + assertTextProjection(profiledText); + if ( + !profilelessText.includes("Invoker [absent]") || + !profilelessText.includes("Represented subject [absent]") || + profilelessText.includes("Operator") + ) { + throw new Error("profileless text inspection fabricated an operator subject"); + } + if ( + !profiledText.includes("Invoker [present]") || + !profiledText.includes("Represented subject [absent]") + ) { + throw new Error( + `profiled text inspection omitted durable operator attribution: ${profiledText}`, + ); + } + const profilelessBefore = parseJson( + await gateway.runCli(["audit", "--run", profilelessRunId, "--explain", "--json"]), + "profileless Gateway inspection", + ) as AuditRunInspectResult; + const profiledBefore = parseJson( + await gateway.runCli(["audit", "--run", profiledRunId, "--explain", "--json"]), + "profiled Gateway inspection", + ) as AuditRunInspectResult; + assertGatewayIdentityProjection(profilelessBefore, { + coverage: "unattributed", + invoker: "absent", + }); + assertGatewayIdentityProjection(profiledBefore, { + coverage: "attribution-only", + invoker: "present", + }); + const profilelessContext = normalizedContextJson(profilelessBefore); + const profiledContext = normalizedContextJson(profiledBefore); + assertPersistedContextBytes(gateway, profilelessRunId, profilelessContext); + assertPersistedContextBytes(gateway, profiledRunId, profiledContext); + + const listed = (await gateway.call("sessions.list", {})) as { + sessions?: Array<{ + key?: string; + createdActor?: { id?: string; label?: string; type?: string }; + }>; + }; + const profilelessSession = listed.sessions?.find( + (session) => session.key === profilelessSessionKey, + ); + const profiledSession = listed.sessions?.find((session) => session.key === profiledSessionKey); + if (profilelessSession?.createdActor !== undefined) { + throw new Error("profileless Gateway session fabricated a human creator"); + } + const profilelessCreator = inspectPersistedSessionCreator(gateway, profilelessSessionKey); + if ( + profilelessCreator.type !== null || + profilelessCreator.id !== null || + profilelessCreator.labelPersisted + ) { + throw new Error("profileless Gateway session persisted a fabricated creator"); + } + if ( + profiledSession?.createdActor?.type !== "human" || + !profiledSession.createdActor.id || + profiledSession.createdActor.label !== "Operator" + ) { + throw new Error("profiled Gateway session lost its current profile display projection"); + } + const profiledCreator = inspectPersistedSessionCreator(gateway, profiledSessionKey); + if ( + profiledCreator.type !== "human" || + profiledCreator.id !== profiledSession.createdActor.id || + profiledCreator.labelPersisted + ) { + throw new Error("profiled Gateway session did not persist only its authenticated profile id"); + } const repeatedRunId = `identity-repeated-${randomUUID()}`; + let repeatedRows: ReturnType = []; + const repeatedBeforeRestart = new Map(); await runRepeatedIngressTurns(gateway, options.repoRoot, repeatedRunId); - const repeatedRows = findRunExecutions(gateway, repeatedRunId); + repeatedRows = findRunExecutions(gateway, repeatedRunId); if ( repeatedRows.length !== 2 || new Set(repeatedRows.map((row) => row.execution_id)).size !== 2 || @@ -350,7 +818,6 @@ async function runProof(options: ProducerOptions): Promise { if (discovery.identity.state !== "ambiguous" || discovery.identity.candidates.length !== 2) { throw new Error("repeated same-session run was not reported as two ambiguous executions"); } - const repeatedBeforeRestart = new Map(); for (const row of repeatedRows) { const text = await gateway.runCli(["audit", "--execution", row.execution_id, "--explain"]); assertTextProjection(text); @@ -389,6 +856,19 @@ async function runProof(options: ProducerOptions): Promise { if (afterContext !== beforeContext) { throw new Error("normalized execution identity context bytes changed across Gateway restart"); } + for (const [gatewayRunId, expectedContext, expectedIdentity] of [ + [profilelessRunId, profilelessContext, { coverage: "unattributed", invoker: "absent" }], + [profiledRunId, profiledContext, { coverage: "attribution-only", invoker: "present" }], + ] as const) { + const afterGateway = parseJson( + await gateway.runCli(["audit", "--run", gatewayRunId, "--explain", "--json"]), + `post-restart Gateway run ${gatewayRunId}`, + ) as AuditRunInspectResult; + assertGatewayIdentityProjection(afterGateway, expectedIdentity); + if (normalizedContextJson(afterGateway) !== expectedContext) { + throw new Error(`Gateway execution changed across restart: ${gatewayRunId}`); + } + } for (const [executionId, expectedContext] of repeatedBeforeRestart) { const afterExact = parseJson( await gateway.runCli(["audit", "--execution", executionId, "--explain", "--json"]), @@ -404,8 +884,8 @@ async function runProof(options: ProducerOptions): Promise { enabled: false, executionIdentity: true, }); + await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-GLOBAL"); }); - await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-GLOBAL"); if (inspectExecutionIdentityStorage(gateway).rowCount !== retainedBeforeGlobalDisable) { throw new Error("global audit disable unexpectedly retained a new execution context"); } @@ -424,6 +904,13 @@ async function runProof(options: ProducerOptions): Promise { `${JSON.stringify( { runId, + gatewayRuns: { + profiled: { runId: profiledRunId, contextSha256: sha256(profiledContext) }, + profileless: { + runId: profilelessRunId, + contextSha256: sha256(profilelessContext), + }, + }, repeatedRunId, repeatedExecutions: repeatedRows.map((row) => ({ executionId: row.execution_id, @@ -450,10 +937,12 @@ async function runProof(options: ProducerOptions): Promise { )}\n`, "utf8", ); - return `local run=${runId}; repeated run=${repeatedRunId} executions=${repeatedRows.map((row) => row.execution_id).join(",")}; Gateway pid=${gateway.pid ?? "unknown"}; text+JSON exact selection passed before/after replacement; normalized context sha256=${sha256(beforeContext)}`; + const repeatedDetails = `repeated run=${repeatedRunId} executions=${repeatedRows.map((row) => row.execution_id).join(",")}; exact selection passed`; + return `local run=${runId}; profiled Gateway run=${profiledRunId}; profileless Gateway run=${profilelessRunId}; ${repeatedDetails}; Gateway pid=${gateway.pid ?? "unknown"}; text+JSON and persisted bytes passed before/after replacement; normalized context sha256=${sha256(beforeContext)}`; } finally { await gateway?.stop().catch(() => undefined); await mock.stop(); + await fakeTailscale?.cleanup(); } }