From 618fba92bf010a4417ac07c979dfc1ca64df0165 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:05:15 -0500 Subject: [PATCH] feat(audit): add execution identity inspection (#117034) * feat(audit): add opt-in execution identity inspection * fix(audit): gate recovery identity retention * fix(audit): keep recovery identity type private * test(audit): type internal recovery fixture * test(audit): split recovery identity coverage * docs(audit): define operator read trust boundary * test(qa): register identity scenario child * fix(audit): enforce shared identity retention bounds * fix(audit): seal public ingress identity boundary * fix(audit): keep ingress guard lint-clean * fix(gateway): preserve advertised method order * chore(protocol): sync advertised method order * fix(protocol): encode audit selector invariants * test(audit): prove exact execution guard * fix(audit): keep identity storage lazy --- .../openclaw/app/gateway/GatewayProtocol.kt | 1 + .../OpenClawProtocol/GatewayModels.swift | 357 ++++++++ config/knip.all-exports.config.ts | 2 + docs/cli/audit.md | 131 ++- docs/cli/config.md | 1 + docs/gateway/audit.md | 148 +++- docs/gateway/configuration-reference.md | 12 +- docs/gateway/operator-scopes.md | 9 +- docs/gateway/protocol.md | 12 +- extensions/discord/src/voice/ingress.test.ts | 49 ++ .../src/schema-export-registry.ts | 9 + .../gateway-protocol/src/schema-modules.ts | 1 + packages/gateway-protocol/src/schema/agent.ts | 3 + .../src/schema/audit-run.test.ts | 160 ++++ .../gateway-protocol/src/schema/audit-run.ts | 317 +++++++ .../protocol-schema-fragment-operations.ts | 10 + .../src/validator-registry.ts | 5 + .../agent-run-identity-inspection.yaml | 37 + scripts/check-protocol-registry.mjs | 4 +- .../agent-command-execution-identity.ts | 51 ++ .../agent-command.live-model-switch.test.ts | 75 ++ src/agents/agent-command.ts | 75 +- src/agents/command/types.ts | 12 +- ...-recovery-state.execution-identity.test.ts | 218 +++++ .../main-session-recovery-state.test.ts | 80 +- src/agents/main-session-recovery-state.ts | 14 + .../main-session-recovery-store.test.ts | 13 + src/agents/main-session-recovery-types.ts | 13 + src/agents/main-session-restart-dispatch.ts | 15 + .../main-session-restart-recovery.test.ts | 87 +- src/audit/audit-config.test.ts | 21 +- src/audit/audit-config.ts | 5 + src/audit/audit-event-writer.test.ts | 517 ++++++++++- src/audit/audit-event-writer.ts | 77 +- src/audit/audit-event-writer.worker.ts | 49 +- src/audit/audit-events.test.ts | 5 + src/audit/audit-identity.ts | 45 +- src/audit/audit-recorder.test.ts | 1 + src/audit/audit-recorder.ts | 3 + .../execution-identity-admission.test.ts | 235 +++++ src/audit/execution-identity-admission.ts | 400 +++++++++ src/audit/execution-identity-context-build.ts | 147 ++++ src/audit/execution-identity-context.test.ts | 831 ++++++++++++++++++ src/audit/execution-identity-context.ts | 726 +++++++++++++++ src/cli/program/core-command-descriptors.ts | 2 +- src/cli/program/register.audit.ts | 8 +- src/commands/agent-exec.test.ts | 57 ++ src/commands/agent-exec.ts | 12 + src/commands/agent-local-audit.ts | 24 + src/commands/agent-via-gateway.test.ts | 61 ++ src/commands/agent-via-gateway.ts | 25 +- src/commands/agent.test.ts | 62 ++ src/commands/audit.test-support.ts | 17 + src/commands/audit.test.ts | 222 +++++ src/commands/audit.ts | 316 +++++++ src/config/schema.help.core.ts | 2 + src/config/schema.labels.ts | 1 + .../sessions/main-session-recovery.types.ts | 8 + src/config/types.base.ts | 5 + ...od-schema.audit-execution-identity.test.ts | 27 + src/config/zod-schema.root-shape.ts | 1 + src/gateway/boot.test.ts | 6 +- src/gateway/boot.ts | 5 +- src/gateway/method-scopes.test.ts | 11 + .../methods/core-descriptors.since.test.ts | 1 + src/gateway/methods/core-descriptors.ts | 2 + src/gateway/server-methods-list.test.ts | 9 +- .../agent-request-preflight.test.ts | 23 +- .../server-methods/agent-request-preflight.ts | 11 + .../server-methods/agent-request-types.ts | 1 + .../agent-restart-recovery-context.test.ts | 98 ++- .../agent-restart-recovery-context.ts | 29 +- .../agent-run-execution-phase.ts | 14 +- src/gateway/server-methods/audit.test.ts | 66 +- src/gateway/server-methods/audit.ts | 46 +- src/gateway/server-node-events.test.ts | 19 + .../server-runtime-subscriptions.test.ts | 26 + src/gateway/server-runtime-subscriptions.ts | 5 + .../agent-runtime-ingress-contract.test.ts | 19 + src/state/openclaw-state-db-contract.ts | 6 + src/state/openclaw-state-db.generated.d.ts | 11 + src/state/openclaw-state-db.test.ts | 36 +- src/state/openclaw-state-db.ts | 63 +- src/state/openclaw-state-schema.sql | 15 + .../runtime/agent-run-identity-inspection.ts | 525 +++++++++++ .../agent-run-identity-repeated-turn-child.ts | 45 + 86 files changed, 6713 insertions(+), 212 deletions(-) create mode 100644 extensions/discord/src/voice/ingress.test.ts create mode 100644 packages/gateway-protocol/src/schema/audit-run.test.ts create mode 100644 packages/gateway-protocol/src/schema/audit-run.ts create mode 100644 qa/scenarios/runtime/agent-run-identity-inspection.yaml create mode 100644 src/agents/agent-command-execution-identity.ts create mode 100644 src/agents/main-session-recovery-state.execution-identity.test.ts create mode 100644 src/audit/execution-identity-admission.test.ts create mode 100644 src/audit/execution-identity-admission.ts create mode 100644 src/audit/execution-identity-context-build.ts create mode 100644 src/audit/execution-identity-context.test.ts create mode 100644 src/audit/execution-identity-context.ts create mode 100644 src/commands/agent-local-audit.ts create mode 100644 src/config/zod-schema.audit-execution-identity.test.ts create mode 100644 src/plugin-sdk/agent-runtime-ingress-contract.test.ts create mode 100644 test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts create mode 100644 test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 68ae109258c9..7578c6a48f09 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -507,6 +507,7 @@ enum class GatewayMethod( HooksStatus("hooks.status"), TasksRetry("tasks.retry"), TasksDismiss("tasks.dismiss"), + AuditRunInspect("audit.run.inspect"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 63ae69cbd4a2..00f7afbf4a8a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2665,6 +2665,7 @@ public struct AgentParams: Codable, Sendable { public let bootstrapcontextrunkind: AnyCodable? public let acpturnsource: String? public let internalruntimehandoffid: String? + public let internalexecutionidentityretry: Bool? public let execapprovalfollowupexpectedsessionid: String? public let internalevents: [[String: AnyCodable]]? public let inputprovenance: [String: AnyCodable]? @@ -2713,6 +2714,7 @@ public struct AgentParams: Codable, Sendable { bootstrapcontextrunkind: AnyCodable? = nil, acpturnsource: String? = nil, internalruntimehandoffid: String? = nil, + internalexecutionidentityretry: Bool? = nil, execapprovalfollowupexpectedsessionid: String? = nil, internalevents: [[String: AnyCodable]]? = nil, inputprovenance: [String: AnyCodable]? = nil, @@ -2760,6 +2762,7 @@ public struct AgentParams: Codable, Sendable { self.bootstrapcontextrunkind = bootstrapcontextrunkind self.acpturnsource = acpturnsource self.internalruntimehandoffid = internalruntimehandoffid + self.internalexecutionidentityretry = internalexecutionidentityretry self.execapprovalfollowupexpectedsessionid = execapprovalfollowupexpectedsessionid self.internalevents = internalevents self.inputprovenance = inputprovenance @@ -2809,6 +2812,7 @@ public struct AgentParams: Codable, Sendable { case bootstrapcontextrunkind = "bootstrapContextRunKind" case acpturnsource = "acpTurnSource" case internalruntimehandoffid = "internalRuntimeHandoffId" + case internalexecutionidentityretry = "internalExecutionIdentityRetry" case execapprovalfollowupexpectedsessionid = "execApprovalFollowupExpectedSessionId" case internalevents = "internalEvents" case inputprovenance = "inputProvenance" @@ -8528,6 +8532,322 @@ public struct AuditActivityListResult: Codable, Sendable { } } +public struct ExecutionIdentityContextV1: Codable, Sendable { + public let schemaversion: Double + public let contextid: String + public let executionid: String + public let runid: String + public let createdat: Int + public let trustdomain: [String: AnyCodable] + public let invoker: [String: AnyCodable] + public let ingress: [String: AnyCodable] + public let agentprincipal: [String: AnyCodable] + public let agentdefinition: [String: AnyCodable] + public let runtimeinstance: [String: AnyCodable] + public let representedsubject: [String: AnyCodable]? + public let sponsor: [String: AnyCodable]? + public let applicablegrants: [[String: AnyCodable]] + public let assurance: [[String: AnyCodable]] + public let lineage: [String: AnyCodable]? + public let coveragestate: AnyCodable + public let missingevidence: [String] + + public init( + schemaversion: Double, + contextid: String, + executionid: String, + runid: String, + createdat: Int, + trustdomain: [String: AnyCodable], + invoker: [String: AnyCodable], + ingress: [String: AnyCodable], + agentprincipal: [String: AnyCodable], + agentdefinition: [String: AnyCodable], + runtimeinstance: [String: AnyCodable], + representedsubject: [String: AnyCodable]? = nil, + sponsor: [String: AnyCodable]? = nil, + applicablegrants: [[String: AnyCodable]], + assurance: [[String: AnyCodable]], + lineage: [String: AnyCodable]? = nil, + coveragestate: AnyCodable, + missingevidence: [String]) + { + self.schemaversion = schemaversion + self.contextid = contextid + self.executionid = executionid + self.runid = runid + self.createdat = createdat + self.trustdomain = trustdomain + self.invoker = invoker + self.ingress = ingress + self.agentprincipal = agentprincipal + self.agentdefinition = agentdefinition + self.runtimeinstance = runtimeinstance + self.representedsubject = representedsubject + self.sponsor = sponsor + self.applicablegrants = applicablegrants + self.assurance = assurance + self.lineage = lineage + self.coveragestate = coveragestate + self.missingevidence = missingevidence + } + + private enum CodingKeys: String, CodingKey { + case schemaversion = "schemaVersion" + case contextid = "contextId" + case executionid = "executionId" + case runid = "runId" + case createdat = "createdAt" + case trustdomain = "trustDomain" + case invoker + case ingress + case agentprincipal = "agentPrincipal" + case agentdefinition = "agentDefinition" + case runtimeinstance = "runtimeInstance" + case representedsubject = "representedSubject" + case sponsor + case applicablegrants = "applicableGrants" + case assurance + case lineage + case coveragestate = "coverageState" + case missingevidence = "missingEvidence" + } +} + +public struct DecisionReceiptV1: Codable, Sendable { + public let schemaversion: Double + public let receiptid: String + public let contextid: String + public let executionid: String + public let runid: String + public let actionid: String? + public let occurredat: Int + public let action: [String: AnyCodable] + public let decision: [String: AnyCodable] + public let enforcement: [String: AnyCodable] + public let source: [String: AnyCodable] + public let missingevidence: [String] + public let remediation: [[String: AnyCodable]] + + public init( + schemaversion: Double, + receiptid: String, + contextid: String, + executionid: String, + runid: String, + actionid: String? = nil, + occurredat: Int, + action: [String: AnyCodable], + decision: [String: AnyCodable], + enforcement: [String: AnyCodable], + source: [String: AnyCodable], + missingevidence: [String], + remediation: [[String: AnyCodable]]) + { + self.schemaversion = schemaversion + self.receiptid = receiptid + self.contextid = contextid + self.executionid = executionid + self.runid = runid + self.actionid = actionid + self.occurredat = occurredat + self.action = action + self.decision = decision + self.enforcement = enforcement + self.source = source + self.missingevidence = missingevidence + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case schemaversion = "schemaVersion" + case receiptid = "receiptId" + case contextid = "contextId" + case executionid = "executionId" + case runid = "runId" + case actionid = "actionId" + case occurredat = "occurredAt" + case action + case decision + case enforcement + case source + case missingevidence = "missingEvidence" + case remediation + } +} + +public struct AuditRunIdentityPresentV1: Codable, Sendable { + public let state: String + public let context: ExecutionIdentityContextV1 + + public init( + state: String, + context: ExecutionIdentityContextV1) + { + self.state = state + self.context = context + } + + private enum CodingKeys: String, CodingKey { + case state + case context + } +} + +public struct AuditRunIdentityUnknownV1: Codable, Sendable { + public let state: String + public let reasoncode: String + public let missingevidence: [String] + public let remediation: [[String: AnyCodable]] + + public init( + state: String, + reasoncode: String, + missingevidence: [String], + remediation: [[String: AnyCodable]]) + { + self.state = state + self.reasoncode = reasoncode + self.missingevidence = missingevidence + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case state + case reasoncode = "reasonCode" + case missingevidence = "missingEvidence" + case remediation + } +} + +public struct AuditRunIdentityUnsupportedV1: Codable, Sendable { + public let state: String + public let reasoncode: String + public let missingevidence: [String] + public let remediation: [[String: AnyCodable]] + + public init( + state: String, + reasoncode: String, + missingevidence: [String], + remediation: [[String: AnyCodable]]) + { + self.state = state + self.reasoncode = reasoncode + self.missingevidence = missingevidence + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case state + case reasoncode = "reasonCode" + case missingevidence = "missingEvidence" + case remediation + } +} + +public struct AuditRunIdentityAmbiguousV1: Codable, Sendable { + public let state: String + public let reasoncode: String + public let candidates: [[String: AnyCodable]] + public let missingevidence: [String] + public let remediation: [[String: AnyCodable]] + + public init( + state: String, + reasoncode: String, + candidates: [[String: AnyCodable]], + missingevidence: [String], + remediation: [[String: AnyCodable]]) + { + self.state = state + self.reasoncode = reasoncode + self.candidates = candidates + self.missingevidence = missingevidence + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case state + case reasoncode = "reasonCode" + case candidates + case missingevidence = "missingEvidence" + case remediation + } +} + +public struct AuditRunInspectParams: Codable, Sendable { + public let runid: String? + public let executionid: String? + public let executioncursor: String? + public let executionlimit: Int? + public let decisioncursor: String? + public let decisionlimit: Int? + + public init( + runid: String? = nil, + executionid: String? = nil, + executioncursor: String? = nil, + executionlimit: Int? = nil, + decisioncursor: String? = nil, + decisionlimit: Int? = nil) + { + self.runid = runid + self.executionid = executionid + self.executioncursor = executioncursor + self.executionlimit = executionlimit + self.decisioncursor = decisioncursor + self.decisionlimit = decisionlimit + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case executionid = "executionId" + case executioncursor = "executionCursor" + case executionlimit = "executionLimit" + case decisioncursor = "decisionCursor" + case decisionlimit = "decisionLimit" + } +} + +public struct AuditRunInspectResult: Codable, Sendable { + public let schemaversion: Double + public let run: [String: AnyCodable] + public let identity: AuditRunIdentityV1 + public let decisions: [DecisionReceiptV1] + public let coverage: [String: AnyCodable] + public let nextdecisioncursor: String? + public let nextexecutioncursor: String? + + public init( + schemaversion: Double, + run: [String: AnyCodable], + identity: AuditRunIdentityV1, + decisions: [DecisionReceiptV1], + coverage: [String: AnyCodable], + nextdecisioncursor: String? = nil, + nextexecutioncursor: String? = nil) + { + self.schemaversion = schemaversion + self.run = run + self.identity = identity + self.decisions = decisions + self.coverage = coverage + self.nextdecisioncursor = nextdecisioncursor + self.nextexecutioncursor = nextexecutioncursor + } + + private enum CodingKeys: String, CodingKey { + case schemaversion = "schemaVersion" + case run + case identity + case decisions + case coverage + case nextdecisioncursor = "nextDecisionCursor" + case nextexecutioncursor = "nextExecutionCursor" + } +} + public struct AuditEvent: Codable, Sendable { public let eventid: String public let sequence: Int @@ -17990,6 +18310,43 @@ public enum AuditActivityEventV1: Codable, Sendable { } } +public enum AuditRunIdentityV1: Codable, Sendable { + case present(AuditRunIdentityPresentV1) + case unknown(AuditRunIdentityUnknownV1) + case unsupported(AuditRunIdentityUnsupportedV1) + case ambiguous(AuditRunIdentityAmbiguousV1) + + private enum CodingKeys: String, CodingKey { + case discriminator = "state" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "present": self = try .present(AuditRunIdentityPresentV1(from: decoder)) + case "unknown": self = try .unknown(AuditRunIdentityUnknownV1(from: decoder)) + case "unsupported": self = try .unsupported(AuditRunIdentityUnsupportedV1(from: decoder)) + case "ambiguous": self = try .ambiguous(AuditRunIdentityAmbiguousV1(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown AuditRunIdentityV1 discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .present(let value): try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + case .unsupported(let value): try value.encode(to: encoder) + case .ambiguous(let value): try value.encode(to: encoder) + } + } +} + public enum ApprovalPresentation: Codable, Sendable { case exec(ExecApprovalPresentation) case plugin(PluginApprovalPresentation) diff --git a/config/knip.all-exports.config.ts b/config/knip.all-exports.config.ts index 2237d238841f..da7d84465d7b 100644 --- a/config/knip.all-exports.config.ts +++ b/config/knip.all-exports.config.ts @@ -51,6 +51,8 @@ const ROOT_TEST_ENTRY_GLOBS = [ "test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts!", "test/e2e/qa-lab/runtime/docker-e2e-lane.ts!", "test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts!", + // The identity scenario spawns this process-isolated repeated-turn driver by path. + "test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts!", // Invoked directly by the Docker image-auth scenario. "test/e2e/qa-lab/runtime/openai-image-auth-docker-client.ts!", "test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts!", diff --git a/docs/cli/audit.md b/docs/cli/audit.md index 0314a3839521..85bde77e3dde 100644 --- a/docs/cli/audit.md +++ b/docs/cli/audit.md @@ -9,14 +9,28 @@ title: "Audit records" # `openclaw audit` -Query the Gateway's metadata-only audit ledger for agent runs, tool actions, and -opt-in message lifecycle records. +Query the Gateway's metadata-only activity ledger, discover executions that +share a run correlation, or inspect immutable identity context for one exact +agent execution. -The ledger is on by default for run and tool events. Set -[`logging.audit.enabled: false`](/gateway/configuration-reference#audit) and -restart the Gateway to stop all new event records. Message records are -separately disabled by default; set `logging.audit.messages` to `direct` or -`all` and restart the Gateway to record them. Existing records stay queryable until they expire (30 days). +Run and tool activity records are on by default. Execution identity is +separately off by default on fresh installs and upgrades. Enable it explicitly: + +```bash +openclaw config set logging.audit.executionIdentity true +openclaw gateway restart +``` + +Identity collection requires `logging.audit.enabled` to remain enabled. +Message records are also separately disabled by default; set +`logging.audit.messages` to `direct` or `all` and restart the Gateway to +record them. Existing records stay queryable until they expire (30 days). + +Direct local commands use the same bounded writer lifecycle as the Gateway. +`openclaw agent exec` deletes its temporary state directory by default, so its +audit evidence is intentionally discarded with the rest of that isolated run. +Use `agent exec --state-dir ` when the run state must remain available, +and inspect it through a Gateway using that same state directory. The ledger is separate from conversation transcripts: it records identity, ordering, provenance, action, status, and normalized outcome codes, but never @@ -30,6 +44,10 @@ openclaw audit openclaw audit --agent main --status failed openclaw audit --session "agent:main:main" --after 2026-07-01T00:00:00Z openclaw audit --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 +openclaw audit --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 --explain +openclaw audit --execution 5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf --explain +openclaw audit --execution 5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf --explain --json +openclaw audit --run 8c69f72e-8b11-4c54-98d5-1a3dd67450c3 --explain --json openclaw audit --kind tool_action --limit 50 --json openclaw audit --kind message --direction outbound --channel telegram --json ``` @@ -38,7 +56,8 @@ openclaw audit --kind message --direction outbound --channel telegram --json - `--agent `: exact agent id - `--session `: exact session key -- `--run `: exact run id +- `--run `: exact run id; filters activity unless `--explain` is also set +- `--execution `: exact execution id; requires `--explain` - `--kind `: `agent_run`, `tool_action`, or `message` - `--status `: `started`, `succeeded`, `failed`, `cancelled`, `timed_out`, `blocked`, or `unknown` @@ -46,8 +65,14 @@ openclaw audit --kind message --direction outbound --channel telegram --json - `--channel `: exact message channel - `--after ` / `--before `: inclusive ISO timestamp or Unix milliseconds -- `--limit `: page size from 1 to 500; default `100` -- `--cursor `: continue a previous newest-first query +- `--limit `: activity page size from 1 to 500 (default `100`), decision + page size from 1 to 100, or ambiguous execution-candidate page size from 1 + to 50 with `--explain` (default `50`) +- `--cursor `: continue an activity, decision, or ambiguous + execution-candidate page +- `--explain`: inspect immutable execution identity and run-admission reasoning; + requires exactly one of `--run` or `--execution` and accepts only `--limit`, + `--cursor`, and `--json` - `--json`: print the bounded page as JSON The CLI queries the versioned activity RPC so one command shows the complete @@ -63,6 +88,74 @@ channels, outcomes, and stable HMAC references can correlate activity. Protect them with the same access controls and retention practices as other operator records. +The Gateway intentionally exposes retained execution-identity diagnostics to +every client with `operator.read` in its operator domain. That scope is a +trusted read-only boundary, not hostile multi-tenant isolation. Use separate +Gateway trust domains when operators must not share audit identity data. + +## Discover and explain executions + +Every admitted outer turn receives an opaque `executionId`. `contextId` +identifies its immutable evidence record; the existing `runId` stays a +possibly shared session, routing, or recovery correlation. Use `--run +--explain` to discover retained executions rather than query the best-effort +activity list. One match resolves directly. Multiple matches return +`ambiguous`, list at most 50 candidates, and tell you to select one explicitly: + +```bash +openclaw audit --execution --explain +``` + +OpenClaw never silently selects the first or latest execution. The exact text +view renders these sections: + +1. **Identity**: trust domain, invoker, ingress, agent principal, agent + definition, runtime instance, represented subject, and sponsor. +2. **Authority**: applicable grants and assurance evidence. +3. **Lineage**: parent context or an explicit absent, unknown, or unsupported + state. +4. **Decisions**: the bounded run-admission receipt page. +5. **Missing evidence** and **Next steps**. + +Every field includes `present`, `absent`, `unknown`, or `unsupported`; the CLI +does not infer a user from a session key, device id, display name, or shared +credential. A direct local run currently shows authoritative `local-cli` +ingress, an absent invoker, and +`unattributed` coverage. Its admission receipt says `not-applicable` because no +identity-aware policy or grant evaluation was proven. + +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 +result instead contains at most 50 execution candidates and an optional +`nextExecutionCursor`. Sensitive domain, +runtime, invoker, assurance, ingress-source, and grant references are +installation-local HMAC projections. Configured agent ids and exact run ids +remain visible, as do context and execution ids, so redirected output is still +private operator data. + +An older Gateway produces an explicit `unsupported` result with +`gateway_upgrade_required` and an upgrade-and-rerun next step. The CLI never +reconstructs identity from legacy audit rows. A current Gateway distinguishes +an unknown run, an unavailable pre-feature, disabled, or failed context write, +an expired context, and a corrupt context without claiming that missing +best-effort activity proves no execution. A newly admitted run can also be +temporarily unavailable while its bounded identity envelope waits in the audit +writer queue; retry inspection after the run or normal process shutdown. +Admission never waits for writer readiness, schema or HMAC-key initialization, +SQLite, or persistence. + +Once a context is older than 30 days, the CLI returns no fields or admission +decisions from it. While bounded cleanup is pending, the result is `unsupported` +with an expiry-and-rerun next step. After cleanup it can become `unknown` if no +separately retained activity remains; this absence does not prove that the run +did not occur. Startup and hourly maintenance prune at most 1,024 identity +contexts per tick and continue when collection is disabled. Queue saturation, +worker/storage failure, cleanup failure, or abrupt process termination can lose +best-effort evidence but never block or abort the agent run. Normal Gateway and +direct-local CLI shutdown flushes accepted work when its writer lifecycle +permits. + ## Recorded events The Gateway projects trusted lifecycle streams into six actions: @@ -124,6 +217,24 @@ openclaw gateway call audit.activity.list --params '{"channel":"telegram","limit The result is `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`. Results are newest first and limited to 500 records per request. +`audit.run.inspect` also requires `operator.read`: + +```bash +openclaw gateway call audit.run.inspect \ + --params '{"runId":"8c69f72e-8b11-4c54-98d5-1a3dd67450c3","decisionLimit":50}' + +openclaw gateway call audit.run.inspect \ + --params '{"executionId":"5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf","decisionLimit":50}' +``` + +Its result is `{ "schemaVersion": 1, "run": ..., "identity": ..., "decisions": +..., "coverage": ..., "nextDecisionCursor"?: ..., "nextExecutionCursor"?: ... }`. +The closed request accepts exactly one of `executionId` or `runId`. +`decisionLimit` is 1–100 and `decisionCursor` is optional. Run discovery also +accepts `executionLimit` from 1–50 and an optional `executionCursor`. A run +with multiple retained executions returns the typed `ambiguous` identity state +and no identity context or decisions until the caller selects an execution id. + The shipped `audit.list` RPC remains unchanged for older run/tool clients. When `audit.activity.list` is unavailable on an older Gateway, the CLI retries `audit.list` only if every requested filter is supported by that legacy method. `--kind message`, diff --git a/docs/cli/config.md b/docs/cli/config.md index 7bfa1365f8e0..f145d9a159e7 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -33,6 +33,7 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" openclaw config set agents.defaults.heartbeat.every "2h" +openclaw config set logging.audit.executionIdentity true openclaw config set 'agents.entries.main.tools.exec.node' "node-id-or-name" openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN diff --git a/docs/gateway/audit.md b/docs/gateway/audit.md index be9e816fff55..4593687798d3 100644 --- a/docs/gateway/audit.md +++ b/docs/gateway/audit.md @@ -20,6 +20,110 @@ normalized outcome codes. It never stores prompts, message bodies, tool arguments, tool results, attachments, filenames, URLs, command output, or raw error text. +The Gateway also keeps an adjacent execution identity context for newly +admitted agent runs. This context is authoritative for the identity facts it +contains; it does not make the activity ledger lossless and does not turn audit +records into authorization evidence. + +## Run identity inspection + +Execution identity recording is off by default, including on fresh installs +and upgrades. Enable it explicitly, then restart the Gateway: + +```bash +openclaw config set logging.audit.executionIdentity true +openclaw gateway restart +``` + +Collection requires both `logging.audit.enabled` and +`logging.audit.executionIdentity` to be true. Setting either to `false` +stops new contexts after restart; no environment-variable alias or silent +migration enables the feature. Retained contexts remain inspectable until +their 30-day expiry. + +After session work admission succeeds, OpenClaw validates and freezes +one bounded identity envelope, immediately offers it to the existing audit +writer queue, and continues the run without waiting for writer readiness, +SQLite, or persistence. The worker initializes schema and HMAC-key state, +pseudonymizes raw references, constructs the immutable context, validates its +canonical bytes, and persists it. An accepted envelope can therefore be +temporarily unavailable to inspection while queued work finishes. + +Persistence remains best-effort. Queue saturation, worker or storage failure, +and process crashes can lose evidence; they log only a bounded operational +warning and never abort the run. Normal Gateway and direct-local CLI shutdown +flushes accepted work when the writer lifecycle permits, but abrupt termination +can still lose queued evidence. + +When identity collection is enabled, restart recovery stores only the safe +execution/context/run ids and timestamp with its existing private recovery +owner. A later ambiguous retry references that token instead of rebuilding +identity from the new process. When collection or the audit ledger is disabled, +recovery creates, stores, and propagates no new identity token. If the original +queued context was lost, exact inspection stays explicitly unavailable; the +retry never manufactures replacement evidence. Raw identity references are not +stored in the recovery token. + +Each admitted outer turn receives a new opaque `executionId`; `contextId` +identifies its immutable evidence record, while the existing `runId` remains a +possibly shared routing, session, or recovery correlation. Query one exact +execution with `audit.run.inspect` or +[`openclaw audit --execution --explain`](/cli/audit). Use `--run +--explain` to discover executions for a run correlation. One retained match +resolves directly. Multiple matches return `ambiguous` with at most 50 +candidate execution ids and require exact selection; OpenClaw never chooses the +first or latest execution silently. The result explicitly states the evidence +state for these fields: + +- trust domain, invoker, and ingress; +- agent principal, agent definition, and runtime instance; +- represented subject and sponsor; +- applicable grants and assurance evidence; +- parent or child lineage when available. + +The foundation records direct local CLI ingress and Gateway boot-system ingress +at their authoritative producers. Generic public ingress remains explicitly +unknown when its boundary cannot prove a more specific source; OpenClaw never +infers ingress or invoker identity from a session key. A direct local execution +is `unattributed`: the Gateway cell, local CLI ingress, configured agent, and +runtime binding are present, but no durable invoker principal is supplied at +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. + +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 +an explanation of admission evidence, not an enforcement claim. + +Run inspection returns successful typed diagnostics instead of inventing +facts: + +- `unknown`: the selected run or execution is not known, or expected context is + corrupt or unreadable; +- `unsupported`: best-effort activity shows the run, but no context is + available, as with a pre-feature, disabled, or failed context write. A + context just beyond retention also uses this state while its bounded cleanup + is pending, with an explicit expiry remediation; +- `ambiguous`: a `runId` has multiple retained executions; select a candidate + `executionId` before inspecting identity or decisions; +- `unattributed`: the supported run has no usable invoker principal; +- `attribution-only`: invoker attribution exists but was not evaluated for + authorization. + +The method requires `operator.read`. Requests are closed and select exactly one +`executionId` or `runId`. Decision pages contain at most 100 receipts; +ambiguous run-discovery pages contain at most 50 candidate executions. Both use +bounded cursors. + +Every client with `operator.read` in the same Gateway operator domain may +receive this retained identity category. This is intentional: the scope already +covers logs and session reads, collection is explicit opt-in, retained +references are bounded and pseudonymized, and optional display labels are +secret-redacted. `operator.read` is not a hostile multi-tenant isolation +boundary; use separate Gateway trust domains when operators must not share this +diagnostic data. + ## Record families Run and tool events are recorded whenever auditing is enabled (the default). @@ -89,6 +193,17 @@ Run and tool records retain `sessionKey` and `sessionId` for correlation; canonical session keys can themselves contain platform account or peer ids. Message records intentionally omit both. +Execution identity contexts use the same installation-local key owner with a +separate HMAC domain. Raw runtime, invoker, ingress-source, assurance, and grant +references exist only in a deeply frozen, in-process worker message capped at +16 KiB and 16 entries in each grant/assurance array. The worker replaces them with keyed +pseudonyms before persistence; they are never stored, exported, inspected, or +logged. Configured agent ids plus context, execution, and run ids remain +operator-visible. +Contexts never contain prompt or message text, command bodies, arguments, +paths, credentials, environment values, or arbitrary plugin payloads. Each +encoded context is also capped at 16 KiB. + Audit exports remain sensitive operational metadata even without content: timing, channels, outcomes, and stable pseudonyms can correlate activity. Protect exports with the same access controls and retention practices as other @@ -100,8 +215,8 @@ The ledger is best-effort and deliberately bounded. Treat it as evidence of what was recorded, not as proof of what happened: - **Absence of a row proves nothing.** Pre-admission inbound drops, sends from - CLI processes without a running Gateway recorder, and plugin-local or - direct-send paths that bypass shared durable delivery leave no record. + plugin-local or direct-send paths that bypass shared durable delivery, a + dropped admission envelope, and crash-lost queued work can leave no record. - Writes go through a bounded background worker; worker failure or queue saturation drops records and logs one operational warning. - Crash-ambiguous outbound sends are recorded as `unknown` rather than @@ -123,6 +238,31 @@ Upgrading from a Gateway with the earlier run/tool-only ledger migrates the schema automatically at startup (or via `openclaw doctor --fix`); existing rows and their ledger sequences are preserved. +Execution identity contexts also live in the shared state database. Canonical +rows are keyed by unique execution and context ids; `runId` is a non-unique, +indexed correlation. Their +additive table is created lazily on first use without a schema-version bump. +Fresh and upgraded installations do not populate identity contexts until an +operator enables collection. +First-use schema creation, HMAC-key access, canonical context construction, and +all SQLite work happen in the audit worker, never in agent admission. +Contexts are retained for 30 days and capped at 100,000 rows. Exact-execution +inspection and run discovery never return a context, candidate, or admission +decision after that context is older than 30 days, even if physical cleanup +has not run. Expired +rows are pruned during Gateway startup, hourly audit maintenance, and later +context writes, with at most 1,024 identity-context rows removed per write or +maintenance tick. Maintenance continues when collection is disabled. An older +build ignores this table. + +Immediately after expiry, inspection can report the run as `unsupported` while +the expired row still proves only that its identity context became unavailable; +no expired fields or decisions are returned. After bounded cleanup, the same +lookup can become `unknown` if no separately retained best-effort activity +remains. That transition does not prove the run did not occur. These limits +make the inspector an operational diagnostic surface, not a compliance +archive. + ## Querying - CLI: [`openclaw audit`](/cli/audit) with filters for agent, session, run, @@ -131,6 +271,10 @@ rows and their ledger sequences are preserved. versioned V1 activity event union; the shipped `audit.list` RPC is unchanged for older run/tool clients. See [Gateway protocol](/gateway/protocol#audit-ledger-rpc). +- Identity RPC: `audit.run.inspect` (requires `operator.read`) accepts one + `executionId` for exact inspection or one `runId` for bounded discovery. It + returns the immutable V1 context and admission receipt for an exact match, or + a typed ambiguous candidate page when a run has multiple executions. ## Related diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 813969d4ff3c..a1c24bd9b659 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1215,6 +1215,7 @@ Notes: logging: { audit: { enabled: true, + executionIdentity: false, messages: "off", // off | direct | all }, }, @@ -1239,6 +1240,11 @@ and coverage limits. the incident. Setting `false` stops new event inserts after the Gateway restarts; existing records stay readable until they expire. Turning it back on resumes recording from that point — the gap is not backfilled. +- `executionIdentity`: retain bounded attribution context for exact execution + inspection (default: `false`). This privacy-sensitive metadata is disabled + on fresh installs and upgrades. Collection requires `enabled: true`; use + `openclaw config set logging.audit.executionIdentity true`, then restart the + Gateway. There is no environment-variable alias. - `messages`: message metadata scope (default: `"off"`). `"direct"` records known direct conversations only. `"all"` also records group, channel, and unknown conversation kinds. Both modes remain content-free and replace raw @@ -1250,9 +1256,9 @@ A root-level `audit` block is retired; the canonical path is `logging.audit`. The root config object is strict, so an old top-level `audit` block is rejected. Run [`openclaw doctor --fix`](/cli/doctor) to move it to `logging.audit`. -The running Gateway captures `logging.audit.enabled` and -`logging.audit.messages` at startup; -restart it after changing either setting. Message coverage currently includes +The running Gateway captures `logging.audit.enabled`, +`logging.audit.executionIdentity`, and `logging.audit.messages` at startup; +restart it after changing any of these settings. Message coverage currently includes accepted inbound messages that reach core dispatch and one terminal row per original logical outbound reply payload that reaches shared durable delivery. Plugin-local and direct-send paths that bypass those shared boundaries are not diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 209427653587..e6a8531103d5 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -31,7 +31,7 @@ require the `node` role. | Scope | Meaning | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `operator.read` | Read-only status, lists, catalog, logs, session reads, and other non-mutating calls. | +| `operator.read` | Read-only status, lists, catalog, logs, session reads, retained audit and execution-identity diagnostics, and other non-mutating calls. | | `operator.write` | Mutating operator actions: sending messages, invoking tools, updating talk/voice settings, node command relay. Also satisfies `operator.read`. | | `operator.admin` | Administrative access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, reserved namespaces, and high-risk approvals. | | `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. | @@ -78,6 +78,13 @@ independent of the connecting client's `client.id` or `client.mode`. Client identity can still affect connection and device-auth policy, but it neither grants nor removes session mutation authority. +`audit.run.inspect` intentionally uses `operator.read`. Every client with that +scope in a Gateway operator domain may receive the retained execution-identity +context, including bounded pseudonymized references and secret-redacted display +labels. `operator.read` is not a per-user or hostile multi-tenant privacy +boundary. Operators who must keep this data separate need separate Gateway +trust domains. + ## Device pairing approvals Device pairing records are the durable source of approved roles and scopes. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 14f440bcba45..689e9b083b20 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -602,7 +602,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `agents.list` returns gateway-visible agent entries, including effective model/runtime metadata and optional semantic `kind` (`agent` or `system`). Clients advertise the `agent-kind` handshake capability to receive the complete typed roster; clients without it keep the legacy selector-safe roster without system rows. Kind-aware clients exclude `system` rows from ordinary selectors while retaining them in diagnostic views. Older v4 gateways may return rows without `kind`. - `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring. - `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent. - - `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.list` remains the compatibility-safe run/tool RPC. + - `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.run.inspect` discovers execution ids or inspects one exact execution identity context; `audit.list` remains the compatibility-safe run/tool RPC. - `agents.workspace.list` and `agents.workspace.get` (`operator.read`) expose read-only, paginated browsing of an agent's workspace directory for clients in the trusted operator domain described in [Operator scopes](/gateway/operator-scopes). Requests accept workspace-relative paths only; reads stay confined to the realpathed workspace root (symlink and hardlink escapes rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses do not expose the host workspace path. There are no write operations in this namespace. - `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below. - `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. @@ -841,6 +841,16 @@ recording is separately controlled by `logging.audit.messages` and defaults to recording is disabled, `audit.activity.list` keeps serving records written earlier until they expire. +`audit.run.inspect` also requires `operator.read`. Its closed request selects +exactly one `executionId` for exact inspection or one `runId` for bounded +execution discovery. One run match resolves directly; multiple matches return +an explicit `ambiguous` result with at most 50 candidates and require exact +execution selection. Decision pages contain at most 100 receipts. Execution +identity collection is separately off by default and requires +`logging.audit.executionIdentity: true` plus an enabled audit ledger after +Gateway restart. Missing best-effort evidence never proves that a run did not +occur. + The shipped `audit.list` request, result, and `AuditEvent` schemas remain unchanged and return only agent-run and tool-action records. New operator clients should call `audit.activity.list` when the Gateway advertises it. Older diff --git a/extensions/discord/src/voice/ingress.test.ts b/extensions/discord/src/voice/ingress.test.ts new file mode 100644 index 000000000000..4753cb752c8e --- /dev/null +++ b/extensions/discord/src/voice/ingress.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +type MockIngressInput = { message?: string; sessionKey?: string; runId?: string }; + +const mocks = vi.hoisted(() => ({ + agentCommandFromIngress: vi.fn(async (_input: MockIngressInput) => ({ + payloads: [{ text: "spoken" }], + })), +})); + +vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ + agentCommandFromIngress: mocks.agentCommandFromIngress, +})); + +import { runDiscordVoiceAgentTurn } from "./ingress.js"; + +describe("Discord voice ingress execution correlation", () => { + it("admits sequential same-session turns without inventing a public run id", async () => { + const entry = { + guildId: "guild-1", + channelId: "channel-1", + route: { agentId: "main", sessionKey: "agent:main:discord:channel:channel-1" }, + }; + const shared = { + entry: entry as never, + userId: "user-1", + cfg: {} as never, + discordConfig: {} as never, + runtime: { log: vi.fn(), error: vi.fn() } as never, + context: { senderIsOwner: false, speakerLabel: "Guest" }, + fetchGuildName: vi.fn(async () => "Guild"), + speakerContext: {} as never, + }; + + await runDiscordVoiceAgentTurn({ ...shared, message: "first turn" }); + await runDiscordVoiceAgentTurn({ ...shared, message: "second turn" }); + + expect(mocks.agentCommandFromIngress).toHaveBeenCalledTimes(2); + const inputs = mocks.agentCommandFromIngress.mock.calls.map(([input]) => input); + expect(inputs.map((input) => input.message)).toEqual(["first turn", "second turn"]); + expect(inputs.map((input) => input.sessionKey)).toEqual([ + "agent:main:discord:channel:channel-1", + "agent:main:discord:channel:channel-1", + ]); + for (const input of inputs) { + expect(input).not.toHaveProperty("runId"); + } + }); +}); diff --git a/packages/gateway-protocol/src/schema-export-registry.ts b/packages/gateway-protocol/src/schema-export-registry.ts index 644dcf0b5058..4e6af9e35988 100644 --- a/packages/gateway-protocol/src/schema-export-registry.ts +++ b/packages/gateway-protocol/src/schema-export-registry.ts @@ -255,6 +255,15 @@ export { AuditActivityListResultSchema, AuditActivityOutboundMessageV1Schema, AuditActivityToolActionV1Schema, + ExecutionIdentityContextV1Schema, + DecisionReceiptV1Schema, + AuditRunIdentityPresentV1Schema, + AuditRunIdentityUnknownV1Schema, + AuditRunIdentityUnsupportedV1Schema, + AuditRunIdentityAmbiguousV1Schema, + AuditRunIdentityV1Schema, + AuditRunInspectParamsSchema, + AuditRunInspectResultSchema, AuditEventSchema, AuditListParamsSchema, AuditListResultSchema, diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index ad8acac6708c..9da78b14138e 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -6,6 +6,7 @@ export * from "./schema/agents-workspace.js"; export * from "./schema/artifacts.js"; export * from "./schema/approvals.js"; export * from "./schema/audit-activity.js"; +export * from "./schema/audit-run.js"; export * from "./schema/audit.js"; export * from "./schema/board.js"; export * from "./schema/users.js"; diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index 5c40074860a6..09eee97a0adf 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -321,6 +321,9 @@ export const AgentParamsSchema = closedObject({ ), acpTurnSource: Type.Optional(Type.Literal("manual_spawn")), internalRuntimeHandoffId: Type.Optional(NonEmptyString), + // Enabled backend recovery supplies only capture/retry mode. Disabled collection omits it; + // the private token, when present, remains in durable session state. + internalExecutionIdentityRetry: Type.Optional(Type.Boolean()), execApprovalFollowupExpectedSessionId: Type.Optional(NonEmptyString), internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)), inputProvenance: Type.Optional(InputProvenanceSchema), diff --git a/packages/gateway-protocol/src/schema/audit-run.test.ts b/packages/gateway-protocol/src/schema/audit-run.test.ts new file mode 100644 index 000000000000..eb4b965b8865 --- /dev/null +++ b/packages/gateway-protocol/src/schema/audit-run.test.ts @@ -0,0 +1,160 @@ +import { Compile } from "typebox/compile"; +import { describe, expect, it } from "vitest"; +import { validateAuditRunInspectParams, validateExecutionIdentityContextV1 } from "../index.js"; +import { + AuditRunInspectParamsSchema, + AuditRunInspectResultSchema, + DecisionReceiptV1Schema, + type ExecutionIdentityContextV1, +} from "./audit-run.js"; + +const hmacRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`; + +function context(): ExecutionIdentityContextV1 { + return { + schemaVersion: 1, + contextId: "context-1", + executionId: "execution-1", + runId: "run-1", + createdAt: 1, + trustDomain: { kind: "gateway-cell", domainRef: hmacRef, state: "present" }, + invoker: { state: "absent" }, + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + }, + agentPrincipal: { kind: "agent", domainRef: hmacRef, principalRef: "main" }, + agentDefinition: { definitionRef: "main", state: "present" }, + runtimeInstance: { runtimeRef: hmacRef, kind: "embedded", state: "present" }, + applicableGrants: [], + assurance: [{ kind: "runtime-binding", evidenceRef: hmacRef, strength: "boundary-verified" }], + coverageState: "unattributed", + missingEvidence: ["invoker.principal"], + }; +} + +describe("audit run inspection protocol", () => { + it("accepts the bounded V1 context and truthful admission receipt", () => { + const identity = context(); + expect(validateExecutionIdentityContextV1(identity)).toBe(true); + expect(Buffer.byteLength(JSON.stringify(identity), "utf8")).toBeLessThan(16 * 1024); + + const validateReceipt = Compile(DecisionReceiptV1Schema); + expect( + validateReceipt.Check({ + schemaVersion: 1, + receiptId: "receipt-1", + contextId: identity.contextId, + executionId: identity.executionId, + runId: identity.runId, + occurredAt: identity.createdAt, + action: { family: "run", operation: "admission" }, + decision: { + outcome: "not-applicable", + reasonCode: "run_admission_identity_not_evaluated", + }, + enforcement: { + coverageState: "unattributed", + policyRefs: [], + grantRefs: [], + contextFieldsUsed: [], + }, + source: { + owner: "agent-command", + recordRef: identity.contextId, + decisionBoundary: "agent-command.run-admission", + }, + missingEvidence: ["invoker.principal"], + remediation: [{ code: "none", text: "No enforcement is claimed." }], + }), + ).toBe(true); + }); + + it("closes request objects and enforces exact-run query bounds", () => { + expect(validateAuditRunInspectParams({ runId: "run-1", decisionLimit: 100 })).toBe(true); + expect(validateAuditRunInspectParams({ executionId: "execution-1" })).toBe(true); + expect(validateAuditRunInspectParams({ runId: "run-1", executionLimit: 50 })).toBe(true); + expect(validateAuditRunInspectParams({ runId: "run-1", executionLimit: 51 })).toBe(false); + expect(validateAuditRunInspectParams({})).toBe(false); + expect(validateAuditRunInspectParams({ runId: "run-1", executionId: "execution-1" })).toBe( + false, + ); + expect(validateAuditRunInspectParams({ executionId: "execution-1", executionLimit: 2 })).toBe( + false, + ); + expect(validateAuditRunInspectParams({ runId: "", decisionLimit: 50 })).toBe(false); + expect(validateAuditRunInspectParams({ runId: "run-1", decisionLimit: 101 })).toBe(false); + expect(validateAuditRunInspectParams({ runId: "run-1", extra: true })).toBe(false); + }); + + it("exports selector and discovery-pagination invariants for generated clients", () => { + const validate = Compile(AuditRunInspectParamsSchema); + + expect(validate.Check({ runId: "run-1", executionCursor: "cursor-1" })).toBe(true); + expect(validate.Check({ executionId: "execution-1", decisionLimit: 100 })).toBe(true); + expect(validate.Check({})).toBe(false); + expect(validate.Check({ runId: "run-1", executionId: "execution-1" })).toBe(false); + expect(validate.Check({ executionId: "execution-1", executionCursor: "cursor-1" })).toBe(false); + expect(validate.Check({ executionId: "execution-1", executionLimit: 2 })).toBe(false); + }); + + it("accepts bounded ambiguous run discovery without selecting an execution", () => { + const validate = Compile(AuditRunInspectResultSchema); + expect( + validate.Check({ + schemaVersion: 1, + run: { runId: "run-1", status: "known" }, + identity: { + state: "ambiguous", + reasonCode: "execution_selection_required", + candidates: [ + { executionId: "execution-1", contextId: "context-1", createdAt: 1 }, + { executionId: "execution-2", contextId: "context-2", createdAt: 2 }, + ], + missingEvidence: ["execution.selection"], + remediation: [{ code: "select_execution_id", text: "Select one exact execution." }], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["execution.selection"] }, + }), + ).toBe(true); + }); + + it("rejects malformed, oversized, and open-ended context payloads", () => { + expect(validateExecutionIdentityContextV1({ ...context(), extra: true })).toBe(false); + expect( + validateExecutionIdentityContextV1({ + ...context(), + missingEvidence: Array.from({ length: 17 }, (_, index) => `missing-${index}`), + }), + ).toBe(false); + expect( + validateExecutionIdentityContextV1({ + ...context(), + runtimeInstance: { ...context().runtimeInstance, kind: "mystery" }, + }), + ).toBe(false); + }); + + it.each(["unknown", "unsupported"] as const)( + "accepts a typed %s diagnostic without inventing identity", + (state) => { + const validate = Compile(AuditRunInspectResultSchema); + expect( + validate.Check({ + schemaVersion: 1, + run: { runId: "run-1", status: state === "unknown" ? "unknown" : "known" }, + identity: { + state, + reasonCode: `${state}_identity`, + missingEvidence: ["identity.context"], + remediation: [{ code: "retry", text: "Retry after checking the run id." }], + }, + decisions: [], + coverage: { state, missingEvidence: ["identity.context"] }, + }), + ).toBe(true); + }, + ); +}); diff --git a/packages/gateway-protocol/src/schema/audit-run.ts b/packages/gateway-protocol/src/schema/audit-run.ts new file mode 100644 index 000000000000..d3cda6b5057e --- /dev/null +++ b/packages/gateway-protocol/src/schema/audit-run.ts @@ -0,0 +1,317 @@ +// Versioned exact-execution identity and run-discovery projections. +import { type Static, Type } from "typebox"; +import { closedObject } from "./closed-object.js"; + +const ExecutionIdentityRefSchema = Type.String({ minLength: 1, maxLength: 256 }); +const ExecutionIdentityDisplayLabelSchema = Type.String({ maxLength: 128 }); +const ExecutionIdentityEvidenceStateSchema = Type.Union([ + Type.Literal("present"), + Type.Literal("absent"), + Type.Literal("unknown"), + Type.Literal("unsupported"), +]); +const ExecutionIdentityContextCoverageStateSchema = Type.Union([ + Type.Literal("attribution-only"), + Type.Literal("unattributed"), + Type.Literal("unknown"), + Type.Literal("unsupported"), +]); +const ExecutionIdentityDecisionCoverageStateSchema = Type.Union([ + Type.Literal("enforced"), + Type.Literal("attribution-only"), + Type.Literal("unattributed"), + Type.Literal("unknown"), + Type.Literal("unsupported"), +]); +const ExecutionIdentityRefArraySchema = Type.Array(ExecutionIdentityRefSchema, { maxItems: 16 }); + +const ExecutionIdentityPrincipalKindSchema = Type.Union([ + Type.Literal("person"), + Type.Literal("agent"), + Type.Literal("service"), + Type.Literal("schedule"), + Type.Literal("webhook"), + Type.Literal("system"), + Type.Literal("local-account"), + Type.Literal("runtime"), +]); + +export const PrincipalRefV1Schema = closedObject({ + kind: ExecutionIdentityPrincipalKindSchema, + domainRef: ExecutionIdentityRefSchema, + principalRef: ExecutionIdentityRefSchema, + displayLabel: Type.Optional(ExecutionIdentityDisplayLabelSchema), +}); + +const PrincipalFactV1Schema = closedObject({ + principal: PrincipalRefV1Schema, + state: ExecutionIdentityEvidenceStateSchema, +}); + +const SponsorFactV1Schema = closedObject({ + principal: PrincipalRefV1Schema, + relationshipRef: Type.Optional(ExecutionIdentityRefSchema), + state: ExecutionIdentityEvidenceStateSchema, +}); + +const AssuranceKindSchema = 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"), +]); + +const AssuranceEvidenceV1Schema = closedObject({ + kind: AssuranceKindSchema, + evidenceRef: ExecutionIdentityRefSchema, + strength: Type.Union([ + Type.Literal("self-asserted"), + Type.Literal("boundary-verified"), + Type.Literal("cryptographic"), + ]), +}); + +const ExecutionIdentityIngressKindSchema = 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 ExecutionIdentityRuntimeKindSchema = Type.Union([ + Type.Literal("gateway"), + Type.Literal("embedded"), + Type.Literal("worker"), + Type.Literal("plugin-harness"), + Type.Literal("acp"), +]); + +export const ExecutionIdentityContextV1Schema = closedObject({ + schemaVersion: Type.Literal(1), + contextId: ExecutionIdentityRefSchema, + executionId: ExecutionIdentityRefSchema, + runId: ExecutionIdentityRefSchema, + createdAt: Type.Integer({ minimum: 0 }), + trustDomain: closedObject({ + kind: Type.Literal("gateway-cell"), + domainRef: ExecutionIdentityRefSchema, + state: ExecutionIdentityEvidenceStateSchema, + }), + invoker: closedObject({ + principal: Type.Optional(PrincipalRefV1Schema), + state: ExecutionIdentityEvidenceStateSchema, + }), + ingress: closedObject({ + kind: ExecutionIdentityIngressKindSchema, + sourceRef: Type.Optional(ExecutionIdentityRefSchema), + boundary: ExecutionIdentityRefSchema, + state: ExecutionIdentityEvidenceStateSchema, + }), + agentPrincipal: PrincipalRefV1Schema, + agentDefinition: closedObject({ + definitionRef: ExecutionIdentityRefSchema, + revisionRef: Type.Optional(ExecutionIdentityRefSchema), + state: ExecutionIdentityEvidenceStateSchema, + }), + runtimeInstance: closedObject({ + runtimeRef: ExecutionIdentityRefSchema, + kind: ExecutionIdentityRuntimeKindSchema, + state: ExecutionIdentityEvidenceStateSchema, + }), + representedSubject: Type.Optional(PrincipalFactV1Schema), + sponsor: Type.Optional(SponsorFactV1Schema), + applicableGrants: Type.Array( + closedObject({ + grantRef: ExecutionIdentityRefSchema, + state: ExecutionIdentityEvidenceStateSchema, + }), + { maxItems: 16 }, + ), + assurance: Type.Array(AssuranceEvidenceV1Schema, { maxItems: 16 }), + lineage: Type.Optional( + closedObject({ + parentContextId: Type.Optional(ExecutionIdentityRefSchema), + parentExecutionId: Type.Optional(ExecutionIdentityRefSchema), + parentRunId: Type.Optional(ExecutionIdentityRefSchema), + parentAgentPrincipal: Type.Optional(PrincipalRefV1Schema), + delegationRef: Type.Optional(ExecutionIdentityRefSchema), + depth: Type.Integer({ minimum: 0, maximum: 64 }), + }), + ), + coverageState: ExecutionIdentityContextCoverageStateSchema, + missingEvidence: ExecutionIdentityRefArraySchema, +}); + +const ExecutionIdentityRemediationV1Schema = closedObject({ + code: ExecutionIdentityRefSchema, + text: Type.String({ minLength: 1, maxLength: 512 }), +}); + +export const DecisionReceiptV1Schema = closedObject({ + schemaVersion: Type.Literal(1), + receiptId: ExecutionIdentityRefSchema, + contextId: ExecutionIdentityRefSchema, + executionId: ExecutionIdentityRefSchema, + runId: ExecutionIdentityRefSchema, + actionId: Type.Optional(ExecutionIdentityRefSchema), + occurredAt: Type.Integer({ minimum: 0 }), + action: closedObject({ + family: ExecutionIdentityRefSchema, + operation: ExecutionIdentityRefSchema, + resourceRef: Type.Optional(ExecutionIdentityRefSchema), + targetRef: Type.Optional(ExecutionIdentityRefSchema), + summary: Type.Optional(Type.String({ maxLength: 512 })), + }), + decision: closedObject({ + outcome: Type.Union([ + Type.Literal("allowed"), + Type.Literal("denied"), + Type.Literal("not-applicable"), + Type.Literal("unknown"), + ]), + reasonCode: ExecutionIdentityRefSchema, + }), + enforcement: closedObject({ + coverageState: ExecutionIdentityDecisionCoverageStateSchema, + evaluatorRef: Type.Optional(ExecutionIdentityRefSchema), + policyRefs: ExecutionIdentityRefArraySchema, + grantRefs: ExecutionIdentityRefArraySchema, + contextFieldsUsed: ExecutionIdentityRefArraySchema, + }), + source: closedObject({ + owner: ExecutionIdentityRefSchema, + recordRef: ExecutionIdentityRefSchema, + decisionBoundary: ExecutionIdentityRefSchema, + }), + missingEvidence: ExecutionIdentityRefArraySchema, + remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }), +}); + +export const AuditRunIdentityPresentV1Schema = closedObject({ + state: Type.Literal("present"), + context: ExecutionIdentityContextV1Schema, +}); + +export const AuditRunIdentityUnknownV1Schema = closedObject({ + state: Type.Literal("unknown"), + reasonCode: ExecutionIdentityRefSchema, + missingEvidence: ExecutionIdentityRefArraySchema, + remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }), +}); + +export const AuditRunIdentityUnsupportedV1Schema = closedObject({ + state: Type.Literal("unsupported"), + reasonCode: ExecutionIdentityRefSchema, + missingEvidence: ExecutionIdentityRefArraySchema, + remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }), +}); + +export const AuditRunIdentityAmbiguousV1Schema = closedObject({ + state: Type.Literal("ambiguous"), + reasonCode: ExecutionIdentityRefSchema, + candidates: Type.Array( + closedObject({ + executionId: ExecutionIdentityRefSchema, + contextId: ExecutionIdentityRefSchema, + createdAt: Type.Integer({ minimum: 0 }), + }), + { maxItems: 50 }, + ), + missingEvidence: ExecutionIdentityRefArraySchema, + remediation: Type.Array(ExecutionIdentityRemediationV1Schema, { maxItems: 8 }), +}); + +export const AuditRunIdentityV1Schema = Type.Union([ + AuditRunIdentityPresentV1Schema, + AuditRunIdentityUnknownV1Schema, + AuditRunIdentityUnsupportedV1Schema, + AuditRunIdentityAmbiguousV1Schema, +]); + +const AuditRunDecisionPageParams = { + decisionCursor: Type.Optional(ExecutionIdentityRefSchema), + decisionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), +}; + +export const AuditRunInspectParamsSchema = Type.Object( + { + runId: Type.Optional(ExecutionIdentityRefSchema), + executionId: Type.Optional(ExecutionIdentityRefSchema), + executionCursor: Type.Optional(ExecutionIdentityRefSchema), + executionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })), + ...AuditRunDecisionPageParams, + }, + { + additionalProperties: false, + // Keep exact selection and run discovery mutually exclusive in the exported + // wire schema so generated clients cannot construct server-rejected requests. + oneOf: [ + { required: ["runId"], not: { required: ["executionId"] } }, + { + required: ["executionId"], + not: { + anyOf: [ + { required: ["runId"] }, + { required: ["executionCursor"] }, + { required: ["executionLimit"] }, + ], + }, + }, + ], + }, +); + +export const AuditRunInspectResultSchema = closedObject({ + schemaVersion: Type.Literal(1), + run: closedObject({ + runId: Type.Optional(ExecutionIdentityRefSchema), + executionId: Type.Optional(ExecutionIdentityRefSchema), + status: Type.Union([Type.Literal("known"), Type.Literal("unknown")]), + }), + identity: AuditRunIdentityV1Schema, + decisions: Type.Array(DecisionReceiptV1Schema, { maxItems: 100 }), + coverage: closedObject({ + state: ExecutionIdentityDecisionCoverageStateSchema, + missingEvidence: ExecutionIdentityRefArraySchema, + }), + nextDecisionCursor: Type.Optional(ExecutionIdentityRefSchema), + nextExecutionCursor: Type.Optional(ExecutionIdentityRefSchema), +}); + +export type PrincipalRefV1 = Static; +export type ExecutionIdentityContextV1 = Static; +export type DecisionReceiptV1 = Static; +export type AuditRunIdentityV1 = Static; +type AuditRunDecisionPage = { + decisionCursor?: string; + decisionLimit?: number; +}; +export type AuditRunInspectParams = + | (AuditRunDecisionPage & { + runId: string; + executionId?: never; + executionCursor?: string; + executionLimit?: number; + }) + | (AuditRunDecisionPage & { + executionId: string; + runId?: never; + executionCursor?: never; + executionLimit?: never; + }); +export type AuditRunInspectResult = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts index c4d3a3b5f041..20a29d854fac 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-operations.ts @@ -1,4 +1,5 @@ import * as auditActivity from "./audit-activity.js"; +import * as auditRun from "./audit-run.js"; import * as audit from "./audit.js"; import * as config from "./config.js"; import * as openclaw from "./openclaw.js"; @@ -14,6 +15,15 @@ export const OperationsProtocolSchemas = { AuditActivityEventV1: auditActivity.AuditActivityEventV1Schema, AuditActivityListParams: auditActivity.AuditActivityListParamsSchema, AuditActivityListResult: auditActivity.AuditActivityListResultSchema, + ExecutionIdentityContextV1: auditRun.ExecutionIdentityContextV1Schema, + DecisionReceiptV1: auditRun.DecisionReceiptV1Schema, + AuditRunIdentityPresentV1: auditRun.AuditRunIdentityPresentV1Schema, + AuditRunIdentityUnknownV1: auditRun.AuditRunIdentityUnknownV1Schema, + AuditRunIdentityUnsupportedV1: auditRun.AuditRunIdentityUnsupportedV1Schema, + AuditRunIdentityAmbiguousV1: auditRun.AuditRunIdentityAmbiguousV1Schema, + AuditRunIdentityV1: auditRun.AuditRunIdentityV1Schema, + AuditRunInspectParams: auditRun.AuditRunInspectParamsSchema, + AuditRunInspectResult: auditRun.AuditRunInspectResultSchema, AuditEvent: audit.AuditEventSchema, AuditListParams: audit.AuditListParamsSchema, AuditListResult: audit.AuditListResultSchema, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index e26ab724ffbc..f2f382368d13 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -2,6 +2,7 @@ import { lazyCompile as compile } from "./protocol-validator.js"; import * as S from "./schema-modules.js"; import type { AuditActivityListParams, + AuditRunInspectParams, WebPushSubscribeParams, WebPushTestParams, WebPushUnsubscribeParams, @@ -84,6 +85,10 @@ export const validateAgentParams = compile(S.AgentParamsSchema); export const validateAuditActivityListParams = compile( S.AuditActivityListParamsSchema, ); +export const validateAuditRunInspectParams = compile( + S.AuditRunInspectParamsSchema, +); +export const validateExecutionIdentityContextV1 = compile(S.ExecutionIdentityContextV1Schema); export const validateAuditListParams = compile(S.AuditListParamsSchema); export const validateUsersListParams = compile(S.UsersListParamsSchema); export const validateUsersSelfParams = compile(S.UsersSelfParamsSchema); diff --git a/qa/scenarios/runtime/agent-run-identity-inspection.yaml b/qa/scenarios/runtime/agent-run-identity-inspection.yaml new file mode 100644 index 000000000000..eb4fc68761f4 --- /dev/null +++ b/qa/scenarios/runtime/agent-run-identity-inspection.yaml @@ -0,0 +1,37 @@ +title: Agent-run execution identity inspection + +scenario: + id: agent-run-identity-inspection + surface: gateway + coverage: + secondary: + - gateway.identity-and-presence-apis + objective: Verify an operator can inspect one local execution, discover repeated same-session executions, and select each immutable identity context through the audit CLI before and after Gateway restart. + successCriteria: + - A real local agent turn against the deterministic mock provider records one bounded execution identity context. + - Fresh and existing-install restarts keep execution-identity storage absent before explicit opt-in, and global audit disable prevents new identity rows. + - The audit CLI renders text and JSON projections for the exact run, including every identity field and the truthful non-enforcement admission receipt. + - A replacement Gateway process returns byte-equivalent normalized identity context JSON for the same run. + - Two real public-ingress turns that omit runId share the session correlation but retain distinct execution and context ids. + - Run discovery reports those turns as ambiguous and both text and JSON exact-execution selection return the intended turn. + docsRefs: + - docs/gateway/audit.md + - docs/cli/audit.md + - docs/concepts/qa-e2e-automation.md + codeRefs: + - src/agents/agent-command.ts + - src/agents/agent-command-execution-identity.ts + - src/audit/execution-identity-admission.ts + - src/audit/audit-event-writer.ts + - src/audit/execution-identity-context.ts + - src/gateway/server-methods/audit.ts + - src/commands/audit.ts + - test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts + - test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts + execution: + kind: script + path: test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts + summary: Starts an ephemeral Gateway and mock provider, runs local and repeated public-ingress turns, proves run ambiguity plus exact text/JSON selection, replaces the Gateway process, and compares normalized context bytes. + args: + - --artifact-base + - ${outputDir} diff --git a/scripts/check-protocol-registry.mjs b/scripts/check-protocol-registry.mjs index b74854d3e9d8..ff5e82434853 100644 --- a/scripts/check-protocol-registry.mjs +++ b/scripts/check-protocol-registry.mjs @@ -111,8 +111,8 @@ const ownerModules = [ ...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu), ].map((match) => match[1]); check( - ownerModules.length === 53 && new Set(ownerModules).size === ownerModules.length, - "schema-modules.ts must contain one unique 53-module owner list", + ownerModules.length === 54 && new Set(ownerModules).size === ownerModules.length, + "schema-modules.ts must contain one unique 54-module owner list", ); check( schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length, diff --git a/src/agents/agent-command-execution-identity.ts b/src/agents/agent-command-execution-identity.ts new file mode 100644 index 000000000000..9e1605eed7da --- /dev/null +++ b/src/agents/agent-command-execution-identity.ts @@ -0,0 +1,51 @@ +import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js"; +import { + enqueueExecutionIdentityContextAtAdmission, + type ExecutionIdentityAdmissionFacts, +} from "../audit/execution-identity-admission.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { AgentCommandOpts } from "./command/types.js"; + +type AgentCommandAdmissionIngress = ExecutionIdentityAdmissionFacts["ingress"]; + +const LOCAL_CLI_ADMISSION_INGRESS: AgentCommandAdmissionIngress = { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", +}; + +function systemIngress(boundary: string): AgentCommandAdmissionIngress { + return { kind: "system", boundary, state: "present" }; +} + +function recordAgentCommandExecutionIdentity(params: { + admission?: AgentCommandOpts["executionIdentityAdmission"]; + agentId: string; + cfg: OpenClawConfig; + ingress: AgentCommandAdmissionIngress; + runId: string; + runtimeKind: ExecutionIdentityAdmissionFacts["runtime"]["kind"]; +}): void { + // Session work admission owns these facts. Queue acceptance is not persistence; + // audit loss must never become run loss. + enqueueExecutionIdentityContextAtAdmission( + { + runId: params.runId, + agentId: params.agentId, + ingress: params.ingress, + runtime: { kind: params.runtimeKind }, + }, + { + enabled: isExecutionIdentityCollectionEnabled(params.cfg), + ...(params.admission + ? { token: params.admission.token, retryOnly: params.admission.retryOnly } + : {}), + }, + ); +} + +export const executionIdentity = { + localIngress: LOCAL_CLI_ADMISSION_INGRESS, + record: recordAgentCommandExecutionIdentity, + systemIngress, +}; diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 6cb9942f1b48..00149f82f01d 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -116,12 +116,18 @@ const state = vi.hoisted(() => ({ storePathMock: undefined as string | undefined, resolvedSessionKeyMock: undefined as string | undefined, trajectoryRecorderParamsMock: vi.fn(), + enqueueExecutionIdentityContextAtAdmissionMock: vi.fn(), })); vi.mock("./model-fallback-runner.js", () => ({ runWithModelFallback: (params: unknown) => state.runWithModelFallbackMock(params), })); +vi.mock("../audit/execution-identity-admission.js", () => ({ + enqueueExecutionIdentityContextAtAdmission: (...args: unknown[]) => + state.enqueueExecutionIdentityContextAtAdmissionMock(...args), +})); + vi.mock("./command/attempt-execution.runtime.js", () => ({ buildAcpResult: (...args: unknown[]) => state.buildAcpResultMock(...args), createAcpToolLifecycleTracker: () => ({ @@ -652,11 +658,13 @@ vi.mock("../acp/control-plane/manager.js", () => ({ })); let agentCommand: typeof import("./agent-command.js").agentCommand; +let agentCommandFromSystem: typeof import("./agent-command.js").agentCommandFromSystem; let agentCommandTesting: typeof import("./agent-command.js").testing; beforeAll(async () => { const mod = await import("./agent-command.js"); agentCommand ??= mod.agentCommand; + agentCommandFromSystem ??= mod.agentCommandFromSystem; agentCommandTesting ??= mod.testing; }); @@ -781,6 +789,17 @@ async function runBasicAgentCommand() { }); } +async function runSystemAgentCommand() { + await agentCommandFromSystem( + { + message: "boot", + sessionKey: "agent:main:boot", + deliver: false, + }, + { boundary: "gateway.boot" }, + ); +} + function runDiscordDelivery(overrides: Partial[0]> = {}) { return agentCommand({ message: "hello", @@ -1062,6 +1081,62 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { ); }); + it("keeps collection off by default without blocking local execution", async () => { + setupSuccessfulAttempt(); + + await runBasicAgentCommand(); + + expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenCalledWith( + expect.objectContaining({ + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + }), + { enabled: false }, + ); + expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1); + }); + + it("records authoritative local and system ingress only after explicit opt-in", async () => { + state.runtimeConfigMock = { + ...state.defaultRuntimeConfig, + logging: { audit: { executionIdentity: true } }, + }; + setupSuccessfulAttempt(); + + await runBasicAgentCommand(); + await runSystemAgentCommand(); + + expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + }), + { enabled: true }, + ); + expect(state.enqueueExecutionIdentityContextAtAdmissionMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + }), + { enabled: true }, + ); + }); + + it.each([ + ["local CLI", runBasicAgentCommand], + ["system", runSystemAgentCommand], + ])("keeps %s runs nonblocking when evidence cannot be queued", async (_name, run) => { + state.runtimeConfigMock = { + ...state.defaultRuntimeConfig, + logging: { audit: { executionIdentity: true } }, + }; + state.enqueueExecutionIdentityContextAtAdmissionMock.mockReturnValue(undefined); + setupSuccessfulAttempt(); + + await run(); + + expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1); + }); + it("forwards the auth profile bound to the configured default model", async () => { state.runtimeConfigMock = { agents: { diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 1fe747fcd311..9bf2fbbc1eb8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -24,6 +24,7 @@ import { ensureSessionDiffBaseline } from "../sessions/session-diff-baseline.js" import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; import { classifySessionStateActor } from "../sessions/session-state-events.js"; import { sessionDeliveryChannel, type DeliveryContext } from "../utils/delivery-context.shared.js"; +import { executionIdentity } from "./agent-command-execution-identity.js"; import { runWithAgentCommandRecoveryOwner } from "./agent-command-recovery-owner.js"; import { buildCurrentRunRestartRecoveryClaim, @@ -49,7 +50,11 @@ import { loadSessionStoreRuntime, resolveAgentCommandDeps } from "./command/runt import { persistSessionEntry, prepareCurrentRunDelivery } from "./command/session-helpers.js"; import { prepareEmbeddedSessionState } from "./command/session-preparation.js"; import { clearRotatedSessionMetadata } from "./command/session.js"; -import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js"; +import type { + AgentCommandGatewayIngressOpts, + AgentCommandIngressOpts, + AgentCommandOpts, +} from "./command/types.js"; import { removeInternalSessionEffectsSession, resolveInternalSessionEffectsTarget, @@ -60,11 +65,14 @@ import type { AgentRunSessionTarget } from "./run-session-target.js"; import { createAgentRunRestartAbortError } from "./run-termination.js"; import { measureAgentStartup } from "./startup-timing.js"; +type AgentCommandAdmissionIngress = Parameters[0]["ingress"]; + const log = createSubsystemLogger("agents/agent-command"); async function agentCommandInternal( prepared: Awaited>, initialOpts: AgentCommandOpts, + admissionIngress: AgentCommandAdmissionIngress, runtime: RuntimeEnv = defaultRuntime, deps?: CliDeps, ) { @@ -223,6 +231,14 @@ async function agentCommandInternal( }, }); return await sessionWorkAdmission.run(async () => { + executionIdentity.record({ + admission: opts.executionIdentityAdmission, + agentId: sessionAgentId, + cfg, + ingress: admissionIngress, + runId, + runtimeKind: !isRawModelRun && acpResolution?.kind === "ready" ? "acp" : "embedded", + }); if (sessionStore && sessionKey && !suppressVisibleSessionEffects) { try { await repairPendingAssistantTranscriptTurns({ @@ -577,9 +593,9 @@ async function agentCommandInternal( } } -/** Runs an agent turn from CLI/runtime options against the resolved session and model policy. */ -export async function agentCommand( +async function agentCommandWithAdmissionIngress( opts: AgentCommandOpts, + admissionIngress: AgentCommandAdmissionIngress, runtime: RuntimeEnv = defaultRuntime, deps?: CliDeps, ) { @@ -601,11 +617,8 @@ export async function agentCommand( opts: { ...opts, lifecycleGeneration, - // agentCommand is the trusted-operator entrypoint used by CLI/local flows. - // Ingress callers must opt into owner identity explicitly via - // agentCommandFromIngress so network-facing paths cannot inherit this default by accident. + // Only the local entrypoint may inherit trusted-operator defaults. senderIsOwner: opts.senderIsOwner ?? true, - // Local/CLI callers are trusted by default for per-run model overrides. allowModelOverride: opts.allowModelOverride ?? true, }, prepare: async (preparedOpts) => @@ -613,14 +626,39 @@ export async function agentCommand( prepareAgentCommandExecution(preparedOpts, runtime), ), run: async (prepared) => - await agentCommandInternal(prepared, prepared.opts, runtime, resolvedDeps), + await agentCommandInternal( + prepared, + prepared.opts, + admissionIngress, + runtime, + resolvedDeps, + ), }), ), ); } +export async function agentCommand( + opts: AgentCommandOpts, + runtime: RuntimeEnv = defaultRuntime, + deps?: CliDeps, +) { + const { localIngress } = executionIdentity; + return await agentCommandWithAdmissionIngress(opts, localIngress, runtime, deps); +} + +export async function agentCommandFromSystem( + opts: AgentCommandOpts, + admission: { boundary: string }, + runtime: RuntimeEnv = defaultRuntime, + deps?: CliDeps, +) { + const ingress = executionIdentity.systemIngress(admission.boundary); + return await agentCommandWithAdmissionIngress(opts, ingress, runtime, deps); +} + async function agentCommandFromIngressInternal( - opts: AgentCommandIngressOpts, + opts: AgentCommandGatewayIngressOpts, runtime: RuntimeEnv = defaultRuntime, deps?: CliDeps, recovery?: { @@ -643,7 +681,14 @@ async function agentCommandFromIngressInternal( }, prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime), restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery, - run: async (prepared) => await agentCommandInternal(prepared, prepared.opts, runtime, deps), + run: async (prepared) => + await agentCommandInternal( + prepared, + prepared.opts, + { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" }, + runtime, + deps, + ), }); if (result) { @@ -660,12 +705,18 @@ export async function agentCommandFromIngress( runtime: RuntimeEnv = defaultRuntime, deps?: CliDeps, ) { - return await agentCommandFromIngressInternal(opts, runtime, deps); + // Plugin SDK callers may be plain JavaScript. Enforce the private recovery + // boundary at runtime so extra or inherited properties cannot author audit identity. + return await agentCommandFromIngressInternal( + { ...opts, executionIdentityAdmission: undefined }, + runtime, + deps, + ); } /** Internal Gateway entrypoint that restores a rejected restart-recovery admission. */ export async function agentCommandFromGatewayIngress( - opts: AgentCommandIngressOpts, + opts: AgentCommandGatewayIngressOpts, runtime: RuntimeEnv, deps: CliDeps | undefined, recovery: { diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 4235b2868164..5eccb85f4dc4 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -5,6 +5,7 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; import type { SpawnedRunMetadata } from "../../agents/spawned-context.js"; import type { PromptMode } from "../../agents/system-prompt.types.js"; +import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js"; import type { MediaFact } from "../../media/media-facts.js"; @@ -192,6 +193,11 @@ export type AgentCommandOpts = { mainRestartRecoveryOwnerLease?: MainSessionRecoveryOwnerLease; /** Gateway already consumed this automatic recovery run's durable reservation. */ mainRestartRecoveryAdmitted?: boolean; + /** Private recovery correlation; public ingress callers cannot author identity evidence. */ + executionIdentityAdmission?: { + token: ExecutionIdentityAdmissionToken; + retryOnly: boolean; + }; /** Called when the actual run model is selected, including fallback retries. */ onActiveModelSelected?: (ctx: { provider: string; model: string }) => void | Promise; /** Called when every candidate in the run's model fallback chain failed. */ @@ -215,10 +221,14 @@ export type AgentCommandOpts = { /** Restricted option surface for external ingress callsites. */ export type AgentCommandIngressOpts = Omit< AgentCommandOpts, - "senderIsOwner" | "allowModelOverride" + "senderIsOwner" | "allowModelOverride" | "executionIdentityAdmission" > & { /** Trusted sender identity bit for command/channel-action auth; defaults false for ingress. */ senderIsOwner?: boolean; /** Ingress callsites must always pass explicit model-override authorization state. */ allowModelOverride: boolean; }; + +/** Gateway-only ingress extends the public Plugin SDK surface with private recovery correlation. */ +export type AgentCommandGatewayIngressOpts = AgentCommandIngressOpts & + Pick; diff --git a/src/agents/main-session-recovery-state.execution-identity.test.ts b/src/agents/main-session-recovery-state.execution-identity.test.ts new file mode 100644 index 000000000000..c93df634bb14 --- /dev/null +++ b/src/agents/main-session-recovery-state.execution-identity.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import type { + InternalSessionEntry as SessionEntry, + MainRestartRecoveryState, +} from "../config/sessions.js"; +import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; + +const executionIdentity = (runId: string) => ({ + tokenVersion: 1 as const, + contextId: `context-${runId}`, + executionId: `execution-${runId}`, + runId, + createdAt: 1, +}); + +function recoveryState( + overrides: Partial = {}, +): MainRestartRecoveryState { + return { + cycleId: "cycle-1", + revision: 1, + chargedAttempts: 0, + ...overrides, + }; +} + +function interruptedEntry(overrides: Partial = {}): SessionEntry { + return { + sessionId: "session-1", + updatedAt: 100, + status: "running", + abortedLastRun: true, + mainRestartRecovery: recoveryState(), + ...overrides, + }; +} + +function observe(entry: SessionEntry, lifecycleGeneration: string) { + const result = transitionMainSessionRecovery(entry, { + kind: "observe", + cycleId: "unused-cycle", + lifecycleGeneration, + sessionKey: "agent:main:main", + }); + if (result.kind !== "observed") { + throw new Error("expected recovery observation"); + } + return result.view; +} + +function claimForeground(entry: SessionEntry) { + return transitionMainSessionRecovery(entry, { + kind: "claim_foreground", + cycleId: "unused", + lifecycleGeneration: "generation-1", + sessionId: "session-1", + sessionKey: "agent:main:main", + claimId: "foreground-1", + }); +} + +describe("main session recovery execution identity state", () => { + it("captures identity at reservation and clears it with a cancelled reservation", () => { + const entry = interruptedEntry(); + const prepared = transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 1, + lifecycleGeneration: "generation-1", + now: 200, + observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, + runId: "recovery-1", + executionIdentity: { state: "enabled", token: executionIdentity("recovery-1") }, + }); + expect(prepared.kind).toBe("reserved"); + if (prepared.kind !== "reserved") { + throw new Error("expected reservation"); + } + expect(prepared.reservation).toMatchObject({ + executionIdentityAdmission: { + kind: "capture", + token: executionIdentity("recovery-1"), + }, + }); + + expect( + transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 1, + lifecycleGeneration: "generation-1", + now: 201, + observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, + runId: "recovery-2", + executionIdentity: { state: "enabled", token: executionIdentity("recovery-2") }, + }), + ).toEqual({ kind: "rejected", reason: "stale_revision" }); + expect(entry.mainRestartRecovery?.reservation).toMatchObject({ + runId: "recovery-1", + attempt: 1, + }); + + expect(claimForeground(entry).kind).toBe("foreground_claimed"); + expect( + transitionMainSessionRecovery(entry, { + kind: "cancel_reservation", + reservation: prepared.reservation, + }), + ).toEqual({ kind: "applied" }); + expect(entry.mainRestartRecovery).toMatchObject({ + chargedAttempts: 0, + foregroundClaims: { + lifecycleGeneration: "generation-1", + tokens: ["foreground-1"], + }, + }); + expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); + expect(entry.mainRestartRecovery?.executionIdentity).toBeUndefined(); + expect(observe(entry, "generation-1")).toEqual({ status: "blocked" }); + }); + + it("reuses captured identity after an ambiguous dispatch", () => { + const entry = interruptedEntry(); + const prepared = transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 1, + lifecycleGeneration: "generation-1", + now: 200, + observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, + runId: "recovery-1", + executionIdentity: { state: "enabled", token: executionIdentity("recovery-1") }, + }); + if (prepared.kind !== "reserved") { + throw new Error("expected reservation"); + } + + expect( + transitionMainSessionRecovery(entry, { + kind: "abandon_reservation", + reservation: prepared.reservation, + }), + ).toEqual({ kind: "applied" }); + expect(entry.mainRestartRecovery).toMatchObject({ chargedAttempts: 1 }); + expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); + expect(observe(entry, "generation-1")).toMatchObject({ + status: "recoverable", + nextAttempt: 2, + }); + const retry = transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 2, + lifecycleGeneration: "generation-1", + now: 300, + observation: { + sessionId: "session-1", + cycleId: "cycle-1", + revision: entry.mainRestartRecovery!.revision, + }, + runId: "recovery-1", + executionIdentity: { + state: "enabled", + token: { ...executionIdentity("replacement"), runId: "recovery-1" }, + }, + }); + expect(retry).toMatchObject({ + kind: "reserved", + reservation: { + executionIdentityAdmission: { + kind: "retry-reference", + token: executionIdentity("recovery-1"), + }, + }, + }); + }); + + it("keeps disabled recovery identity out of durable state and reservations", () => { + const entry = interruptedEntry(); + + const prepared = transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 1, + lifecycleGeneration: "generation-1", + now: 200, + observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, + runId: "recovery-1", + executionIdentity: { state: "disabled" }, + }); + + expect(prepared).toMatchObject({ kind: "reserved" }); + if (prepared.kind !== "reserved") { + throw new Error("expected reservation"); + } + expect(prepared.reservation.executionIdentityAdmission).toBeUndefined(); + expect(entry.mainRestartRecovery?.executionIdentity).toBeUndefined(); + }); + + it("does not propagate a previously retained token while collection is disabled", () => { + const retained = executionIdentity("recovery-1"); + const entry = interruptedEntry({ + mainRestartRecovery: recoveryState({ executionIdentity: retained }), + }); + + const prepared = transitionMainSessionRecovery(entry, { + kind: "prepare_attempt", + attempt: 1, + lifecycleGeneration: "generation-1", + now: 200, + observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, + runId: "recovery-1", + executionIdentity: { state: "disabled" }, + }); + + expect(prepared).toMatchObject({ kind: "reserved" }); + if (prepared.kind !== "reserved") { + throw new Error("expected reservation"); + } + expect(prepared.reservation.executionIdentityAdmission).toBeUndefined(); + expect(entry.mainRestartRecovery?.executionIdentity).toEqual(retained); + }); +}); diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index c7cbef35c988..a9b02eb4ed58 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -8,7 +8,6 @@ import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lif import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; const sessionKey = "agent:main:main"; - function recoveryState( overrides: Partial = {}, ): MainRestartRecoveryState { @@ -164,56 +163,6 @@ describe("main session recovery state", () => { ]); }); - it("charges at reservation and refunds only the matching reservation", () => { - const entry = interruptedEntry(); - const prepared = transitionMainSessionRecovery(entry, { - kind: "prepare_attempt", - attempt: 1, - lifecycleGeneration: "generation-1", - now: 200, - observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, - runId: "recovery-1", - }); - expect(prepared.kind).toBe("reserved"); - if (prepared.kind !== "reserved") { - throw new Error("expected reservation"); - } - - expect( - transitionMainSessionRecovery(entry, { - kind: "prepare_attempt", - attempt: 1, - lifecycleGeneration: "generation-1", - now: 201, - observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, - runId: "recovery-2", - }), - ).toEqual({ kind: "rejected", reason: "stale_revision" }); - expect(entry.mainRestartRecovery?.reservation).toMatchObject({ - runId: "recovery-1", - attempt: 1, - }); - - const claim = claimForeground(entry); - expect(claim.kind).toBe("foreground_claimed"); - - expect( - transitionMainSessionRecovery(entry, { - kind: "cancel_reservation", - reservation: prepared.reservation, - }), - ).toEqual({ kind: "applied" }); - expect(entry.mainRestartRecovery).toMatchObject({ - chargedAttempts: 0, - foregroundClaims: { - lifecycleGeneration: "generation-1", - tokens: ["foreground-1"], - }, - }); - expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); - expect(observe(entry, "generation-1")).toEqual({ status: "blocked" }); - }); - it("rejects foreground work after the automatic recovery budget is exhausted", () => { const entry = interruptedEntry({ mainRestartRecovery: recoveryState({ chargedAttempts: 3 }), @@ -348,34 +297,6 @@ describe("main session recovery state", () => { expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); }); - it("releases an ambiguous dispatch reservation without refunding its charge", () => { - const entry = interruptedEntry(); - const prepared = transitionMainSessionRecovery(entry, { - kind: "prepare_attempt", - attempt: 1, - lifecycleGeneration: "generation-1", - now: 200, - observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, - runId: "recovery-1", - }); - if (prepared.kind !== "reserved") { - throw new Error("expected reservation"); - } - - expect( - transitionMainSessionRecovery(entry, { - kind: "abandon_reservation", - reservation: prepared.reservation, - }), - ).toEqual({ kind: "applied" }); - expect(entry.mainRestartRecovery).toMatchObject({ chargedAttempts: 1 }); - expect(entry.mainRestartRecovery?.reservation).toBeUndefined(); - expect(observe(entry, "generation-1")).toMatchObject({ - status: "recoverable", - nextAttempt: 2, - }); - }); - it("moves a reservation into the lifecycle fence during Gateway admission", () => { const entry = interruptedEntry({ pendingFinalDelivery: { kind: "replayable", text: " captured reply ", createdAt: 1 }, @@ -613,6 +534,7 @@ describe("main session recovery state", () => { now: 500, observation: oldObservation, runId: "stale-run", + executionIdentity: { state: "disabled" }, }), ).toEqual({ kind: "rejected", reason: "stale_cycle" }); }); diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index 95d7a0e168eb..2fffe9672be9 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -347,8 +347,17 @@ export function transitionMainSessionRecovery( if (command.attempt !== state.chargedAttempts + 1) { return { kind: "rejected", reason: "stale_revision" }; } + const executionIdentityAdmission = + command.executionIdentity.state === "disabled" + ? undefined + : state.executionIdentity + ? ({ kind: "retry-reference", token: state.executionIdentity } as const) + : ({ kind: "capture", token: command.executionIdentity.token } as const); updateRecoveryState(entry, state, { chargedAttempts: command.attempt, + ...(executionIdentityAdmission?.kind === "capture" + ? { executionIdentity: executionIdentityAdmission.token } + : {}), reservation: { runId: command.runId, attempt: command.attempt, @@ -364,6 +373,7 @@ export function transitionMainSessionRecovery( lifecycleGeneration: command.lifecycleGeneration, runId: command.runId, attempt: command.attempt, + ...(executionIdentityAdmission ? { executionIdentityAdmission } : {}), }, }; } @@ -387,6 +397,10 @@ export function transitionMainSessionRecovery( ? Math.max(0, command.reservation.attempt - 1) : state.chargedAttempts, reservation: undefined, + ...(command.kind === "cancel_reservation" && + command.reservation.executionIdentityAdmission?.kind === "capture" + ? { executionIdentity: undefined } + : {}), }); return { kind: "applied" }; } diff --git a/src/agents/main-session-recovery-store.test.ts b/src/agents/main-session-recovery-store.test.ts index dffcf6d2f621..9260b7cd6b1d 100644 --- a/src/agents/main-session-recovery-store.test.ts +++ b/src/agents/main-session-recovery-store.test.ts @@ -20,6 +20,17 @@ import { } from "./main-session-recovery-store.js"; const sessionKey = "agent:main:main"; +const executionIdentity = (runId: string) => ({ + tokenVersion: 1 as const, + contextId: `context-${runId}`, + executionId: `execution-${runId}`, + runId, + createdAt: 1, +}); +const enabledExecutionIdentity = (runId: string) => ({ + state: "enabled" as const, + token: executionIdentity(runId), +}); const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("main session recovery store", () => { @@ -104,6 +115,7 @@ describe("main session recovery store", () => { now: 200, observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, runId: "recovery-1", + executionIdentity: enabledExecutionIdentity("recovery-1"), }, target: { sessionKey: targetSessionKey, storePath }, }); @@ -210,6 +222,7 @@ describe("main session recovery store", () => { now: 400, observation: { sessionId: "session-1", cycleId: "cycle-1", revision: 1 }, runId: "stale-recovery", + executionIdentity: enabledExecutionIdentity("stale-recovery"), }); expect(result.transition).toEqual({ kind: "rejected", reason: "session_replaced" }); diff --git a/src/agents/main-session-recovery-types.ts b/src/agents/main-session-recovery-types.ts index 7a86dd6f52e1..b2d091f4afd1 100644 --- a/src/agents/main-session-recovery-types.ts +++ b/src/agents/main-session-recovery-types.ts @@ -1,8 +1,17 @@ import type { InternalSessionEntry as SessionEntry, + MainRestartRecoveryState, RestartRecoveryRun, } from "../config/sessions.js"; +type MainSessionRecoveryExecutionIdentity = NonNullable< + MainRestartRecoveryState["executionIdentity"] +>; + +type MainSessionRecoveryExecutionIdentityAdmission = + | { kind: "capture"; token: MainSessionRecoveryExecutionIdentity } + | { kind: "retry-reference"; token: MainSessionRecoveryExecutionIdentity }; + export type MainSessionRecoveryObservation = { sessionId: string; cycleId: string; @@ -15,6 +24,7 @@ export type MainSessionRecoveryReservation = { lifecycleGeneration: string; runId: string; attempt: number; + executionIdentityAdmission?: MainSessionRecoveryExecutionIdentityAdmission; }; export type MainSessionRecoveryOwnerClaim = { @@ -85,6 +95,9 @@ export type MainSessionRecoveryCommand = now: number; observation: MainSessionRecoveryObservation; runId: string; + executionIdentity: + | { state: "disabled" } + | { state: "enabled"; token: MainSessionRecoveryExecutionIdentity }; } | { kind: "cancel_reservation" | "abandon_reservation"; diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index 047b7b1c22d4..d320e2eea3e3 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; +import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js"; +import { createExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js"; import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js"; import type { SessionEntry } from "../config/sessions.js"; import { @@ -418,6 +420,7 @@ export async function resumeMainSession(params: { } const recoveryRunId = claimedRunId && claimedRunId !== sourceRunId ? claimedRunId : randomUUID(); const reusingRecoveryRunId = recoveryRunId === claimedRunId; + const executionIdentityCollectionEnabled = isExecutionIdentityCollectionEnabled(params.cfg); const dispatchSessionKey = params.canonicalSessionKey ?? params.sessionKey; const recoverySessionKeys = Array.from(new Set([dispatchSessionKey, params.sessionKey])); let reservation: MainSessionRecoveryReservation | undefined; @@ -445,6 +448,12 @@ export async function resumeMainSession(params: { now: Date.now(), observation: params.observation, runId: recoveryRunId, + executionIdentity: executionIdentityCollectionEnabled + ? { + state: "enabled", + token: createExecutionIdentityAdmissionToken(recoveryRunId), + } + : { state: "disabled" }, }, requireWriteSuccess: true, shouldContinue: params.shouldContinue, @@ -511,6 +520,12 @@ export async function resumeMainSession(params: { ...(params.sessionWorkAdmissionHandoffId ? { internalRuntimeHandoffId: params.sessionWorkAdmissionHandoffId } : {}), + ...(reservation.executionIdentityAdmission + ? { + internalExecutionIdentityRetry: + reservation.executionIdentityAdmission.kind === "retry-reference", + } + : {}), idempotencyKey: recoveryRunId, deliver: Boolean(deliveryContext) && diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 89313709c540..06aa9251a23d 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -97,6 +97,9 @@ const discordDeliveryContext = { channel: "discord", to: "discord:dm:123", } as const; +const executionIdentityEnabledConfig = { + logging: { audit: { executionIdentity: true } }, +} satisfies OpenClawConfig; vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(async () => ({ runId: "run-resumed" })), @@ -1177,6 +1180,27 @@ describe("main-session-restart-recovery", () => { expect(sourceClaimAtDispatch).toBe("control-ui-run"); }); + it.each([ + ["fresh default config", undefined], + ["upgrade config without the new setting", {}], + ["explicit collection disable", { logging: { audit: { executionIdentity: false } } }], + ["disabled audit ledger", { logging: { audit: { enabled: false, executionIdentity: true } } }], + ] satisfies Array<[string, OpenClawConfig | undefined]>)( + "stores no recovery identity with %s", + async (_label, cfg) => { + const sessionsDir = await makeSessionsDir(); + const storePath = path.join(sessionsDir, "sessions.json"); + await writeMainSession({ sessionsDir }); + await writeCompletedToolTranscript(sessionsDir); + + await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, cfg); + + const entry = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); + expect(entry?.mainRestartRecovery?.executionIdentity).toBeUndefined(); + expect(gatewayParams()).not.toHaveProperty("internalExecutionIdentityRetry"); + }, + ); + it("retains one stable transcript-only claim across ambiguous dispatch rejection", async () => { const sessionsDir = await makeSessionsDir(); const storePath = path.join(sessionsDir, "sessions.json"); @@ -1188,7 +1212,7 @@ describe("main-session-restart-recovery", () => { await writeCompletedToolTranscript(sessionsDir); vi.mocked(callGateway).mockRejectedValueOnce(new Error("gateway unavailable")); - await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, {}); + await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, executionIdentityEnabledConfig); const firstRecoveryRunId = ( vi.mocked(callGateway).mock.calls[0]?.[0].params as { idempotencyKey?: unknown } | undefined @@ -1205,8 +1229,21 @@ describe("main-session-restart-recovery", () => { status: "running", }); expect(pending?.mainRestartRecovery?.reservation).toBeUndefined(); + const executionIdentity = pending?.mainRestartRecovery?.executionIdentity; + expect(executionIdentity).toMatchObject({ + tokenVersion: 1, + contextId: expect.any(String), + executionId: expect.any(String), + runId: firstRecoveryRunId, + createdAt: expect.any(Number), + }); + const firstRequest = vi.mocked(callGateway).mock.calls[0]?.[0]; + expect(firstRequest).toBeDefined(); + expect((firstRequest!.params as Record).internalExecutionIdentityRetry).toBe( + false, + ); - await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, {}); + await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, executionIdentityEnabledConfig); const runIds = vi .mocked(callGateway) .mock.calls.map(([request]) => @@ -1216,13 +1253,55 @@ describe("main-session-restart-recovery", () => { ) .filter((runId) => runId !== undefined); expect(runIds).toEqual([firstRecoveryRunId, firstRecoveryRunId]); - expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({ + const recovered = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); + expect(recovered).toMatchObject({ abortedLastRun: false, - mainRestartRecovery: { chargedAttempts: 2 }, + mainRestartRecovery: { chargedAttempts: 2, executionIdentity }, restartRecoveryDeliveryRunId: firstRecoveryRunId, restartRecoveryDeliverySourceRunId: "control-ui-run", status: "running", }); + const agentRequests = vi + .mocked(callGateway) + .mock.calls.map(([request]) => request) + .filter((request) => request.method === "agent"); + expect(agentRequests[1]).toBeDefined(); + expect( + (agentRequests[1]!.params as Record).internalExecutionIdentityRetry, + ).toBe(true); + }); + + it("does not propagate a retained recovery token after collection is disabled", async () => { + const sessionsDir = await makeSessionsDir(); + const storePath = path.join(sessionsDir, "sessions.json"); + await writeMainSession({ + sessionsDir, + restartRecoveryDeliveryRunId: "control-ui-run", + restartRecoveryDeliverySourceRunId: "control-ui-run", + }); + await writeCompletedToolTranscript(sessionsDir); + vi.mocked(callGateway).mockRejectedValueOnce(new Error("gateway unavailable")); + + await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, executionIdentityEnabledConfig); + const retained = loadSessionEntry({ sessionKey: "agent:main:main", storePath }) + ?.mainRestartRecovery?.executionIdentity; + expect(retained).toBeDefined(); + + await expectRecovery( + { recovered: 1, failed: 0, skipped: 0 }, + { logging: { audit: { executionIdentity: false } } }, + ); + + const requests = vi + .mocked(callGateway) + .mock.calls.map(([request]) => request) + .filter((request) => request.method === "agent"); + expect(requests).toHaveLength(2); + expect(requests[1]?.params).not.toHaveProperty("internalExecutionIdentityRetry"); + expect( + loadSessionEntry({ sessionKey: "agent:main:main", storePath })?.mainRestartRecovery + ?.executionIdentity, + ).toEqual(retained); }); it("retries reservation cleanup after a transient session-store failure", async () => { diff --git a/src/audit/audit-config.test.ts b/src/audit/audit-config.test.ts index 6ca8f2ff8474..af85a66bb95b 100644 --- a/src/audit/audit-config.test.ts +++ b/src/audit/audit-config.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { isAuditLedgerEnabled, resolveAuditMessageMode } from "./audit-config.js"; +import { + isAuditLedgerEnabled, + isExecutionIdentityCollectionEnabled, + resolveAuditMessageMode, +} from "./audit-config.js"; describe("isAuditLedgerEnabled", () => { it("defaults to enabled without config or audit section", () => { @@ -22,4 +26,19 @@ describe("isAuditLedgerEnabled", () => { expect(resolveAuditMessageMode({ logging: { audit: { messages: "direct" } } })).toBe("direct"); expect(resolveAuditMessageMode({ logging: { audit: { messages: "all" } } })).toBe("all"); }); + + it("keeps execution identity off until both switches are explicitly enabled", () => { + expect(isExecutionIdentityCollectionEnabled(undefined)).toBe(false); + expect(isExecutionIdentityCollectionEnabled({ logging: { audit: {} } })).toBe(false); + expect( + isExecutionIdentityCollectionEnabled({ + logging: { audit: { enabled: true, executionIdentity: true } }, + }), + ).toBe(true); + expect( + isExecutionIdentityCollectionEnabled({ + logging: { audit: { enabled: false, executionIdentity: true } }, + }), + ).toBe(false); + }); }); diff --git a/src/audit/audit-config.ts b/src/audit/audit-config.ts index 9fe205ea7a95..f67657eaa4ac 100644 --- a/src/audit/audit-config.ts +++ b/src/audit/audit-config.ts @@ -12,6 +12,11 @@ export function isAuditLedgerEnabled(cfg: OpenClawConfig | undefined): boolean { return cfg?.logging?.audit?.enabled !== false; } +/** Execution identity is retained only after an explicit startup-scoped opt-in. */ +export function isExecutionIdentityCollectionEnabled(cfg: OpenClawConfig | undefined): boolean { + return isAuditLedgerEnabled(cfg) && cfg?.logging?.audit?.executionIdentity === true; +} + /** Message metadata remains an explicit opt-in inside the default-on ledger. */ export function resolveAuditMessageMode(cfg: OpenClawConfig | undefined): AuditMessageMode { return cfg?.logging?.audit?.messages ?? "off"; diff --git a/src/audit/audit-event-writer.test.ts b/src/audit/audit-event-writer.test.ts index 7eb0d25e476b..0a9fd39c2fa8 100644 --- a/src/audit/audit-event-writer.test.ts +++ b/src/audit/audit-event-writer.test.ts @@ -1,14 +1,57 @@ -import { afterAll, afterEach, describe, expect, it } from "vitest"; -import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; -import { listAuditEvents } from "./audit-event-store.js"; +import { listAuditEvents, recordAuditEvent } from "./audit-event-store.js"; import type { AuditEventInput } from "./audit-event-types.js"; import { createAuditEventWriter } from "./audit-event-writer.js"; +import { + configureExecutionIdentityAdmissionSink, + createExecutionIdentityAdmissionToken, + enqueueExecutionIdentityContextAtAdmission, + type ExecutionIdentityAdmissionEnvelope, + type ExecutionIdentityAdmissionFacts, +} from "./execution-identity-admission.js"; +import { + inspectExecutionIdentityRun, + processExecutionIdentityAdmissionWork, +} from "./execution-identity-context.js"; -const tempDirs: string[] = []; +function captureExecutionIdentityAdmissionEnvelope( + facts: ExecutionIdentityAdmissionFacts, + options: { + contextId?: string; + executionId?: string; + now?: number; + runtimeInstanceId?: string; + } = {}, +) { + let captured: ExecutionIdentityAdmissionEnvelope | undefined; + const clear = configureExecutionIdentityAdmissionSink((work) => { + if (work.kind === "capture") { + captured = work.envelope; + } + return true; + }); + const result = enqueueExecutionIdentityContextAtAdmission(facts, { + ...options, + enabled: true, + }); + clear(); + if (!result || !captured) { + throw new Error("expected admission envelope"); + } + return captured; +} + +function persistExecutionIdentityAdmissionEnvelope( + envelope: ExecutionIdentityAdmissionEnvelope, + options: Parameters[1] = {}, +) { + return processExecutionIdentityAdmissionWork({ kind: "capture", envelope }, options); +} function input(): AuditEventInput { return { @@ -25,17 +68,55 @@ function input(): AuditEventInput { }; } +function captureWork(envelope: ExecutionIdentityAdmissionEnvelope) { + return { kind: "capture" as const, envelope }; +} + afterEach(() => { closeOpenClawStateDatabaseForTest(); }); - -afterAll(() => { - cleanupTempDirs(tempDirs); -}); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("audit event worker", () => { + it("keeps first-use identity storage absent during maintenance without admission", 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) }); + + await writer.ready; + await writer.stop(); + + expect(errors).toEqual([]); + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + }); + + it("keeps an established current store identity-free during maintenance", async () => { + const stateDir = tempDirs.make("openclaw-audit-writer-"); + const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; + recordAuditEvent(input(), database); + closeOpenClawStateDatabaseForTest(); + const errors: string[] = []; + const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); + + await writer.ready; + await writer.stop(); + + expect(errors).toEqual([]); + expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1); + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + }); + it("returns immediately under SQLite contention and flushes before stop", async () => { - const stateDir = makeTempDir(tempDirs, "openclaw-audit-writer-"); + 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) }); @@ -51,4 +132,422 @@ describe("audit event worker", () => { expect(errors).toEqual([]); expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1); }); + + it("keeps first-use identity admission prompt under a held write lock", async () => { + const stateDir = tempDirs.make("openclaw-audit-writer-"); + const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { db } = openOpenClawStateDatabase(database); + db.exec("DELETE FROM audit_identity_keys;"); + db.exec("BEGIN IMMEDIATE"); + const errors: string[] = []; + const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); + const clearSink = configureExecutionIdentityAdmissionSink(writer.recordExecutionIdentity); + const admittedAt = Date.now(); + + const startedAt = performance.now(); + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "held-lock-run", + agentId: "main", + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + rawSourceRef: "raw-ingress-secret", + }, + runtime: { kind: "embedded" }, + invoker: { kind: "local-account", rawPrincipalRef: "raw-principal-secret" }, + }, + { + enabled: true, + contextId: "held-lock-context", + executionId: "held-lock-execution", + now: admittedAt, + runtimeInstanceId: "raw-runtime-secret", + }, + ), + ).toEqual({ + candidateContextId: "held-lock-context", + candidateExecutionId: "held-lock-execution", + accepted: true, + }); + expect(performance.now() - startedAt).toBeLessThan(250); + expect( + db.prepare("SELECT name FROM sqlite_schema WHERE name = 'execution_identity_contexts'").get(), + ).toBeUndefined(); + expect(db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get()).toEqual({ + count: 0, + }); + + db.exec("ROLLBACK"); + clearSink(); + await writer.stop(); + expect(errors).toEqual([]); + expect( + inspectExecutionIdentityRun({ runId: "held-lock-run" }, { ...database, now: admittedAt }), + ).toMatchObject({ + identity: { + state: "present", + context: { + contextId: "held-lock-context", + executionId: "held-lock-execution", + runId: "held-lock-run", + createdAt: admittedAt, + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + }, + runtimeInstance: { kind: "embedded", state: "present" }, + }, + }, + }); + const persisted = db + .prepare("SELECT context_json FROM execution_identity_contexts WHERE run_id = ?") + .get("held-lock-run") as { context_json: string }; + for (const raw of ["raw-ingress-secret", "raw-principal-secret", "raw-runtime-secret"]) { + expect(persisted.context_json).not.toContain(raw); + expect(JSON.stringify(errors)).not.toContain(raw); + } + }); + + it("prunes expired identity contexts at startup without a new run", async () => { + const stateDir = tempDirs.make("openclaw-audit-writer-"); + const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; + persistExecutionIdentityAdmissionEnvelope( + captureExecutionIdentityAdmissionEnvelope( + { + runId: "expired-before-startup", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + runtime: { kind: "embedded" }, + }, + { now: 0, runtimeInstanceId: "runtime-1" }, + ), + { ...database, now: 0 }, + ); + closeOpenClawStateDatabaseForTest(); + + const errors: string[] = []; + const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); + await writer.ready; + + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts") + .get(), + ).toEqual({ count: 0 }); + await writer.stop(); + expect(errors).toEqual([]); + }); + + it("uses one pending limit across audit events and identity envelopes", async () => { + const stateDir = tempDirs.make("openclaw-audit-writer-"); + const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const { db } = openOpenClawStateDatabase(database); + db.exec("BEGIN IMMEDIATE"); + const errors: string[] = []; + const writer = createAuditEventWriter({ + stateDir, + maxPending: 1, + onError: (error) => errors.push(error), + }); + expect(writer.record(input())).toBe(true); + expect( + writer.recordExecutionIdentity( + captureWork( + captureExecutionIdentityAdmissionEnvelope( + { + runId: "queue-full-run", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: "runtime-1" }, + ), + ), + ), + ).toBe(false); + expect(errors).toContain("audit event queue is full (1); dropping metadata"); + db.exec("ROLLBACK"); + await writer.stop(); + expect(listAuditEvents({ database, limit: 10 }).events).toHaveLength(1); + }); + + it("preserves exact-envelope idempotency and safely reports every canonical conflict", 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 admittedAt = Date.now(); + const original = captureExecutionIdentityAdmissionEnvelope( + { + runId: "ordered-run", + agentId: "main", + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + runtime: { kind: "embedded" }, + }, + { + contextId: "ordered-context", + executionId: "ordered-execution", + now: admittedAt, + runtimeInstanceId: "runtime-1", + }, + ); + const factConflict = captureExecutionIdentityAdmissionEnvelope( + { + runId: "ordered-run", + agentId: "other", + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + rawSourceRef: "raw-conflict-source", + }, + runtime: { kind: "embedded" }, + invoker: { kind: "local-account", rawPrincipalRef: "raw-conflict-principal" }, + }, + { + contextId: "ordered-context", + executionId: "ordered-execution", + now: admittedAt, + runtimeInstanceId: "runtime-1", + }, + ); + const contextIdConflict = { ...original, contextId: "conflicting-context" }; + const createdAtConflict = { ...original, createdAt: admittedAt + 1 }; + + const startedAt = performance.now(); + expect(writer.recordExecutionIdentity(captureWork(original))).toBe(true); + expect(writer.recordExecutionIdentity(captureWork(original))).toBe(true); + expect( + writer.recordExecutionIdentity({ + kind: "retry-reference", + token: createExecutionIdentityAdmissionToken(original.runId, { + contextId: original.contextId, + executionId: original.executionId, + now: original.createdAt, + }), + }), + ).toBe(true); + expect(writer.recordExecutionIdentity(captureWork(contextIdConflict))).toBe(true); + expect(writer.recordExecutionIdentity(captureWork(createdAtConflict))).toBe(true); + expect(writer.recordExecutionIdentity(captureWork(factConflict))).toBe(true); + expect(performance.now() - startedAt).toBeLessThan(250); + await writer.stop(); + + expect(errors).toEqual([ + "audit execution identity context conflict", + "audit execution identity context conflict", + "audit execution identity context conflict", + ]); + expect( + inspectExecutionIdentityRun({ runId: "ordered-run" }, { ...database, now: admittedAt }), + ).toMatchObject({ + identity: { + state: "present", + context: { + contextId: "ordered-context", + agentDefinition: { definitionRef: "main" }, + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + }, + }, + }); + const persisted = openOpenClawStateDatabase(database) + .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE run_id = ?") + .get("ordered-run") as { context_json: string }; + for (const raw of ["raw-conflict-source", "raw-conflict-principal"]) { + expect(persisted.context_json).not.toContain(raw); + expect(JSON.stringify(errors)).not.toContain(raw); + } + }); + + it("reports a lost durable recovery reference safely without blocking the caller", async () => { + const stateDir = tempDirs.make("openclaw-audit-writer-"); + const errors: string[] = []; + const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) }); + const token = createExecutionIdentityAdmissionToken("raw-run-not-a-secret", { + contextId: "context-missing", + executionId: "execution-missing", + now: 100, + }); + + const startedAt = performance.now(); + expect(writer.recordExecutionIdentity({ kind: "retry-reference", token })).toBe(true); + expect(performance.now() - startedAt).toBeLessThan(250); + await writer.stop(); + + expect(errors).toContain("audit execution identity recovery evidence unavailable"); + expect(JSON.stringify(errors)).not.toContain(token.contextId); + expect(JSON.stringify(errors)).not.toContain(token.executionId); + expect(JSON.stringify(errors)).not.toContain(token.runId); + expect( + openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }) + .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + }); + + it("keeps unavailable worker, schema, and insert failures off the admission path", async () => { + const envelope = captureExecutionIdentityAdmissionEnvelope( + { + runId: "nonblocking-failure-run", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: "runtime-1" }, + ); + + const unavailableErrors: string[] = []; + const unavailableWriter = createAuditEventWriter({ + workerUrl: new URL("./missing-audit-event-writer.worker.ts", import.meta.url), + onError: (error) => unavailableErrors.push(error), + }); + await unavailableWriter.ready; + const unavailableStartedAt = performance.now(); + expect(unavailableWriter.recordExecutionIdentity(captureWork(envelope))).toBe(false); + expect(performance.now() - unavailableStartedAt).toBeLessThan(250); + await unavailableWriter.stop(); + expect(unavailableErrors).toContain("audit event writer is unavailable; dropping metadata"); + + const schemaStateDir = tempDirs.make("openclaw-audit-writer-"); + const schemaDatabase = { env: { OPENCLAW_STATE_DIR: schemaStateDir } }; + openOpenClawStateDatabase(schemaDatabase).db.exec(` + CREATE VIEW execution_identity_contexts AS + SELECT 'context' AS context_id, 'run' AS run_id, 0 AS created_at, + 'unattributed' AS coverage_state, 2 AS context_bytes, '{}' AS context_json; + `); + closeOpenClawStateDatabaseForTest(); + const schemaErrors: string[] = []; + const schemaWriter = createAuditEventWriter({ + stateDir: schemaStateDir, + onError: (error) => schemaErrors.push(error), + }); + const schemaStartedAt = performance.now(); + expect(schemaWriter.recordExecutionIdentity(captureWork(envelope))).toBe(true); + expect(performance.now() - schemaStartedAt).toBeLessThan(250); + await schemaWriter.stop(); + expect(schemaErrors).toContain("audit execution identity persistence failed"); + + const insertStateDir = tempDirs.make("openclaw-audit-writer-"); + const insertDatabase = { env: { OPENCLAW_STATE_DIR: insertStateDir } }; + persistExecutionIdentityAdmissionEnvelope( + captureExecutionIdentityAdmissionEnvelope( + { + runId: "insert-failure-setup", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: "runtime-setup" }, + ), + insertDatabase, + ); + const insertDb = openOpenClawStateDatabase(insertDatabase).db; + insertDb.exec(` + CREATE TRIGGER reject_identity_insert + BEFORE INSERT ON execution_identity_contexts + BEGIN + SELECT RAISE(ABORT, 'raw-trigger-secret'); + END; + `); + const insertErrors: string[] = []; + const insertWriter = createAuditEventWriter({ + stateDir: insertStateDir, + onError: (error) => insertErrors.push(error), + }); + const insertStartedAt = performance.now(); + expect(insertWriter.recordExecutionIdentity(captureWork(envelope))).toBe(true); + expect(performance.now() - insertStartedAt).toBeLessThan(250); + await insertWriter.stop(); + expect(insertErrors).toContain("audit execution identity persistence failed"); + expect(JSON.stringify(insertErrors)).not.toContain("raw-trigger-secret"); + insertDb.exec("DROP TRIGGER reject_identity_insert;"); + expect( + inspectExecutionIdentityRun({ runId: envelope.runId }, insertDatabase).identity, + ).toMatchObject({ state: "unknown", reasonCode: "run_not_found" }); + }); + + it("keeps malformed, serialization, key, and persistence failures nonblocking and redaction-safe", 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 rawSecret = "raw-worker-message-secret"; + const unserializable = { + ...captureExecutionIdentityAdmissionEnvelope( + { + runId: "serialization-run", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: "runtime-1" }, + ), + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + rawSourceRef: () => rawSecret, + }, + }; + expect(writer.recordExecutionIdentity(captureWork(unserializable as never))).toBe(false); + await writer.stop(); + expect(errors).toContain("audit execution identity envelope could not be queued"); + expect(JSON.stringify(errors)).not.toContain(rawSecret); + + const malformedErrors: string[] = []; + const malformedWriter = createAuditEventWriter({ + stateDir, + onError: (error) => malformedErrors.push(error), + }); + expect(malformedWriter.recordExecutionIdentity({ rawSecret } as never)).toBe(true); + await malformedWriter.stop(); + expect(malformedErrors).toContain("audit execution identity envelope rejected"); + expect(JSON.stringify(malformedErrors)).not.toContain(rawSecret); + + closeOpenClawStateDatabaseForTest(); + persistExecutionIdentityAdmissionEnvelope( + captureExecutionIdentityAdmissionEnvelope( + { + runId: "before-key-loss", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: "runtime-1" }, + ), + database, + ); + openOpenClawStateDatabase(database).db.exec("DELETE FROM audit_identity_keys;"); + closeOpenClawStateDatabaseForTest(); + const keyErrors: string[] = []; + const keyWriter = createAuditEventWriter({ + stateDir, + onError: (error) => keyErrors.push(error), + }); + expect( + keyWriter.recordExecutionIdentity( + captureWork( + captureExecutionIdentityAdmissionEnvelope( + { + runId: "after-key-loss", + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local" }, + runtime: { kind: "embedded" }, + }, + { runtimeInstanceId: rawSecret }, + ), + ), + ), + ).toBe(true); + await keyWriter.stop(); + expect(keyErrors).toContain("audit execution identity key unavailable"); + expect(JSON.stringify(keyErrors)).not.toContain(rawSecret); + expect( + inspectExecutionIdentityRun({ runId: "after-key-loss" }, database).identity, + ).toMatchObject({ state: "unknown", reasonCode: "run_not_found" }); + }); }); diff --git a/src/audit/audit-event-writer.ts b/src/audit/audit-event-writer.ts index 0c54dbca2b89..fcc6aab6ff0a 100644 --- a/src/audit/audit-event-writer.ts +++ b/src/audit/audit-event-writer.ts @@ -2,9 +2,12 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveStateDir } from "../config/paths.js"; +import { redactSensitiveText } from "../logging/redact.js"; import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "../state/openclaw-state-db.js"; import type { AuditEventInput } from "./audit-event-types.js"; +import type { ExecutionIdentityAdmissionWork } from "./execution-identity-admission.js"; const MAX_PENDING_AUDIT_EVENTS = 4_096; // The worker can be synchronously blocked inside SQLite's busy timeout. Keep @@ -21,9 +24,18 @@ type AuditWriterMessage = export type AuditEventWriter = { ready: Promise; record: (input: AuditEventInput) => boolean; + /** Reports only queue acceptance; persistence succeeds or fails asynchronously. */ + recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean; stop: () => Promise; }; +function formatAuditWriterError(error: unknown): string { + return truncateUtf16Safe( + redactSensitiveText(error instanceof Error ? error.message : String(error), { mode: "tools" }), + 512, + ); +} + function resolveAuditEventWriterUrl(currentModuleUrl = import.meta.url): URL { const currentPath = fileURLToPath(currentModuleUrl); const normalized = currentPath.replaceAll(path.sep, "/"); @@ -56,10 +68,11 @@ export function createAuditEventWriter( execArgv: sourceWorkerExecArgv, }); } catch (error) { - options.onError?.(error instanceof Error ? error.message : String(error)); + options.onError?.(formatAuditWriterError(error)); return { ready: Promise.resolve(), record: () => false, + recordExecutionIdentity: () => false, stop: async () => {}, }; } @@ -92,7 +105,41 @@ export function createAuditEventWriter( finish?.(); }; const fail = (error: unknown) => { - options.onError?.(error instanceof Error ? error.message : String(error)); + options.onError?.(formatAuditWriterError(error)); + }; + + const enqueue = ( + message: + | { type: "record-event"; input: AuditEventInput } + | { type: "record-execution-identity"; work: ExecutionIdentityAdmissionWork }, + ): boolean => { + if (stopped || unavailable || pending >= maxPending) { + if (!stopped) { + fail( + unavailable + ? "audit event writer is unavailable; dropping metadata" + : `audit event queue is full (${maxPending}); dropping metadata`, + ); + } + return false; + } + pending += 1; + try { + // Node Worker.postMessage is not the browser Window API and has no targetOrigin. + // oxlint-disable-next-line unicorn/require-post-message-target-origin + worker.postMessage(message); + return true; + } catch (error) { + pending -= 1; + if (message.type === "record-execution-identity") { + fail("audit execution identity envelope could not be queued"); + } else { + unavailable = true; + void worker.terminate(); + fail(error); + } + return false; + } }; worker.on("message", (message: AuditWriterMessage) => { @@ -133,30 +180,8 @@ export function createAuditEventWriter( return { ready, - record: (input) => { - if (stopped || unavailable || pending >= maxPending) { - if (!stopped) { - fail( - unavailable - ? "audit event writer is unavailable; dropping metadata" - : `audit event queue is full (${maxPending}); dropping metadata`, - ); - } - return false; - } - pending += 1; - try { - // Node Worker.postMessage is not the browser Window API and has no targetOrigin. - // oxlint-disable-next-line unicorn/require-post-message-target-origin - worker.postMessage({ type: "record", input }); - return true; - } catch (error) { - pending -= 1; - unavailable = true; - fail(error); - return false; - } - }, + record: (input) => enqueue({ type: "record-event", input }), + recordExecutionIdentity: (work) => enqueue({ type: "record-execution-identity", work }), stop: async () => { if (stopped) { return; diff --git a/src/audit/audit-event-writer.worker.ts b/src/audit/audit-event-writer.worker.ts index 9b8ef012ffbf..25c2b1b58943 100644 --- a/src/audit/audit-event-writer.worker.ts +++ b/src/audit/audit-event-writer.worker.ts @@ -3,10 +3,17 @@ import { parentPort, workerData } from "node:worker_threads"; import { closeOpenClawStateDatabase } from "../state/openclaw-state-db.js"; import { pruneExpiredAuditEvents, recordAuditEvent } from "./audit-event-store.js"; import type { AuditEventInput } from "./audit-event-types.js"; +import { + processExecutionIdentityAdmissionWork, + pruneExpiredExecutionIdentityContexts, +} from "./execution-identity-context.js"; const AUDIT_MAINTENANCE_INTERVAL_MS = 60 * 60_000; -type AuditWriterRequest = { type: "record"; input: AuditEventInput } | { type: "stop" }; +type AuditWriterRequest = + | { type: "record-event"; input: AuditEventInput } + | { type: "record-execution-identity"; work: unknown } + | { type: "stop" }; const stateDir = workerData && typeof workerData === "object" && typeof workerData.stateDir === "string" @@ -18,12 +25,41 @@ if (!parentPort || !stateDir) { const port = parentPort; const database = { env: { OPENCLAW_STATE_DIR: stateDir } }; +function executionIdentityFailureMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("audit identity key is missing") || + message.includes("audit identity key is corrupt") + ) { + return "audit execution identity key unavailable"; + } + if (message.includes("execution identity context conflict")) { + return "audit execution identity context conflict"; + } + if (message.includes("execution identity recovery evidence unavailable")) { + return "audit execution identity recovery evidence unavailable"; + } + if ( + message.includes("admission envelope") || + message.includes("admission work") || + message.includes("admission token") + ) { + return "audit execution identity envelope rejected"; + } + return "audit execution identity persistence failed"; +} + function reportMaintenance(): void { try { pruneExpiredAuditEvents({ database }); } catch (error) { port.postMessage({ type: "maintenance-error", error: String(error) }); } + try { + pruneExpiredExecutionIdentityContexts({ database }); + } catch (error) { + port.postMessage({ type: "maintenance-error", error: String(error) }); + } } reportMaintenance(); @@ -31,7 +67,7 @@ const maintenanceTimer = setInterval(reportMaintenance, AUDIT_MAINTENANCE_INTERV port.postMessage({ type: "ready" }); port.on("message", (message: AuditWriterRequest) => { - if (message.type === "record") { + if (message.type === "record-event") { try { recordAuditEvent(message.input, database); port.postMessage({ type: "recorded" }); @@ -40,6 +76,15 @@ port.on("message", (message: AuditWriterRequest) => { } return; } + if (message.type === "record-execution-identity") { + try { + processExecutionIdentityAdmissionWork(message.work, database); + port.postMessage({ type: "recorded" }); + } catch (error) { + port.postMessage({ type: "record-error", error: executionIdentityFailureMessage(error) }); + } + return; + } clearInterval(maintenanceTimer); reportMaintenance(); try { diff --git a/src/audit/audit-events.test.ts b/src/audit/audit-events.test.ts index 6c908b3b7a76..f27c33c93bed 100644 --- a/src/audit/audit-events.test.ts +++ b/src/audit/audit-events.test.ts @@ -86,6 +86,7 @@ function captureAuditWriter(inputs: AuditEventInput[]): AuditEventWriter { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; } @@ -635,6 +636,7 @@ describe("agent activity audit projection", () => { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; const recorder = createAgentEventAuditRecorder({ writer }); @@ -665,6 +667,7 @@ describe("agent activity audit projection", () => { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; const recorder = createAgentEventAuditRecorder({ writer, terminalSettleMs: 60_000 }); @@ -694,6 +697,7 @@ describe("agent activity audit projection", () => { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; const recorder = createAgentEventAuditRecorder({ writer, terminalSettleMs: 60_000 }); @@ -724,6 +728,7 @@ describe("agent activity audit projection", () => { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; const recorder = createAgentEventAuditRecorder({ writer }); diff --git a/src/audit/audit-identity.ts b/src/audit/audit-identity.ts index bd1391bbdcd7..498264a52762 100644 --- a/src/audit/audit-identity.ts +++ b/src/audit/audit-identity.ts @@ -12,7 +12,7 @@ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state- type AuditIdentityDatabase = Pick< OpenClawStateKyselyDatabase, - "audit_events" | "audit_identity_keys" + "audit_events" | "audit_identity_keys" | "execution_identity_contexts" >; type AuditIdentityKeyRow = Pick< Selectable, @@ -24,8 +24,8 @@ const AUDIT_IDENTITY_KEY_BYTES = 32; const AUDIT_IDENTITY_KEY_ID_BYTES = 16; const AUDIT_IDENTITY_KEY_ID_RE = /^[a-f0-9]{32}$/u; const AUDIT_IDENTITY_DOMAIN = "openclaw.audit.identity.v1"; -// Only a top-level (depth-0) recordAuditEvent may create the key: the caller's -// catch clears this cache on rollback, but a rolled-back outer transaction +// Only a top-level (depth-0) audit/evidence write may create the key: each +// caller clears this cache on rollback, because a rolled-back outer transaction // around a nested creation would leave a cached key that was never persisted. const identityByDatabase = new WeakMap(); @@ -35,6 +35,7 @@ type AuditIdentityKey = { }; type AuditIdentityKind = "account" | "actor" | "conversation" | "message" | "target"; +type ExecutionIdentityRefKind = "domain" | "evidence" | "grant" | "principal" | "runtime"; function registerAuditIdentityKeyForRedaction(key: Uint8Array): void { const bytes = Buffer.from(key); @@ -81,7 +82,18 @@ export function loadOrCreateAuditIdentityKey(db: DatabaseSync): AuditIdentityKey db, kysely.selectFrom("audit_events").select("sequence").where("kind", "=", "message").limit(1), ); - if (retainedMessage) { + const hasExecutionContextTable = Boolean( + db /* sqlite-allow-raw -- Missing-key integrity must tolerate pre-ensure current-schema DBs. */ + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ); + const retainedExecutionContext = hasExecutionContextTable + ? executeSqliteQueryTakeFirstSync( + db, + kysely.selectFrom("execution_identity_contexts").select("context_id").limit(1), + ) + : undefined; + if (retainedMessage || retainedExecutionContext) { // A missing key with retained refs would split correlation on restart. // Fail closed instead of silently rotating away from the persisted key id. throw new Error("audit identity key is missing"); @@ -147,3 +159,28 @@ export function pseudonymizeAuditIdentity(params: { .digest("hex"); return `hmac-sha256:v1:${params.identity.keyId}:${digest}`; } + +/** Project one execution-identity ref without retaining its raw owner value. */ +export function pseudonymizeExecutionIdentityRef(params: { + db: DatabaseSync; + kind: ExecutionIdentityRefKind; + scope: string; + value: string; +}): string { + if (!params.scope || !params.value) { + throw new Error("execution identity HMAC scope and value must be non-empty"); + } + const identity = loadOrCreateAuditIdentityKey(params.db); + const digest = createHmac("sha256", identity.key) + .update( + JSON.stringify([ + "openclaw.audit.execution-identity.v1", + params.kind, + params.scope, + params.value, + ]), + "utf8", + ) + .digest("hex"); + return `hmac-sha256:v1:${identity.keyId}:${digest}`; +} diff --git a/src/audit/audit-recorder.test.ts b/src/audit/audit-recorder.test.ts index 0a1681ab461d..af7f4511f1d6 100644 --- a/src/audit/audit-recorder.test.ts +++ b/src/audit/audit-recorder.test.ts @@ -12,6 +12,7 @@ function captureWriter(inputs: AuditEventInput[]): AuditEventWriter { inputs.push(input); return true; }, + recordExecutionIdentity: () => true, stop: async () => {}, }; } diff --git a/src/audit/audit-recorder.ts b/src/audit/audit-recorder.ts index d2aa84aa5df0..30a50aa3aeb7 100644 --- a/src/audit/audit-recorder.ts +++ b/src/audit/audit-recorder.ts @@ -7,6 +7,7 @@ import { } from "./agent-event-audit.js"; import type { AuditMessageMode } from "./audit-config.js"; import { createAuditEventWriter, type AuditEventWriter } from "./audit-event-writer.js"; +import type { ExecutionIdentityAdmissionWork } from "./execution-identity-admission.js"; import type { TrustedMessageAuditEvent } from "./message-audit-events.js"; const log = createSubsystemLogger("audit/events"); @@ -14,6 +15,7 @@ let persistenceFailureWarned = false; type AuditEventRecorder = AgentEventAuditRecorder & { recordMessage: (event: TrustedMessageAuditEvent) => void; + recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean; }; export function createAuditEventRecorder(options: { @@ -43,6 +45,7 @@ export function createAuditEventRecorder(options: { return { ...agentRecorder, + recordExecutionIdentity: writer.recordExecutionIdentity, recordMessage: (event) => { if (options.messageMode === "off") { return; diff --git a/src/audit/execution-identity-admission.test.ts b/src/audit/execution-identity-admission.test.ts new file mode 100644 index 000000000000..c52c39e46dc4 --- /dev/null +++ b/src/audit/execution-identity-admission.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; +import { + configureExecutionIdentityAdmissionSink, + createExecutionIdentityAdmissionToken, + enqueueExecutionIdentityContextAtAdmission, + hasExecutionIdentityAdmissionSink, + parseExecutionIdentityAdmissionEnvelope, + type ExecutionIdentityAdmissionEnvelope, + type ExecutionIdentityAdmissionFacts, + type ExecutionIdentityAdmissionWork, +} from "./execution-identity-admission.js"; + +const ADMISSION_MAX_BYTES = 16 * 1024; +const ADMISSION_MAX_ITEMS = 16; + +function facts(overrides: Partial = {}) { + return { + runId: "run-1", + agentId: "main", + ingress: { kind: "local-cli" as const, boundary: "agent-command.local" }, + runtime: { kind: "embedded" as const }, + ...overrides, + }; +} + +function captureEnvelope( + admissionFacts: ExecutionIdentityAdmissionFacts, + options: { + contextId?: string; + executionId?: string; + now?: number; + runtimeInstanceId?: string; + } = {}, +) { + let captured: ExecutionIdentityAdmissionEnvelope | undefined; + const clear = configureExecutionIdentityAdmissionSink((work) => { + if (work.kind === "capture") { + captured = work.envelope; + } + return true; + }); + try { + const result = enqueueExecutionIdentityContextAtAdmission(admissionFacts, { + ...options, + enabled: true, + }); + if (!result || !captured) { + throw new Error("expected admission envelope"); + } + return captured; + } finally { + clear(); + } +} + +describe("execution identity admission envelope", () => { + it("captures a deterministic, deeply frozen, redacted envelope with fixed identity", () => { + const envelope = captureEnvelope( + facts({ + invoker: { + kind: "local-account", + rawPrincipalRef: "raw-principal", + displayLabel: "Operator OPENAI_API_KEY=sk-1234567890abcdef", + }, + applicableGrants: [ + { rawGrantRef: "z", state: "present" }, + { rawGrantRef: "a", state: "present" }, + { rawGrantRef: "a", state: "present" }, + ], + assurance: [ + { + kind: "runtime-binding", + rawEvidenceRef: "z", + strength: "boundary-verified", + }, + { + kind: "local-process", + rawEvidenceRef: "a", + strength: "boundary-verified", + }, + ], + }), + { + contextId: "context-1", + executionId: "execution-1", + now: 123, + runtimeInstanceId: "runtime-1", + }, + ); + + expect(envelope).toMatchObject({ + envelopeVersion: 1, + contextId: "context-1", + executionId: "execution-1", + runId: "run-1", + createdAt: 123, + runtimeInstanceId: "runtime-1", + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + }); + expect(envelope.applicableGrants).toEqual([ + { rawGrantRef: "a", state: "present" }, + { rawGrantRef: "z", state: "present" }, + ]); + expect(envelope.invoker?.displayLabel).not.toContain("sk-1234567890abcdef"); + expect(Object.isFrozen(envelope)).toBe(true); + expect(Object.isFrozen(envelope.ingress)).toBe(true); + expect(Object.isFrozen(envelope.assurance)).toBe(true); + expect(parseExecutionIdentityAdmissionEnvelope(structuredClone(envelope))).toEqual(envelope); + expect(Buffer.byteLength(JSON.stringify(envelope), "utf8")).toBeLessThanOrEqual( + ADMISSION_MAX_BYTES, + ); + }); + + it("rejects invalid owned facts, excess items, and oversized encoded envelopes", () => { + expect(() => + captureEnvelope(facts({ runId: "" }), { + runtimeInstanceId: "runtime-1", + }), + ).toThrow("expected admission envelope"); + expect(() => + captureEnvelope( + facts({ + applicableGrants: Array.from({ length: ADMISSION_MAX_ITEMS + 1 }, (_, index) => ({ + rawGrantRef: `grant-${String(index)}`, + state: "present" as const, + })), + }), + { runtimeInstanceId: "runtime-1" }, + ), + ).toThrow("expected admission envelope"); + expect(() => + captureEnvelope( + facts({ + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + rawSourceRef: "a".repeat(4_096), + }, + invoker: { + kind: "local-account", + rawPrincipalRef: "b".repeat(4_096), + }, + applicableGrants: [ + { rawGrantRef: "c".repeat(4_096), state: "present" }, + { rawGrantRef: "d".repeat(4_096), state: "present" }, + ], + }), + { runtimeInstanceId: "e".repeat(4_096) }, + ), + ).toThrow("expected admission envelope"); + }); + + it("reports queue acceptance without claiming persistence and keeps failures nonblocking", () => { + const first = vi.fn(() => true); + const second = vi.fn(() => true); + const clearFirst = configureExecutionIdentityAdmissionSink(first); + const clearSecond = configureExecutionIdentityAdmissionSink(second); + clearFirst(); + expect(hasExecutionIdentityAdmissionSink()).toBe(true); + expect( + enqueueExecutionIdentityContextAtAdmission(facts(), { + enabled: true, + contextId: "context-queued", + executionId: "execution-queued", + now: 1, + runtimeInstanceId: "runtime-1", + }), + ).toEqual({ + candidateContextId: "context-queued", + candidateExecutionId: "execution-queued", + accepted: true, + }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + clearSecond(); + expect(hasExecutionIdentityAdmissionSink()).toBe(false); + expect(() => + enqueueExecutionIdentityContextAtAdmission( + facts({ ingress: { kind: "local-cli", boundary: "x", rawSourceRef: "raw-secret" } }), + { enabled: true }, + ), + ).not.toThrow(); + expect(enqueueExecutionIdentityContextAtAdmission(facts(), { enabled: false })).toBeUndefined(); + }); + + it("allocates distinct execution identities for turns that share one run correlation", () => { + const work = vi.fn<(item: ExecutionIdentityAdmissionWork) => boolean>(() => true); + const clear = configureExecutionIdentityAdmissionSink(work); + try { + enqueueExecutionIdentityContextAtAdmission(facts({ runId: "session-1" }), { + enabled: true, + }); + enqueueExecutionIdentityContextAtAdmission(facts({ runId: "session-1" }), { + enabled: true, + }); + } finally { + clear(); + } + const captures = work.mock.calls + .map(([item]) => item) + .filter((item) => item.kind === "capture"); + expect(captures).toHaveLength(2); + expect(captures[0]!.envelope.runId).toBe("session-1"); + expect(captures[1]!.envelope.runId).toBe("session-1"); + expect(captures[0]!.envelope.executionId).not.toBe(captures[1]!.envelope.executionId); + expect(captures[0]!.envelope.contextId).not.toBe(captures[1]!.envelope.contextId); + }); + + it("queues only the safe token for a durable retry reference", () => { + const work = vi.fn<(item: ExecutionIdentityAdmissionWork) => boolean>(() => true); + const token = createExecutionIdentityAdmissionToken("run-recovery", { + contextId: "context-recovery", + executionId: "execution-recovery", + now: 123, + }); + const clear = configureExecutionIdentityAdmissionSink(work); + try { + enqueueExecutionIdentityContextAtAdmission( + facts({ + runId: "run-recovery", + ingress: { + kind: "api", + boundary: "agent-command.from-ingress", + rawSourceRef: "raw-private-reference", + }, + }), + { enabled: true, token, retryOnly: true }, + ); + } finally { + clear(); + } + expect(work).toHaveBeenCalledWith({ kind: "retry-reference", token }); + expect(JSON.stringify(work.mock.calls)).not.toContain("raw-private-reference"); + }); +}); diff --git a/src/audit/execution-identity-admission.ts b/src/audit/execution-identity-admission.ts new file mode 100644 index 000000000000..b9597eb63660 --- /dev/null +++ b/src/audit/execution-identity-admission.ts @@ -0,0 +1,400 @@ +/** Bounded execution-identity facts captured at authoritative run admission. */ +import { randomUUID } from "node:crypto"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { type Static, Type } from "typebox"; +import { Value } from "typebox/value"; +import { redactSensitiveText } from "../logging/redact.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const EXECUTION_IDENTITY_ADMISSION_MAX_BYTES = 16 * 1024; +const EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS = 16; +const RAW_REF_MAX_LENGTH = 4_096; +const PROCESS_RUNTIME_INSTANCE_ID = randomUUID(); +const log = createSubsystemLogger("audit/events"); + +const boundedRef = () => Type.String({ minLength: 1, maxLength: 256 }); +const rawRef = () => Type.String({ minLength: 1, maxLength: RAW_REF_MAX_LENGTH }); +const evidenceState = () => + Type.Union([ + Type.Literal("present"), + Type.Literal("absent"), + Type.Literal("unknown"), + Type.Literal("unsupported"), + ]); +const closedObject = [0]>(properties: T) => + Type.Object(properties, { additionalProperties: false }); + +const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({ + envelopeVersion: Type.Literal(1), + contextId: boundedRef(), + executionId: boundedRef(), + runId: boundedRef(), + createdAt: Type.Integer({ minimum: 0 }), + 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"), + ]), + 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"), + ]), + }), + invoker: Type.Optional( + closedObject({ + kind: Type.Union([ + Type.Literal("person"), + Type.Literal("agent"), + Type.Literal("service"), + Type.Literal("schedule"), + Type.Literal("webhook"), + Type.Literal("system"), + Type.Literal("local-account"), + Type.Literal("runtime"), + ]), + rawPrincipalRef: rawRef(), + displayLabel: Type.Optional(Type.String({ maxLength: 128 })), + }), + ), + applicableGrants: Type.Array(closedObject({ rawGrantRef: rawRef(), state: evidenceState() }), { + maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS, + }), + 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 }, + ), +}); + +const ExecutionIdentityAdmissionTokenSchema = closedObject({ + tokenVersion: Type.Literal(1), + contextId: boundedRef(), + executionId: boundedRef(), + runId: boundedRef(), + createdAt: Type.Integer({ minimum: 0 }), +}); + +export type ExecutionIdentityAdmissionEnvelope = Static< + typeof ExecutionIdentityAdmissionEnvelopeSchema +>; +export type ExecutionIdentityAdmissionFacts = Omit< + ExecutionIdentityAdmissionEnvelope, + | "envelopeVersion" + | "contextId" + | "executionId" + | "createdAt" + | "runtimeInstanceId" + | "ingress" + | "applicableGrants" + | "assurance" +> & { + ingress: Omit & { + state?: ExecutionIdentityAdmissionEnvelope["ingress"]["state"]; + }; + applicableGrants?: ExecutionIdentityAdmissionEnvelope["applicableGrants"]; + assurance?: ExecutionIdentityAdmissionEnvelope["assurance"]; +}; +export type ExecutionIdentityAdmissionToken = Static; +export type ExecutionIdentityAdmissionWork = + | { kind: "capture"; envelope: ExecutionIdentityAdmissionEnvelope } + | { kind: "retry-reference"; token: ExecutionIdentityAdmissionToken }; +type ExecutionIdentityAdmissionSink = (work: ExecutionIdentityAdmissionWork) => boolean; + +let admissionSink: ExecutionIdentityAdmissionSink | undefined; +let admissionFailureWarned = false; + +function uniqueSorted(values: readonly T[], key: (value: T) => string): T[] { + return [...new Map(values.map((value) => [key(value), value])).values()].toSorted((a, b) => { + const left = key(a); + const right = key(b); + return left < right ? -1 : left > right ? 1 : 0; + }); +} + +function freezeEnvelope(value: T, seen = new WeakSet()): T { + if (!value || typeof value !== "object" || seen.has(value as object)) { + return value; + } + seen.add(value as object); + for (const nested of Object.values(value as Record)) { + freezeEnvelope(nested, seen); + } + return Object.freeze(value); +} + +function validateEnvelope(value: unknown): asserts value is ExecutionIdentityAdmissionEnvelope { + if ( + !Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, value) || + !Number.isSafeInteger(value.createdAt) + ) { + throw new Error("execution identity admission envelope violates its bounded contract"); + } + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_ADMISSION_MAX_BYTES) { + throw new Error("execution identity admission envelope exceeds 16 KiB"); + } +} + +function validateToken(value: unknown): asserts value is ExecutionIdentityAdmissionToken { + if ( + !Value.Check(ExecutionIdentityAdmissionTokenSchema, value) || + !Number.isSafeInteger(value.createdAt) + ) { + throw new Error("execution identity admission token violates its bounded contract"); + } +} + +/** Allocate the immutable correlation owned by one outer admitted turn. */ +export function createExecutionIdentityAdmissionToken( + runId: string, + options: { contextId?: string; executionId?: string; now?: number } = {}, +): ExecutionIdentityAdmissionToken { + const token = { + tokenVersion: 1 as const, + contextId: options.contextId ?? randomUUID(), + executionId: options.executionId ?? randomUUID(), + runId, + createdAt: options.now ?? Date.now(), + }; + validateToken(token); + return freezeEnvelope(token); +} + +export function parseExecutionIdentityAdmissionToken( + value: unknown, +): ExecutionIdentityAdmissionToken { + validateToken(value); + return freezeEnvelope({ ...value }); +} + +function redactDisplayLabel(value: string): string { + // The shared redactor's secret-prefix pass becomes stable on its second pass. + // Stabilizing here lets the worker reject any altered structured-clone payload. + return truncateUtf16Safe( + redactSensitiveText(redactSensitiveText(value, { mode: "tools" }), { mode: "tools" }), + 128, + ); +} + +/** Capture owned admission facts without touching filesystem or database state. */ +function captureExecutionIdentityAdmissionEnvelope( + facts: ExecutionIdentityAdmissionFacts, + options: { + contextId?: string; + executionId?: string; + now?: number; + runtimeInstanceId?: string; + 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) { + throw new Error("execution identity admission token disagrees with the admitted run"); + } + const runtimeInstanceId = options.runtimeInstanceId ?? PROCESS_RUNTIME_INSTANCE_ID; + const assurance = facts.assurance ?? [ + { + kind: "runtime-binding" as const, + rawEvidenceRef: runtimeInstanceId, + strength: "boundary-verified" as const, + }, + ]; + const envelope = { + envelopeVersion: 1 as const, + contextId: token.contextId, + executionId: token.executionId, + runId: token.runId, + createdAt: token.createdAt, + runtimeInstanceId, + agentId: facts.agentId, + ingress: { ...facts.ingress, state: facts.ingress.state ?? "present" }, + runtime: { ...facts.runtime }, + ...(facts.invoker + ? { + invoker: { + ...facts.invoker, + ...(facts.invoker.displayLabel !== undefined + ? { displayLabel: redactDisplayLabel(facts.invoker.displayLabel) } + : {}), + }, + } + : {}), + applicableGrants: uniqueSorted( + facts.applicableGrants ?? [], + (grant) => `${grant.rawGrantRef}\0${grant.state}`, + ).map((grant) => ({ rawGrantRef: grant.rawGrantRef, state: grant.state })), + assurance: uniqueSorted( + assurance, + (item) => `${item.kind}\0${item.rawEvidenceRef}\0${item.strength}`, + ).map((item) => ({ + kind: item.kind, + rawEvidenceRef: item.rawEvidenceRef, + strength: item.strength, + })), + }; + validateEnvelope(envelope); + return freezeEnvelope(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, + }), + runtimeInstanceId: value.runtimeInstanceId, + }); + if (JSON.stringify(parsed) !== JSON.stringify(value)) { + throw new Error("execution identity admission envelope is not canonical"); + } + return parsed; +} + +/** Revalidate either bounded worker message before schema, key, or database work. */ +export function parseExecutionIdentityAdmissionWork( + value: unknown, +): ExecutionIdentityAdmissionWork { + if (!value || typeof value !== "object") { + throw new Error("execution identity admission work violates its bounded contract"); + } + const work = value as { kind?: unknown; envelope?: unknown; token?: unknown }; + if (work.kind === "capture") { + return freezeEnvelope({ + kind: "capture" as const, + envelope: parseExecutionIdentityAdmissionEnvelope(work.envelope), + }); + } + if (work.kind === "retry-reference") { + return freezeEnvelope({ + kind: "retry-reference" as const, + token: parseExecutionIdentityAdmissionToken(work.token), + }); + } + throw new Error("execution identity admission work violates its bounded contract"); +} + +/** Install the current process lifecycle's writer without creating a second queue. */ +export function configureExecutionIdentityAdmissionSink( + sink: ExecutionIdentityAdmissionSink, +): () => void { + admissionSink = sink; + return () => { + if (admissionSink === sink) { + admissionSink = undefined; + } + }; +} + +export function hasExecutionIdentityAdmissionSink(): boolean { + return admissionSink !== undefined; +} + +/** + * Capture and enqueue evidence. The returned ID is only a candidate until async persistence wins. + */ +export function enqueueExecutionIdentityContextAtAdmission( + facts: ExecutionIdentityAdmissionFacts, + options: { + enabled: boolean; + contextId?: string; + executionId?: string; + now?: number; + runtimeInstanceId?: string; + token?: ExecutionIdentityAdmissionToken; + retryOnly?: boolean; + }, +): + | { + candidateContextId: string; + candidateExecutionId: string; + accepted: boolean; + } + | undefined { + if (!options.enabled) { + return undefined; + } + try { + const token = + options.token ?? + createExecutionIdentityAdmissionToken(facts.runId, { + contextId: options.contextId, + executionId: options.executionId, + now: options.now, + }); + validateToken(token); + const work: ExecutionIdentityAdmissionWork = options.retryOnly + ? { kind: "retry-reference", token } + : { + kind: "capture", + envelope: captureExecutionIdentityAdmissionEnvelope(facts, { + token, + runtimeInstanceId: options.runtimeInstanceId, + }), + }; + if (!admissionSink) { + throw new Error("audit writer unavailable"); + } + return { + candidateContextId: token.contextId, + candidateExecutionId: token.executionId, + accepted: admissionSink(work), + }; + } catch { + if (!admissionFailureWarned) { + admissionFailureWarned = true; + log.warn("audit execution identity admission evidence was not queued; continuing without it"); + } + return undefined; + } +} diff --git a/src/audit/execution-identity-context-build.ts b/src/audit/execution-identity-context-build.ts new file mode 100644 index 000000000000..78ebf5a72e5c --- /dev/null +++ b/src/audit/execution-identity-context-build.ts @@ -0,0 +1,147 @@ +/** Worker-only canonical context construction and bounded value helpers. */ +import type { DatabaseSync } from "node:sqlite"; +import type { ExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js"; +import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js"; +import { pseudonymizeExecutionIdentityRef } from "./audit-identity.js"; +import type { ExecutionIdentityAdmissionEnvelope } from "./execution-identity-admission.js"; + +const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024; + +export function ensureBoundedExecutionIdentityRef( + value: string, + label: string, + maxLength = 256, +): string { + if (!value || value.length > maxLength) { + throw new Error(`${label} must be between 1 and ${String(maxLength)} characters`); + } + return value; +} + +function ensureRawRef(value: string, label: string): string { + return ensureBoundedExecutionIdentityRef(value, label, 4_096); +} + +export function freezeExecutionIdentityContext(value: T, seen = new WeakSet()): T { + if (!value || typeof value !== "object" || seen.has(value as object)) { + return value; + } + seen.add(value as object); + for (const nested of Object.values(value as Record)) { + freezeExecutionIdentityContext(nested, seen); + } + return Object.freeze(value); +} + +function hmacRef( + db: DatabaseSync, + kind: Parameters[0]["kind"], + scope: string, + value: string, +): string { + return pseudonymizeExecutionIdentityRef({ + db, + kind, + scope: ensureBoundedExecutionIdentityRef(scope, "HMAC scope"), + value: ensureRawRef(value, "HMAC value"), + }); +} + +function uniqueSorted(values: readonly T[], key: (value: T) => string): T[] { + return [...new Map(values.map((value) => [key(value), value])).values()].toSorted((a, b) => { + const left = key(a); + const right = key(b); + return left < right ? -1 : left > right ? 1 : 0; + }); +} + +export function buildExecutionIdentityContext( + db: DatabaseSync, + envelope: ExecutionIdentityAdmissionEnvelope, + fixed: { contextId: string; createdAt: number }, +): ExecutionIdentityContextV1 { + const runId = ensureBoundedExecutionIdentityRef(envelope.runId, "run id"); + const executionId = ensureBoundedExecutionIdentityRef(envelope.executionId, "execution id"); + const agentId = ensureBoundedExecutionIdentityRef(envelope.agentId, "agent id"); + const contextId = ensureBoundedExecutionIdentityRef(fixed.contextId, "context id"); + const domainRef = hmacRef(db, "domain", "gateway-cell", "gateway-cell"); + const runtimeRef = hmacRef( + db, + "runtime", + domainRef, + ensureRawRef(envelope.runtimeInstanceId, "runtime instance id"), + ); + const invoker = envelope.invoker + ? { + state: "present" as const, + principal: { + kind: envelope.invoker.kind, + domainRef, + principalRef: hmacRef( + db, + "principal", + `${domainRef}:${envelope.invoker.kind}`, + envelope.invoker.rawPrincipalRef, + ), + ...(envelope.invoker.displayLabel !== undefined + ? { displayLabel: envelope.invoker.displayLabel } + : {}), + }, + } + : { state: "absent" as const }; + const assurance = uniqueSorted( + envelope.assurance.map((item) => ({ + kind: item.kind, + evidenceRef: hmacRef(db, "evidence", `${domainRef}:${item.kind}`, item.rawEvidenceRef), + strength: item.strength, + })), + (item) => `${item.kind}\0${item.evidenceRef}\0${item.strength}`, + ); + const applicableGrants = uniqueSorted( + envelope.applicableGrants.map((grant) => ({ + grantRef: hmacRef(db, "grant", domainRef, grant.rawGrantRef), + state: grant.state, + })), + (grant) => `${grant.grantRef}\0${grant.state}`, + ); + const missingEvidence = envelope.invoker ? [] : ["invoker.principal"]; + const context: ExecutionIdentityContextV1 = { + schemaVersion: 1, + contextId, + executionId, + runId, + createdAt: fixed.createdAt, + trustDomain: { kind: "gateway-cell", domainRef, state: "present" }, + invoker, + ingress: { + kind: envelope.ingress.kind, + boundary: ensureBoundedExecutionIdentityRef(envelope.ingress.boundary, "ingress boundary"), + state: envelope.ingress.state, + ...(envelope.ingress.rawSourceRef + ? { + sourceRef: hmacRef( + db, + "principal", + `${domainRef}:ingress:${envelope.ingress.kind}`, + envelope.ingress.rawSourceRef, + ), + } + : {}), + }, + agentPrincipal: { kind: "agent", domainRef, principalRef: agentId }, + agentDefinition: { definitionRef: agentId, state: "present" }, + runtimeInstance: { runtimeRef, kind: envelope.runtime.kind, state: "present" }, + applicableGrants, + assurance, + coverageState: envelope.invoker ? "attribution-only" : "unattributed", + missingEvidence, + }; + if (!validateExecutionIdentityContextV1(context)) { + throw new Error("prepared execution identity context violates the V1 contract"); + } + const encoded = JSON.stringify(context); + if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_CONTEXT_MAX_BYTES) { + throw new Error("prepared execution identity context exceeds 16 KiB"); + } + return freezeExecutionIdentityContext(context); +} diff --git a/src/audit/execution-identity-context.test.ts b/src/audit/execution-identity-context.test.ts new file mode 100644 index 000000000000..d5514e86fddb --- /dev/null +++ b/src/audit/execution-identity-context.test.ts @@ -0,0 +1,831 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, + type OpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { recordAuditEvent } from "./audit-event-store.js"; +import { + configureExecutionIdentityAdmissionSink, + createExecutionIdentityAdmissionToken, + enqueueExecutionIdentityContextAtAdmission, + type ExecutionIdentityAdmissionEnvelope, + type ExecutionIdentityAdmissionFacts, +} from "./execution-identity-admission.js"; +import { + inspectExecutionIdentityRun, + processExecutionIdentityAdmissionWork, + pruneExpiredExecutionIdentityContexts, +} from "./execution-identity-context.js"; + +const RETENTION_MS = 30 * 24 * 60 * 60_000; + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function databaseOptions() { + return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-identity-") } }; +} + +function openIndependentStateDatabase(path: string): OpenClawStateDatabase { + return { + db: openNodeSqliteDatabase(path), + path, + walMaintenance: { checkpoint: () => true, close: () => true }, + }; +} + +function facts( + runId: string, + overrides: Partial = {}, +): ExecutionIdentityAdmissionFacts { + return { + runId, + agentId: "main", + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + runtime: { kind: "embedded" }, + ...overrides, + }; +} + +function captureExecutionIdentityAdmissionEnvelope( + admissionFacts: ExecutionIdentityAdmissionFacts, + options: { + now?: number; + contextId?: string; + executionId?: string; + runtimeInstanceId?: string; + } = {}, +): ExecutionIdentityAdmissionEnvelope { + const { contextId, executionId, runtimeInstanceId, now } = options; + let envelope: ExecutionIdentityAdmissionEnvelope | undefined; + const clear = configureExecutionIdentityAdmissionSink((captured) => { + if (captured.kind === "capture") { + envelope = captured.envelope; + } + return true; + }); + try { + const result = enqueueExecutionIdentityContextAtAdmission(admissionFacts, { + enabled: true, + ...(contextId !== undefined ? { contextId } : {}), + ...(executionId !== undefined ? { executionId } : {}), + ...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}), + ...(now !== undefined ? { now } : {}), + }); + if (!result || !envelope) { + throw new Error("expected admission envelope"); + } + return envelope; + } finally { + clear(); + } +} + +function persistExecutionIdentityAdmissionEnvelope( + envelope: ExecutionIdentityAdmissionEnvelope, + options: Parameters[1] = {}, +) { + return processExecutionIdentityAdmissionWork({ kind: "capture", envelope }, options); +} + +function prepareExecutionIdentityContextAtAdmission( + admissionFacts: ExecutionIdentityAdmissionFacts, + options: { + database?: OpenClawStateDatabase; + env?: NodeJS.ProcessEnv; + now?: number; + contextId?: string; + executionId?: string; + runtimeInstanceId?: string; + limits?: { maxRows: number; pruneBatchRows: number }; + } = {}, +) { + const { contextId, executionId, runtimeInstanceId, now, limits, ...database } = options; + const envelope = captureExecutionIdentityAdmissionEnvelope(admissionFacts, { + ...(contextId !== undefined ? { contextId } : {}), + ...(executionId !== undefined ? { executionId } : {}), + ...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}), + ...(now !== undefined ? { now } : {}), + }); + return persistExecutionIdentityAdmissionEnvelope(envelope, { + ...database, + ...(now !== undefined ? { now } : {}), + ...(limits !== undefined ? { limits } : {}), + }); +} + +describe("execution identity context storage", () => { + it("replays one byte-identical canonical context idempotently across restart", () => { + const database = databaseOptions(); + const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-1"), { + now: 100, + contextId: "context-1", + executionId: "execution-1", + runtimeInstanceId: "runtime-secret-1", + }); + const first = persistExecutionIdentityAdmissionEnvelope(envelope, { ...database, now: 100 }); + + closeOpenClawStateDatabaseForTest(); + const second = persistExecutionIdentityAdmissionEnvelope(structuredClone(envelope), { + ...database, + now: 999, + }); + + expect(second).toEqual(first); + expect(first.coverageState).toBe("unattributed"); + expect(first.invoker).toEqual({ state: "absent" }); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.runtimeInstance)).toBe(true); + expect(JSON.stringify(first)).not.toContain("runtime-secret-1"); + + closeOpenClawStateDatabaseForTest(); + const afterRestart = inspectExecutionIdentityRun( + { executionId: "execution-1" }, + { + ...database, + now: 999, + }, + ); + expect(afterRestart.identity).toEqual({ state: "present", context: first }); + }); + + it.each([ + { + difference: "contextId", + mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({ + ...envelope, + contextId: "context-conflicting", + }), + }, + { + difference: "createdAt", + mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({ + ...envelope, + createdAt: envelope.createdAt + 1, + }), + }, + { + difference: "identity facts", + mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({ + ...envelope, + agentId: "other", + }), + }, + ])( + "conflicts on a same-execution $difference and leaves canonical bytes unchanged", + ({ mutate }) => { + const database = databaseOptions(); + const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-conflict"), { + contextId: "context-original", + executionId: "execution-original", + now: 100, + runtimeInstanceId: "runtime-1", + }); + const original = persistExecutionIdentityAdmissionEnvelope(envelope, { + ...database, + now: 100, + }); + const originalRow = openOpenClawStateDatabase(database) + .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?") + .get("execution-original"); + + expect(() => + persistExecutionIdentityAdmissionEnvelope(mutate(envelope), { ...database, now: 101 }), + ).toThrow("execution identity context conflict"); + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?") + .get("execution-original"), + ).toEqual(originalRow); + expect( + inspectExecutionIdentityRun( + { executionId: "execution-original" }, + { ...database, now: 101 }, + ).identity, + ).toEqual({ state: "present", context: original }); + }, + ); + + it("keeps distinct turns sharing one run correlation exactly inspectable", () => { + const database = databaseOptions(); + const first = prepareExecutionIdentityContextAtAdmission(facts("session-run"), { + ...database, + now: 100, + contextId: "context-first", + executionId: "execution-first", + runtimeInstanceId: "runtime-1", + }); + const second = prepareExecutionIdentityContextAtAdmission(facts("session-run"), { + ...database, + now: 101, + contextId: "context-second", + executionId: "execution-second", + runtimeInstanceId: "runtime-1", + }); + + const discovery = inspectExecutionIdentityRun( + { runId: "session-run" }, + { ...database, now: 101 }, + ); + expect(discovery).toMatchObject({ + run: { runId: "session-run", status: "known" }, + identity: { + state: "ambiguous", + reasonCode: "execution_selection_required", + candidates: [ + { executionId: "execution-first", contextId: "context-first" }, + { executionId: "execution-second", contextId: "context-second" }, + ], + }, + decisions: [], + }); + expect( + inspectExecutionIdentityRun( + { runId: "session-run", executionLimit: 1 }, + { ...database, now: 101 }, + ), + ).toMatchObject({ + identity: { + state: "ambiguous", + candidates: [{ executionId: "execution-first" }], + }, + nextExecutionCursor: "1", + }); + expect( + inspectExecutionIdentityRun( + { runId: "session-run", executionOffset: 1, executionLimit: 1 }, + { ...database, now: 101 }, + ), + ).toMatchObject({ + identity: { + state: "ambiguous", + candidates: [{ executionId: "execution-second" }], + }, + }); + expect( + inspectExecutionIdentityRun({ executionId: "execution-first" }, { ...database, now: 101 }) + .identity, + ).toEqual({ state: "present", context: first }); + expect( + inspectExecutionIdentityRun({ executionId: "execution-second" }, { ...database, now: 101 }) + .identity, + ).toEqual({ state: "present", context: second }); + }); + + it("confirms durable retries without manufacturing lost evidence", () => { + const database = databaseOptions(); + const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-recovery"), { + now: 100, + contextId: "context-recovery", + executionId: "execution-recovery", + runtimeInstanceId: "runtime-original", + }); + const original = processExecutionIdentityAdmissionWork( + { kind: "capture", envelope }, + { ...database, now: 100 }, + ); + const token = createExecutionIdentityAdmissionToken("run-recovery", { + now: 100, + contextId: "context-recovery", + executionId: "execution-recovery", + }); + + closeOpenClawStateDatabaseForTest(); + expect( + processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, database), + ).toEqual(original); + + const missingDatabase = databaseOptions(); + expect(() => + processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, missingDatabase), + ).toThrow("execution identity recovery evidence unavailable"); + expect( + inspectExecutionIdentityRun({ executionId: "execution-recovery" }, missingDatabase), + ).toMatchObject({ + run: { executionId: "execution-recovery", status: "unknown" }, + identity: { state: "unknown", reasonCode: "execution_not_found" }, + decisions: [], + }); + }); + + it("projects authoritative local CLI and system ingress without conflating them", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-local"), database); + prepareExecutionIdentityContextAtAdmission( + facts("run-system", { + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + }), + database, + ); + + expect(inspectExecutionIdentityRun({ runId: "run-local" }, database).identity).toMatchObject({ + state: "present", + context: { + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + }, + }); + expect(inspectExecutionIdentityRun({ runId: "run-system" }, database).identity).toMatchObject({ + state: "present", + context: { + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + }, + }); + }); + + it("keeps inspection read-only and lets persistence create the additive table", () => { + const database = databaseOptions(); + const reopened = openOpenClawStateDatabase(database); + expect( + reopened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + expect(inspectExecutionIdentityRun({ runId: "missing" }, database)).toMatchObject({ + run: { status: "unknown" }, + identity: { state: "unknown", reasonCode: "run_not_found" }, + }); + expect( + reopened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + + prepareExecutionIdentityContextAtAdmission(facts("schema-restored"), database); + expect( + reopened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toEqual({ name: "execution_identity_contexts" }); + expect( + reopened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'index' AND name = ?") + .get("execution_identity_contexts_run_created_idx"), + ).toEqual({ name: "execution_identity_contexts_run_created_idx" }); + }); + + it("keeps maintenance read-only until the first identity capture", () => { + const database = databaseOptions(); + const opened = openOpenClawStateDatabase(database); + expect( + opened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + + expect(pruneExpiredExecutionIdentityContexts({ database })).toBe(0); + expect( + opened.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + }); + + it("records attribution only when an invoker fact is actually present", () => { + const database = databaseOptions(); + const context = prepareExecutionIdentityContextAtAdmission( + facts("run-attributed", { + invoker: { + kind: "local-account", + rawPrincipalRef: "private-local-account", + displayLabel: "Operator OPENAI_API_KEY=sk-1234567890abcdef", + }, + applicableGrants: [ + { rawGrantRef: "grant-z", state: "present" }, + { rawGrantRef: "grant-a", state: "present" }, + ], + assurance: [ + { + kind: "local-process", + rawEvidenceRef: "private-process-evidence", + strength: "boundary-verified", + }, + ], + }), + { ...database, runtimeInstanceId: "private-runtime" }, + ); + const encoded = JSON.stringify(context); + + expect(context.coverageState).toBe("attribution-only"); + expect(context.invoker.state).toBe("present"); + expect(context.missingEvidence).toEqual([]); + expect(context.applicableGrants.map((grant) => grant.grantRef)).toEqual( + context.applicableGrants.map((grant) => grant.grantRef).toSorted(), + ); + for (const secret of [ + "private-local-account", + "private-process-evidence", + "private-runtime", + "grant-a", + "grant-z", + "sk-1234567890abcdef", + ]) { + expect(encoded).not.toContain(secret); + } + expect(context.invoker.principal?.principalRef).toMatch(/^hmac-sha256:v1:/u); + }); + + it("declines recording instead of rotating a missing HMAC key with retained contexts", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-before-key-loss"), database); + openOpenClawStateDatabase(database).db.exec("DELETE FROM audit_identity_keys;"); + closeOpenClawStateDatabaseForTest(); + + expect(() => + prepareExecutionIdentityContextAtAdmission(facts("run-after-key-loss"), database), + ).toThrow("audit identity key is missing"); + }); + + it("skips new context rows when audit collection is disabled", () => { + const database = databaseOptions(); + + expect( + enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }), + ).toBeUndefined(); + + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); + }); + + it("keeps bounded retention maintenance available while collection is disabled", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-before-disable"), { + ...database, + now: 0, + runtimeInstanceId: "runtime-1", + }); + expect( + enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }), + ).toBeUndefined(); + + expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1); + expect( + openOpenClawStateDatabase(database) + .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts") + .get(), + ).toEqual({ count: 0 }); + }); + + it("leaves the original context intact when a later worker write conflicts", () => { + const database = databaseOptions(); + const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-best-effort"), { + contextId: "context-best-effort", + executionId: "execution-best-effort", + runtimeInstanceId: "runtime-1", + }); + persistExecutionIdentityAdmissionEnvelope(envelope, database); + + expect(() => + persistExecutionIdentityAdmissionEnvelope( + { ...envelope, agentId: "conflicting-agent" }, + database, + ), + ).toThrow("execution identity context conflict"); + }); + + it("rolls back the worker write when insert-time retention cleanup fails", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-expired"), { + ...database, + now: 0, + runtimeInstanceId: "runtime-1", + }); + openOpenClawStateDatabase(database).db.exec(` + CREATE TRIGGER reject_identity_cleanup + BEFORE DELETE ON execution_identity_contexts + BEGIN + SELECT RAISE(ABORT, 'cleanup unavailable'); + END; + `); + + expect(() => + prepareExecutionIdentityContextAtAdmission(facts("run-still-admitted"), { + ...database, + now: RETENTION_MS + 1, + runtimeInstanceId: "runtime-1", + }), + ).toThrow("cleanup unavailable"); + }); + + it("stops projecting context and decisions immediately after the retention boundary", () => { + const database = databaseOptions(); + const createdAt = 1_000; + prepareExecutionIdentityContextAtAdmission(facts("run-retention"), { + ...database, + now: createdAt, + contextId: "expired-context-secret", + executionId: "expired-execution-secret", + runtimeInstanceId: "expired-runtime-secret", + }); + + const immediatelyBefore = inspectExecutionIdentityRun( + { runId: "run-retention" }, + { ...database, now: createdAt + RETENTION_MS - 1 }, + ); + expect(immediatelyBefore.identity).toMatchObject({ + state: "present", + context: { contextId: "expired-context-secret" }, + }); + expect(immediatelyBefore.decisions).toHaveLength(1); + expect( + inspectExecutionIdentityRun( + { runId: "run-retention" }, + { ...database, now: createdAt + RETENTION_MS }, + ).identity.state, + ).toBe("present"); + + const immediatelyAfter = inspectExecutionIdentityRun( + { runId: "run-retention" }, + { ...database, now: createdAt + RETENTION_MS + 1 }, + ); + expect(immediatelyAfter).toMatchObject({ + run: { status: "known" }, + identity: { + state: "unsupported", + reasonCode: "identity_context_unavailable", + remediation: [ + expect.objectContaining({ + code: "run_again_after_expiry", + text: expect.stringContaining("outside the 30-day window"), + }), + ], + }, + decisions: [], + coverage: { state: "unsupported", missingEvidence: ["identity.context"] }, + }); + expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-context-secret"); + expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-runtime-secret"); + expect(JSON.stringify(immediatelyAfter)).not.toContain("run_admission_identity_not_evaluated"); + const exactAfter = inspectExecutionIdentityRun( + { executionId: "expired-execution-secret" }, + { ...database, now: createdAt + RETENTION_MS + 1 }, + ); + expect(exactAfter).toMatchObject({ + run: { executionId: "expired-execution-secret", status: "known" }, + identity: { state: "unsupported", reasonCode: "identity_context_unavailable" }, + decisions: [], + }); + expect(JSON.stringify(exactAfter)).not.toContain("expired-context-secret"); + expect(JSON.stringify(exactAfter)).not.toContain("expired-runtime-secret"); + + closeOpenClawStateDatabaseForTest(); + expect( + inspectExecutionIdentityRun( + { runId: "run-retention" }, + { ...database, now: createdAt + RETENTION_MS + 1 }, + ), + ).toEqual(immediatelyAfter); + + expect( + pruneExpiredExecutionIdentityContexts({ + database, + now: createdAt + RETENTION_MS + 1, + }), + ).toBe(1); + expect( + inspectExecutionIdentityRun( + { runId: "run-retention" }, + { ...database, now: createdAt + RETENTION_MS + 1 }, + ), + ).toMatchObject({ + run: { status: "unknown" }, + identity: { + state: "unknown", + reasonCode: "run_not_found", + remediation: [ + expect.objectContaining({ text: expect.stringContaining("not proof of no run") }), + ], + }, + decisions: [], + }); + }); + + it("prunes expired contexts in bounded maintenance batches without new inserts", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("schema-seed"), { + ...database, + now: 1, + runtimeInstanceId: "runtime-1", + }); + const { db } = openOpenClawStateDatabase(database); + db.exec("DELETE FROM execution_identity_contexts;"); + db.prepare( + `WITH RECURSIVE rows(n) AS ( + VALUES (1) + UNION ALL + SELECT n + 1 FROM rows WHERE n < 1025 + ) + INSERT INTO execution_identity_contexts ( + context_id, execution_id, run_id, created_at, coverage_state, context_bytes, context_json + ) + SELECT 'context-' || n, 'execution-' || n, 'run-' || n, 0, 'unattributed', 2, '{}' + FROM rows`, + ).run(); + + expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1_024); + expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({ + count: 1, + }); + expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1); + expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({ + count: 0, + }); + }); + + it("prunes retention and row-cap overflow in bounded batches", () => { + const retentionDatabase = databaseOptions(); + for (const runId of ["old-1", "old-2", "old-3"]) { + prepareExecutionIdentityContextAtAdmission(facts(runId), { + ...retentionDatabase, + now: 0, + runtimeInstanceId: "runtime-1", + limits: { maxRows: 10, pruneBatchRows: 1 }, + }); + } + prepareExecutionIdentityContextAtAdmission(facts("new-1"), { + ...retentionDatabase, + now: RETENTION_MS + 1, + runtimeInstanceId: "runtime-1", + limits: { maxRows: 1, pruneBatchRows: 1 }, + }); + const retainedAfterOneBatch = openOpenClawStateDatabase(retentionDatabase) + .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts") + .get() as { count: number }; + expect(retainedAfterOneBatch.count).toBe(3); + + const capDatabase = databaseOptions(); + for (const runId of ["cap-1", "cap-2", "cap-3"]) { + prepareExecutionIdentityContextAtAdmission(facts(runId), { + ...capDatabase, + now: 100, + contextId: `context-${runId}`, + runtimeInstanceId: "runtime-1", + limits: { maxRows: 2, pruneBatchRows: 1 }, + }); + } + const capped = openOpenClawStateDatabase(capDatabase) + .db.prepare("SELECT run_id FROM execution_identity_contexts ORDER BY context_id") + .all() as Array<{ run_id: string }>; + expect(capped).toHaveLength(2); + expect(capped.map((row) => row.run_id)).not.toContain("cap-1"); + }); + + it("enforces the row cap across independent database connections", () => { + const database = databaseOptions(); + const path = openOpenClawStateDatabase(database).path; + closeOpenClawStateDatabaseForTest(); + const first = openIndependentStateDatabase(path); + const second = openIndependentStateDatabase(path); + try { + for (const [index, connection] of [first, second, first, second].entries()) { + const runId = `shared-cap-${index + 1}`; + prepareExecutionIdentityContextAtAdmission(facts(runId), { + database: connection, + now: 100 + index, + contextId: `context-${runId}`, + runtimeInstanceId: "runtime-1", + limits: { maxRows: 2, pruneBatchRows: 1 }, + }); + } + + const retained = first.db + .prepare("SELECT run_id FROM execution_identity_contexts ORDER BY created_at") + .all() as Array<{ run_id: string }>; + expect(retained.map((row) => row.run_id)).toEqual(["shared-cap-3", "shared-cap-4"]); + } finally { + first.db.close(); + second.db.close(); + } + }); + + it("inspects through a read-only connection while another writer holds the database", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("held-lock-inspection"), { + ...database, + now: 100, + contextId: "context-held-lock-inspection", + executionId: "execution-held-lock-inspection", + runtimeInstanceId: "runtime-1", + }); + const path = openOpenClawStateDatabase(database).path; + closeOpenClawStateDatabaseForTest(); + const lockDatabase = openNodeSqliteDatabase(path); + lockDatabase.exec("BEGIN IMMEDIATE"); + try { + const startedAt = performance.now(); + expect( + inspectExecutionIdentityRun( + { executionId: "execution-held-lock-inspection" }, + { ...database, now: 100 }, + ), + ).toMatchObject({ + identity: { + state: "present", + context: { + contextId: "context-held-lock-inspection", + executionId: "execution-held-lock-inspection", + runId: "held-lock-inspection", + }, + }, + }); + expect(performance.now() - startedAt).toBeLessThan(250); + } finally { + lockDatabase.exec("ROLLBACK"); + lockDatabase.close(); + } + }); + + it("returns typed corrupt, unknown, and unsupported projections", () => { + const corruptDatabase = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-corrupt"), { + ...corruptDatabase, + runtimeInstanceId: "runtime-1", + }); + openOpenClawStateDatabase(corruptDatabase) + .db.prepare("UPDATE execution_identity_contexts SET context_json = ? WHERE run_id = ?") + .run("{", "run-corrupt"); + expect(inspectExecutionIdentityRun({ runId: "run-corrupt" }, corruptDatabase)).toMatchObject({ + run: { status: "known" }, + identity: { state: "unknown", reasonCode: "identity_context_corrupt" }, + coverage: { state: "unknown" }, + }); + + const unknownDatabase = databaseOptions(); + expect(inspectExecutionIdentityRun({ runId: "never-seen" }, unknownDatabase)).toMatchObject({ + run: { status: "unknown" }, + identity: { + state: "unknown", + reasonCode: "run_not_found", + remediation: [expect.objectContaining({ code: "verify_run_id" })], + }, + }); + + recordAuditEvent( + { + sourceId: "legacy-run:1", + sourceSequence: 1, + occurredAt: Date.now(), + kind: "agent_run", + action: "agent.run.started", + status: "started", + actorType: "agent", + actorId: "main", + agentId: "main", + runId: "legacy-run", + }, + unknownDatabase, + ); + expect(inspectExecutionIdentityRun({ runId: "legacy-run" }, unknownDatabase)).toMatchObject({ + run: { status: "known" }, + identity: { + state: "unsupported", + reasonCode: "identity_context_unavailable", + remediation: [expect.objectContaining({ code: "record_new_identity_context" })], + }, + }); + }); + + it("projects one non-enforcement admission explanation", () => { + const database = databaseOptions(); + prepareExecutionIdentityContextAtAdmission(facts("run-receipt"), { + ...database, + now: 123, + contextId: "context-receipt", + runtimeInstanceId: "runtime-1", + }); + const result = inspectExecutionIdentityRun({ runId: "run-receipt" }, { ...database, now: 123 }); + + expect(result.identity).toMatchObject({ + state: "present", + context: { contextId: "context-receipt", coverageState: "unattributed" }, + }); + expect(result.decisions).toEqual([ + expect.objectContaining({ + decision: { + outcome: "not-applicable", + reasonCode: "run_admission_identity_not_evaluated", + }, + enforcement: expect.objectContaining({ + coverageState: "unattributed", + policyRefs: [], + grantRefs: [], + contextFieldsUsed: [], + }), + }), + ]); + expect( + inspectExecutionIdentityRun( + { runId: "run-receipt", decisionOffset: 1 }, + { ...database, now: 123 }, + ).decisions, + ).toEqual([]); + }); +}); diff --git a/src/audit/execution-identity-context.ts b/src/audit/execution-identity-context.ts new file mode 100644 index 000000000000..8ce83b3b4463 --- /dev/null +++ b/src/audit/execution-identity-context.ts @@ -0,0 +1,726 @@ +/** Immutable execution identity context storage and run-admission projection. */ +import type { DatabaseSync } from "node:sqlite"; +import type { Selectable } from "kysely"; +import type { + AuditRunInspectResult, + DecisionReceiptV1, + ExecutionIdentityContextV1, +} from "../../packages/gateway-protocol/src/index.js"; +import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { normalizeSqliteNumber } from "../infra/sqlite-number.js"; +import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; +import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import { clearAuditIdentityKeyCacheForDatabase } from "./audit-identity.js"; +import { + parseExecutionIdentityAdmissionEnvelope, + parseExecutionIdentityAdmissionWork, + type ExecutionIdentityAdmissionToken, +} from "./execution-identity-admission.js"; +import { + buildExecutionIdentityContext, + ensureBoundedExecutionIdentityRef, + freezeExecutionIdentityContext, +} from "./execution-identity-context-build.js"; + +type ExecutionIdentityDatabase = Pick< + OpenClawStateKyselyDatabase, + "audit_events" | "execution_identity_contexts" +>; +type ExecutionIdentityRow = Selectable; + +const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024; +const EXECUTION_IDENTITY_CONTEXT_RETENTION_MS = 30 * 24 * 60 * 60_000; +const EXECUTION_IDENTITY_CONTEXT_MAX_ROWS = 100_000; +const EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS = 1_024; +const EXECUTION_IDENTITY_HMAC_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u; + +const ensuredDatabases = new WeakSet(); + +// Keep this feature-local DDL byte-for-byte aligned with the canonical schema. +const EXECUTION_IDENTITY_CONTEXT_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS execution_identity_contexts ( + context_id TEXT NOT NULL PRIMARY KEY CHECK (length(context_id) BETWEEN 1 AND 256), + execution_id TEXT NOT NULL UNIQUE CHECK (length(execution_id) BETWEEN 1 AND 256), + run_id TEXT NOT NULL CHECK (length(run_id) BETWEEN 1 AND 256), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + coverage_state TEXT NOT NULL CHECK ( + coverage_state IN ('attribution-only', 'unattributed', 'unknown', 'unsupported') + ), + context_bytes INTEGER NOT NULL CHECK (context_bytes BETWEEN 1 AND 16384), + context_json TEXT NOT NULL CHECK (length(context_json) > 0), + UNIQUE (created_at, context_id) +) STRICT; +CREATE INDEX IF NOT EXISTS execution_identity_contexts_run_created_idx + ON execution_identity_contexts (run_id, created_at, execution_id); +`; + +type ExecutionIdentityStoreOptions = OpenClawStateDatabaseOptions & { + now?: number; + limits?: { + maxRows: number; + pruneBatchRows: number; + }; +}; + +type ExecutionIdentityReadOptions = OpenClawStateDatabaseOptions & { + now?: number; +}; + +type ExecutionIdentityContextReadResult = + | { status: "found"; context: ExecutionIdentityContextV1 } + | { status: "expired"; runId: string } + | { status: "missing" } + | { status: "corrupt"; runId: string; reasonCode: "identity_context_corrupt" }; + +function executionIdentityDb(db: DatabaseSync) { + return getNodeSqliteKysely(db); +} + +function ensureExecutionIdentityContextSchema(options: OpenClawStateDatabaseOptions = {}): void { + const database = openOpenClawStateDatabase(options); + if (ensuredDatabases.has(database.db)) { + return; + } + runOpenClawStateWriteTransaction( + ({ db }) => { + // sqlite-allow-raw -- feature-local additive schema DDL; context rows use Kysely. + db.exec(EXECUTION_IDENTITY_CONTEXT_SCHEMA_SQL); + }, + options, + { operationLabel: "audit.execution-identity.schema.ensure" }, + ); + ensuredDatabases.add(database.db); +} + +function parseExecutionIdentityRow(row: ExecutionIdentityRow): ExecutionIdentityContextV1 { + if ( + typeof row.context_json !== "string" || + Buffer.byteLength(row.context_json, "utf8") !== normalizeSqliteNumber(row.context_bytes) || + Buffer.byteLength(row.context_json, "utf8") > EXECUTION_IDENTITY_CONTEXT_MAX_BYTES + ) { + throw new Error("invalid context payload bounds"); + } + const parsed = JSON.parse(row.context_json) as unknown; + if (!validateExecutionIdentityContextV1(parsed)) { + throw new Error("invalid context payload schema"); + } + if ( + parsed.contextId !== row.context_id || + parsed.executionId !== row.execution_id || + parsed.runId !== row.run_id || + parsed.createdAt !== normalizeSqliteNumber(row.created_at) || + parsed.coverageState !== row.coverage_state || + JSON.stringify(parsed) !== row.context_json || + !EXECUTION_IDENTITY_HMAC_REF_RE.test(parsed.trustDomain.domainRef) || + !EXECUTION_IDENTITY_HMAC_REF_RE.test(parsed.runtimeInstance.runtimeRef) + ) { + throw new Error("context payload disagrees with indexed columns"); + } + return freezeExecutionIdentityContext(parsed); +} + +function readRowByExecutionId( + db: DatabaseSync, + executionId: string, +): ExecutionIdentityRow | undefined { + return executeSqliteQueryTakeFirstSync( + db, + executionIdentityDb(db) + .selectFrom("execution_identity_contexts") + .selectAll() + .where("execution_id", "=", executionId), + ); +} + +function readRowsByRunId( + db: DatabaseSync, + runId: string, + now: number, + offset: number, + limit: number, +): ExecutionIdentityRow[] { + return executeSqliteQuerySync( + db, + executionIdentityDb(db) + .selectFrom("execution_identity_contexts") + .selectAll() + .where("run_id", "=", runId) + .where("created_at", ">=", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS) + .orderBy("created_at", "asc") + .orderBy("execution_id", "asc") + .offset(offset) + .limit(limit), + ).rows; +} + +function deleteExpiredExecutionIdentityContexts(db: DatabaseSync, now: number, limit: number) { + const kysely = executionIdentityDb(db); + const expiredIds = kysely + .selectFrom("execution_identity_contexts") + .select("context_id") + .where("created_at", "<", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS) + .orderBy("created_at", "asc") + .orderBy("context_id", "asc") + .limit(limit); + return executeSqliteQuerySync( + db, + kysely.deleteFrom("execution_identity_contexts").where("context_id", "in", expiredIds), + ); +} + +function pruneExecutionIdentityContextsAfterInsert( + db: DatabaseSync, + now: number, + limits: { maxRows: number; pruneBatchRows: number }, +): void { + const kysely = executionIdentityDb(db); + const expired = deleteExpiredExecutionIdentityContexts(db, now, limits.pruneBatchRows); + const expiredCount = Number(expired.numAffectedRows ?? 0n); + const remainingPruneBudget = Math.max(0, limits.pruneBatchRows - expiredCount); + if (remainingPruneBudget > 0) { + // Derive overflow from committed rows inside this transaction. A process-local + // count misses writes from the Gateway worker or a concurrent direct CLI. + const retainedIds = kysely + .selectFrom("execution_identity_contexts") + .select("context_id") + .orderBy("created_at", "desc") + .orderBy("context_id", "desc") + .limit(limits.maxRows); + const oldestOverflowIds = kysely + .selectFrom("execution_identity_contexts") + .select("context_id") + .where("context_id", "not in", retainedIds) + .orderBy("created_at", "asc") + .orderBy("context_id", "asc") + .limit(remainingPruneBudget); + executeSqliteQuerySync( + db, + kysely.deleteFrom("execution_identity_contexts").where("context_id", "in", oldestOverflowIds), + ); + } +} + +/** Delete one bounded batch during the existing audit startup/hourly maintenance tick. */ +export function pruneExpiredExecutionIdentityContexts( + params: { + now?: number; + database?: OpenClawStateDatabaseOptions; + } = {}, +): number { + const databaseOptions = params.database ?? {}; + const database = openOpenClawStateDatabase(databaseOptions); + // Maintenance must not create opt-in storage. First capture owns schema creation; + // once the table exists, cleanup remains active even after collection is disabled. + if (!tableExists(database.db, "execution_identity_contexts")) { + return 0; + } + return runOpenClawStateWriteTransaction( + ({ db }) => { + const deleted = deleteExpiredExecutionIdentityContexts( + db, + params.now ?? Date.now(), + EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS, + ); + return Number(deleted.numAffectedRows ?? 0n); + }, + { ...databaseOptions, database }, + { operationLabel: "audit.execution-identity.context.maintenance" }, + ); +} + +/** Worker-owned canonicalization and persistence for one accepted admission envelope. */ +function persistExecutionIdentityAdmissionEnvelope( + input: unknown, + options: ExecutionIdentityStoreOptions = {}, +): ExecutionIdentityContextV1 { + // Structured clone removes the admission-side freeze. Revalidate all bounds + // before schema/key access so malformed messages never reach persistence. + const envelope = parseExecutionIdentityAdmissionEnvelope(input); + ensureExecutionIdentityContextSchema(options); + const executionId = envelope.executionId; + const opened = openOpenClawStateDatabase(options); + // HMAC lookup/key creation and canonical serialization finish before BEGIN. + // The transaction only rereads the authoritative row and synchronously commits. + const plannedContext = buildExecutionIdentityContext(opened.db, envelope, { + contextId: envelope.contextId, + createdAt: envelope.createdAt, + }); + const plannedContextJson = JSON.stringify(plannedContext); + let transactionDatabase: DatabaseSync | undefined; + try { + return runOpenClawStateWriteTransaction( + ({ db }) => { + transactionDatabase = db; + const existing = readRowByExecutionId(db, executionId); + if (existing) { + const context = parseExecutionIdentityRow(existing); + // Full canonical bytes, including the captured ID and timestamp, own replay identity. + // Never rewrite a newly captured envelope to resemble the retained execution context. + if (plannedContextJson !== existing.context_json) { + throw new Error("execution identity context conflict for execution"); + } + return context; + } + executeSqliteQuerySync( + db, + executionIdentityDb(db) + .insertInto("execution_identity_contexts") + .values({ + context_id: plannedContext.contextId, + execution_id: plannedContext.executionId, + run_id: plannedContext.runId, + created_at: plannedContext.createdAt, + coverage_state: plannedContext.coverageState, + context_bytes: Buffer.byteLength(plannedContextJson, "utf8"), + context_json: plannedContextJson, + }), + ); + pruneExecutionIdentityContextsAfterInsert( + db, + options.now ?? Date.now(), + options.limits ?? { + maxRows: EXECUTION_IDENTITY_CONTEXT_MAX_ROWS, + pruneBatchRows: EXECUTION_IDENTITY_CONTEXT_PRUNE_BATCH_ROWS, + }, + ); + return plannedContext; + }, + options, + { operationLabel: "audit.execution-identity.context.record" }, + ); + } catch (error) { + if (transactionDatabase) { + clearAuditIdentityKeyCacheForDatabase(transactionDatabase); + } + throw error; + } +} + +/** A durable recovery retry may only confirm the originally captured execution. */ +function verifyExecutionIdentityAdmissionRetry( + token: ExecutionIdentityAdmissionToken, + options: ExecutionIdentityReadOptions = {}, +): ExecutionIdentityContextV1 { + const { db } = openOpenClawStateDatabase(options); + if (!tableExists(db, "execution_identity_contexts")) { + throw new Error("execution identity recovery evidence unavailable"); + } + const existing = readRowByExecutionId(db, token.executionId); + if (!existing) { + // Never reconstruct identity from the later runtime after an ambiguous restart. + throw new Error("execution identity recovery evidence unavailable"); + } + const context = parseExecutionIdentityRow(existing); + if ( + context.contextId !== token.contextId || + context.executionId !== token.executionId || + context.runId !== token.runId || + context.createdAt !== token.createdAt + ) { + throw new Error("execution identity context conflict for execution"); + } + return context; +} + +/** Worker-owned persistence/verification for one accepted bounded queue item. */ +export function processExecutionIdentityAdmissionWork( + input: unknown, + options: ExecutionIdentityStoreOptions = {}, +): ExecutionIdentityContextV1 { + const work = parseExecutionIdentityAdmissionWork(input); + return work.kind === "capture" + ? persistExecutionIdentityAdmissionEnvelope(work.envelope, options) + : verifyExecutionIdentityAdmissionRetry(work.token, options); +} + +/** Read one exact execution while turning malformed rows into typed diagnostics. */ +function readExecutionIdentityContextByExecutionId( + executionId: string, + options: ExecutionIdentityReadOptions = {}, +): ExecutionIdentityContextReadResult { + const normalizedExecutionId = ensureBoundedExecutionIdentityRef(executionId, "execution id"); + return ( + withExistingOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "execution_identity_contexts")) { + return { status: "missing" } as const; + } + const row = readRowByExecutionId(db, normalizedExecutionId); + if (!row) { + return { status: "missing" } as const; + } + const createdAt = normalizeSqliteNumber(row.created_at); + if ( + createdAt !== undefined && + createdAt < (options.now ?? Date.now()) - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS + ) { + // The indexed timestamp may explain availability, but expired context JSON + // must never be parsed or projected while bounded maintenance catches up. + return { status: "expired", runId: row.run_id } as const; + } + try { + return { status: "found", context: parseExecutionIdentityRow(row) } as const; + } catch { + return { + status: "corrupt", + runId: row.run_id, + reasonCode: "identity_context_corrupt", + } as const; + } + }, options) ?? { status: "missing" } + ); +} + +function admissionDecision(context: ExecutionIdentityContextV1): DecisionReceiptV1 { + return { + schemaVersion: 1, + receiptId: `${context.contextId}:admission`, + contextId: context.contextId, + executionId: context.executionId, + runId: context.runId, + occurredAt: context.createdAt, + action: { + family: "run", + operation: "admission", + summary: "Run admission was recorded without an identity-aware policy or grant decision.", + }, + decision: { + outcome: "not-applicable", + reasonCode: "run_admission_identity_not_evaluated", + }, + enforcement: { + coverageState: context.coverageState, + policyRefs: [], + grantRefs: [], + contextFieldsUsed: [], + }, + source: { + owner: "agent-command", + recordRef: context.contextId, + decisionBoundary: "agent-command.run-admission", + }, + missingEvidence: [...context.missingEvidence], + remediation: [ + { + code: "no_identity_enforcement_claimed", + text: "Treat this receipt as attribution only; it does not prove authorization.", + }, + ], + }; +} + +function unavailableResult(params: { + selector: { runId: string } | { executionId: string }; + resolvedRunId?: string; + runStatus: "known" | "unknown"; + state: "unknown" | "unsupported"; + reasonCode: string; + missingEvidence: string[]; + remediation: Array<{ code: string; text: string }>; +}): AuditRunInspectResult { + const run: AuditRunInspectResult["run"] = + "executionId" in params.selector + ? { + executionId: params.selector.executionId, + ...(params.resolvedRunId ? { runId: params.resolvedRunId } : {}), + status: params.runStatus, + } + : { + runId: params.resolvedRunId ?? params.selector.runId, + status: params.runStatus, + }; + return { + schemaVersion: 1, + run, + identity: { + state: params.state, + reasonCode: params.reasonCode, + missingEvidence: params.missingEvidence, + remediation: params.remediation, + }, + decisions: [], + coverage: { state: params.state, missingEvidence: params.missingEvidence }, + }; +} + +function unavailableIdentityContext( + selector: { runId: string } | { executionId: string }, + remediation: { code: string; text: string }, + resolvedRunId?: string, +): AuditRunInspectResult { + return unavailableResult({ + selector, + resolvedRunId, + runStatus: "known", + state: "unsupported", + reasonCode: "identity_context_unavailable", + missingEvidence: ["identity.context"], + remediation: [remediation], + }); +} + +function presentResult(params: { + context: ExecutionIdentityContextV1; + decisionOffset?: number; + decisionLimit?: number; +}): AuditRunInspectResult { + const allDecisions = [admissionDecision(params.context)]; + const offset = params.decisionOffset ?? 0; + const limit = params.decisionLimit ?? 50; + const decisions = allDecisions.slice(offset, offset + limit); + const nextOffset = offset + decisions.length; + return { + schemaVersion: 1, + run: { + runId: params.context.runId, + executionId: params.context.executionId, + status: "known", + }, + identity: { state: "present", context: params.context }, + decisions, + coverage: { + state: params.context.coverageState, + missingEvidence: [...params.context.missingEvidence], + }, + ...(nextOffset < allDecisions.length ? { nextDecisionCursor: String(nextOffset) } : {}), + }; +} + +function inspectExactExecution( + params: { executionId: string; decisionOffset?: number; decisionLimit?: number }, + options: ExecutionIdentityReadOptions, +): AuditRunInspectResult { + const executionId = ensureBoundedExecutionIdentityRef(params.executionId, "execution id"); + const selector = { executionId }; + const contextResult = readExecutionIdentityContextByExecutionId(executionId, options); + if (contextResult.status === "found") { + return presentResult({ + context: contextResult.context, + decisionOffset: params.decisionOffset, + decisionLimit: params.decisionLimit, + }); + } + if (contextResult.status === "corrupt") { + return unavailableResult({ + selector, + resolvedRunId: contextResult.runId, + runStatus: "known", + state: "unknown", + reasonCode: contextResult.reasonCode, + missingEvidence: ["identity.context.valid"], + remediation: [ + { + code: "inspect_state_integrity", + text: "Run openclaw doctor and inspect the shared state database before trusting this execution.", + }, + ], + }); + } + if (contextResult.status === "expired") { + return unavailableIdentityContext( + selector, + { + code: "run_again_after_expiry", + text: "This execution's identity context is outside the 30-day retention window; run the operation again to record a new context.", + }, + contextResult.runId, + ); + } + return unavailableResult({ + selector, + runStatus: "unknown", + state: "unknown", + reasonCode: "execution_not_found", + missingEvidence: ["identity.context"], + remediation: [ + { + code: "verify_execution_id", + text: "Verify the exact execution id; absence of best-effort identity evidence is not proof that no run occurred.", + }, + ], + }); +} + +function hasAnyRunContext(db: DatabaseSync, runId: string): boolean { + return Boolean( + executeSqliteQueryTakeFirstSync( + db, + executionIdentityDb(db) + .selectFrom("execution_identity_contexts") + .select("context_id") + .where("run_id", "=", runId) + .limit(1), + ), + ); +} + +function hasRetainedAuditRun(db: DatabaseSync, runId: string, now: number): boolean { + if (!tableExists(db, "audit_events")) { + return false; + } + return Boolean( + executeSqliteQueryTakeFirstSync( + db, + executionIdentityDb(db) + .selectFrom("audit_events") + .select("sequence") + .where("run_id", "=", runId) + .where("occurred_at", ">=", now - EXECUTION_IDENTITY_CONTEXT_RETENTION_MS) + .where("kind", "!=", "message") + .limit(1), + ), + ); +} + +function inspectRunSelector( + params: { + runId: string; + executionOffset?: number; + executionLimit?: number; + decisionOffset?: number; + decisionLimit?: number; + }, + options: ExecutionIdentityReadOptions, +): AuditRunInspectResult { + const runId = ensureBoundedExecutionIdentityRef(params.runId, "run id"); + const now = options.now ?? Date.now(); + const inspected = withExistingOpenClawStateDatabaseReadOnly( + ({ db }) => { + const firstMatches = tableExists(db, "execution_identity_contexts") + ? readRowsByRunId(db, runId, now, 0, 2) + : []; + if (firstMatches.length === 1) { + try { + return presentResult({ + context: parseExecutionIdentityRow(firstMatches[0]!), + decisionOffset: params.decisionOffset, + decisionLimit: params.decisionLimit, + }); + } catch { + return unavailableResult({ + selector: { runId }, + runStatus: "known", + state: "unknown", + reasonCode: "identity_context_corrupt", + missingEvidence: ["identity.context.valid"], + remediation: [ + { + code: "inspect_state_integrity", + text: "Run openclaw doctor and inspect the shared state database before trusting this run.", + }, + ], + }); + } + } + if (firstMatches.length > 1) { + const offset = params.executionOffset ?? 0; + const limit = params.executionLimit ?? 50; + const page = readRowsByRunId(db, runId, now, offset, limit + 1); + const candidates = page.slice(0, limit).map((row) => ({ + executionId: row.execution_id, + contextId: row.context_id, + createdAt: normalizeSqliteNumber(row.created_at) ?? 0, + })); + return { + schemaVersion: 1, + run: { runId, status: "known" }, + identity: { + state: "ambiguous", + reasonCode: "execution_selection_required", + candidates, + missingEvidence: ["execution.selection"], + remediation: [ + { + code: "select_execution_id", + text: "Select one candidate with openclaw audit --execution --explain.", + }, + ], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["execution.selection"] }, + ...(page.length > limit ? { nextExecutionCursor: String(offset + limit) } : {}), + }; + } + if (tableExists(db, "execution_identity_contexts") && hasAnyRunContext(db, runId)) { + return unavailableIdentityContext( + { runId }, + { + code: "run_again_after_expiry", + text: "This run's retained identity contexts are outside the 30-day window; run the operation again to record a new execution.", + }, + ); + } + try { + if (hasRetainedAuditRun(db, runId, now)) { + return unavailableIdentityContext( + { runId }, + { + code: "record_new_identity_context", + text: "Confirm audit collection is enabled and the Gateway is current, then run the operation again to record a new execution context.", + }, + ); + } + } catch { + return unavailableResult({ + selector: { runId }, + runStatus: "unknown", + state: "unknown", + reasonCode: "run_evidence_unreadable", + missingEvidence: ["run.record", "identity.context"], + remediation: [ + { + code: "inspect_state_integrity", + text: "Run openclaw doctor and retry the run inspection.", + }, + ], + }); + } + return undefined; + }, + options, + ); + if (inspected) { + return inspected; + } + return unavailableResult({ + selector: { runId }, + runStatus: "unknown", + state: "unknown", + reasonCode: "run_not_found", + missingEvidence: ["run.record", "identity.context"], + remediation: [ + { + code: "verify_run_id", + text: "Verify the run id; absence of best-effort audit activity is not proof of no run.", + }, + ], + }); +} + +/** Inspect one exact execution or discover bounded executions for a run correlation. */ +export function inspectExecutionIdentityRun( + params: + | { + runId: string; + executionOffset?: number; + executionLimit?: number; + decisionOffset?: number; + decisionLimit?: number; + } + | { executionId: string; decisionOffset?: number; decisionLimit?: number }, + options: ExecutionIdentityReadOptions = {}, +): AuditRunInspectResult { + return "executionId" in params + ? inspectExactExecution(params, options) + : inspectRunSelector(params, options); +} diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 54a055619b59..87dec59c1be1 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -112,7 +112,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([ }, { name: "audit", - description: "Inspect metadata-only run, tool, and message lifecycle records", + description: "Inspect activity records and exact-run identity context", hasSubcommands: false, }, { diff --git a/src/cli/program/register.audit.ts b/src/cli/program/register.audit.ts index 9ca84cdb6326..81d689d958aa 100644 --- a/src/cli/program/register.audit.ts +++ b/src/cli/program/register.audit.ts @@ -10,10 +10,11 @@ import { runCommandWithRuntime } from "../cli-utils.js"; export function registerAuditCommand(program: Command): void { program .command("audit") - .description("Inspect metadata-only run, tool, and message lifecycle records") + .description("Inspect activity records and exact-run identity context") .option("--agent ", "Filter by agent id") .option("--session ", "Filter by exact session key") .option("--run ", "Filter by run id") + .option("--execution ", "Inspect one exact execution id") .option("--kind ", "Filter by kind (agent_run, tool_action, or message)") .option( "--status ", @@ -24,7 +25,8 @@ export function registerAuditCommand(program: Command): void { .option("--after ", "Include records at/after ISO time or Unix milliseconds") .option("--before ", "Include records at/before ISO time or Unix milliseconds") .option("--cursor ", "Continue from a previous result cursor") - .option("--limit ", "Maximum records (1-500)", "100") + .option("--limit ", "Maximum records (1-500; decisions 1-100)") + .option("--explain", "Inspect execution identity and run-admission reasoning", false) .option("--json", "Output a bounded JSON page", false) .addHelpText( "after", @@ -38,6 +40,7 @@ export function registerAuditCommand(program: Command): void { agentId: opts.agent as string | undefined, sessionKey: opts.session as string | undefined, runId: opts.run as string | undefined, + executionId: opts.execution as string | undefined, kind: opts.kind as AuditListCommandOptions["kind"], status: opts.status as AuditListCommandOptions["status"], direction: opts.direction as AuditListCommandOptions["direction"], @@ -46,6 +49,7 @@ export function registerAuditCommand(program: Command): void { before: opts.before as string | undefined, cursor: opts.cursor as string | undefined, limit: opts.limit as string | undefined, + explain: Boolean(opts.explain), json: Boolean(opts.json), }, defaultRuntime, diff --git a/src/commands/agent-exec.test.ts b/src/commands/agent-exec.test.ts index a7d932f5ec99..7c3988b7382e 100644 --- a/src/commands/agent-exec.test.ts +++ b/src/commands/agent-exec.test.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { Readable } from "node:stream"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,6 +13,7 @@ import { loadAuthProfileStoreForRuntime, resolvePersistedAuthProfileOwnerAgentDir, } from "../agents/auth-profiles.js"; +import { enqueueExecutionIdentityContextAtAdmission } from "../audit/execution-identity-admission.js"; import { clearRuntimeConfigSnapshot, getRuntimeConfigSnapshot, @@ -333,6 +335,61 @@ describe("agent exec command composition", () => { await expect(fs.stat(observedStateDir)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("flushes opted-in identity evidence through its owned direct-local writer", async () => { + const root = await makeTempRoot("openclaw-agent-exec-audit-"); + const admittedAt = Date.now(); + setRuntimeConfigSnapshot({ logging: { audit: { executionIdentity: true } } }); + try { + const { runtime } = createRuntime(); + const result = await agentExecCommand("inspect", { stateDir: root }, runtime, { + runAgent: vi.fn(async () => { + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "agent-exec-run", + agentId: "main", + ingress: { + kind: "local-cli", + boundary: "agent-command.local", + state: "present", + }, + runtime: { kind: "embedded" }, + }, + { + enabled: true, + contextId: "agent-exec-context", + executionId: "agent-exec-execution", + now: admittedAt, + runtimeInstanceId: "agent-exec-runtime", + }, + ), + ).toMatchObject({ accepted: true }); + return successResult(); + }), + }); + + expect(result.exitCode).toBe(0); + const database = new DatabaseSync(path.join(root, "state", "openclaw.sqlite"), { + readOnly: true, + }); + try { + const row = database + .prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?") + .get("agent-exec-execution") as { context_json: string }; + expect(JSON.parse(row.context_json)).toMatchObject({ + contextId: "agent-exec-context", + executionId: "agent-exec-execution", + runId: "agent-exec-run", + ingress: { kind: "local-cli", state: "present" }, + }); + } finally { + database.close(); + } + } finally { + clearRuntimeConfigSnapshot(); + } + }); + it("discovers operator-installed plugins while run state stays ephemeral", async () => { const operatorStateDir = await makeTempRoot("openclaw-agent-exec-plugin-owner-"); const pluginDir = path.join(operatorStateDir, "extensions", "exec-provider"); diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts index 47d32e48140b..ae335e28c7f1 100644 --- a/src/commands/agent-exec.ts +++ b/src/commands/agent-exec.ts @@ -7,6 +7,7 @@ import { TextDecoder } from "node:util"; import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit"; import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-error.js"; import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js"; +import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { mergeDeep } from "../infra/deep-merge.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -550,6 +551,7 @@ export async function agentExecCommand( let restoreRuntimeConfigSnapshot: (() => void) | undefined; let runtimePaths: typeof import("../config/paths.js") | undefined; let configIo: typeof import("../config/io.js") | undefined; + let stopLocalAuditWriter: (() => Promise) | undefined; try { const prompt = await resolveAgentExecPrompt( positionalMessage, @@ -623,6 +625,15 @@ export async function agentExecCommand( // env-substituted provider keys to disk where the run's own exec tool // could read them. snapshotIo.setRuntimeConfigSnapshot(runConfig); + if (isExecutionIdentityCollectionEnabled(runConfig)) { + try { + stopLocalAuditWriter = (await import("./agent-local-audit.js")).startAgentLocalAuditWriter({ + stateDir, + }); + } catch { + // Admission emits a bounded warning if the direct-process writer is unavailable. + } + } const [ { withAuthProfileStoreAgentDir, withEnvOnlyAuthProfileStore }, { withHostExecInheritedEnvOmitted }, @@ -695,6 +706,7 @@ export async function agentExecCommand( } let cleanupError: unknown; + await stopLocalAuditWriter?.().catch(() => undefined); const runCleanupStep = (step: () => void) => { try { step(); diff --git a/src/commands/agent-local-audit.ts b/src/commands/agent-local-audit.ts new file mode 100644 index 000000000000..d82e5cc05cfe --- /dev/null +++ b/src/commands/agent-local-audit.ts @@ -0,0 +1,24 @@ +/** Direct-local agent audit writer lifecycle shared by CLI entrypoints. */ +import { createAuditEventRecorder } from "../audit/audit-recorder.js"; +import { + configureExecutionIdentityAdmissionSink, + hasExecutionIdentityAdmissionSink, +} from "../audit/execution-identity-admission.js"; + +/** Own one direct-process writer unless a surrounding runtime already owns it. */ +export function startAgentLocalAuditWriter( + options: { stateDir?: string } = {}, +): (() => Promise) | undefined { + if (hasExecutionIdentityAdmissionSink()) { + return undefined; + } + const recorder = createAuditEventRecorder({ + messageMode: "off", + ...(options.stateDir ? { stateDir: options.stateDir } : {}), + }); + const clearSink = configureExecutionIdentityAdmissionSink(recorder.recordExecutionIdentity); + return async () => { + clearSink(); + await recorder.stop(); + }; +} diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 8adee8946562..0a966df84661 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -5,6 +5,10 @@ import os from "node:os"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + configureExecutionIdentityAdmissionSink, + hasExecutionIdentityAdmissionSink, +} from "../audit/execution-identity-admission.js"; import type { OpenClawConfig } from "../config/config.js"; import { loggingState } from "../logging/state.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -39,6 +43,11 @@ const agentCommand = vi.hoisted(() => vi.fn()); const agentModuleLoadCount = vi.hoisted(() => vi.fn()); const loadAgentSessionModuleMock = vi.hoisted(() => vi.fn()); const startOneShotDiagnosticsExporters = vi.hoisted(() => vi.fn()); +const auditRecorderMocks = vi.hoisted(() => ({ + create: vi.fn(), + recordExecutionIdentity: vi.fn(() => true), + stop: vi.fn(async () => {}), +})); const runtime: RuntimeEnv = { log: vi.fn(), @@ -69,6 +78,7 @@ function mockConfig(storePath: string, overrides?: Partial) { ...overrides?.session, }, gateway: overrides?.gateway, + logging: overrides?.logging, }; loadConfig.mockReturnValue(config); loadConfigWithShellEnvFallback.mockResolvedValue(config); @@ -248,6 +258,15 @@ vi.mock("./agent.js", () => { vi.mock("../plugins/one-shot-diagnostics.js", () => ({ startOneShotDiagnosticsExporters, })); +vi.mock("../audit/audit-recorder.js", () => ({ + createAuditEventRecorder: (...args: unknown[]) => { + auditRecorderMocks.create(...args); + return { + recordExecutionIdentity: auditRecorderMocks.recordExecutionIdentity, + stop: auditRecorderMocks.stop, + }; + }, +})); let originalForceConsoleToStderr = false; let zeroTimeoutGatewayRequestMs: number | undefined; @@ -275,6 +294,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs(); + configureExecutionIdentityAdmissionSink(() => false)(); agentViaGatewayTesting.setGatewayAbortRetryDelaysMsForTests(); loggingState.forceConsoleToStderr = originalForceConsoleToStderr; }); @@ -1820,6 +1840,7 @@ describe("agentCliCommand", () => { expect(loadRuntimeConfig).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); expect(stop).toHaveBeenCalledTimes(1); + expect(auditRecorderMocks.create).not.toHaveBeenCalled(); const startOrder = requireFirstCallOrder(startOneShotDiagnosticsExporters, "exporter start"); const runOrder = requireFirstCallOrder(agentCommand, "embedded agent"); const stopOrder = requireFirstCallOrder(stop, "exporter stop"); @@ -1828,6 +1849,46 @@ describe("agentCliCommand", () => { }); }); + it("owns and flushes the opt-in local audit writer without awaiting persistence", async () => { + await withTempStore( + async () => { + agentCommand.mockImplementationOnce(async () => { + expect(hasExecutionIdentityAdmissionSink()).toBe(true); + return { + payloads: [{ text: "local" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + } as unknown as Awaited>; + }); + + await agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime); + + expect(auditRecorderMocks.create).toHaveBeenCalledWith({ messageMode: "off" }); + expect(auditRecorderMocks.stop).toHaveBeenCalledOnce(); + expect(hasExecutionIdentityAdmissionSink()).toBe(false); + }, + { logging: { audit: { executionIdentity: true } } }, + ); + }); + + it("reuses an existing lifecycle-owned identity writer for local dispatch", async () => { + await withTempStore( + async () => { + const clearSink = configureExecutionIdentityAdmissionSink(() => true); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", to: "+1555", local: true }, runtime); + + expect(auditRecorderMocks.create).not.toHaveBeenCalled(); + expect(hasExecutionIdentityAdmissionSink()).toBe(true); + clearSink(); + }, + { logging: { audit: { executionIdentity: true } } }, + ); + }); + it("suppresses stdout diagnostic logs around JSON local embedded runs", async () => { await withTempStore(async () => { const stop = vi.fn(async () => {}); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 413838311984..c77be1d2e034 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -9,6 +9,7 @@ import { } from "../../packages/gateway-protocol/src/client-info.js"; import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { measureAgentStartup } from "../agents/startup-timing.js"; +import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.types.js"; import { withProgress } from "../cli/progress.js"; @@ -126,6 +127,9 @@ const embeddedAgentCommandLoader = createLazyPromiseLoader( () => import("./agent.js").then((module) => module.agentCommand), { cacheRejections: true }, ); +const localAuditModuleLoader = createLazyPromiseLoader(() => import("./agent-local-audit.js"), { + cacheRejections: true, +}); const agentSessionModuleCache = createLazyPromiseLoader(() => agentSessionModuleLoader(), { cacheRejections: true, }); @@ -154,10 +158,11 @@ type EmbeddedRunDiagnosticsOptions = { async function startEmbeddedRunDiagnosticsExporters( runtime: RuntimeEnv, options: EmbeddedRunDiagnosticsOptions, + config: OpenClawConfig, ): Promise { try { return await startOneShotDiagnosticsExporters({ - config: await loadRuntimeConfig(), + config, suppressStdoutDiagnosticLogs: options.suppressStdoutDiagnosticLogs, }); } catch (err) { @@ -182,11 +187,24 @@ async function runEmbeddedAgentCommand( const agentCommand = await measureAgentStartup("command-import", () => embeddedAgentCommandLoader.load(), ); - const diagnostics = await startEmbeddedRunDiagnosticsExporters(runtime, diagnosticsOptions); + const config = await loadRuntimeConfig(); + const diagnostics = await startEmbeddedRunDiagnosticsExporters( + runtime, + diagnosticsOptions, + config, + ); + let stopLocalAuditWriter: (() => Promise) | undefined; + if (isExecutionIdentityCollectionEnabled(config)) { + try { + stopLocalAuditWriter = (await localAuditModuleLoader.load()).startAgentLocalAuditWriter(); + } catch { + // Admission emits one bounded warning if evidence cannot be queued. + } + } try { return await agentCommand(opts, runtime, deps); } finally { - await diagnostics?.stop(); + await Promise.all([diagnostics?.stop(), stopLocalAuditWriter?.().catch(() => undefined)]); } } @@ -201,6 +219,7 @@ const loadReplyPayloadModule = replyPayloadModuleLoader.load; export const agentViaGatewayTesting = { resetLazyImportsForTests(): void { embeddedAgentCommandLoader.clear(); + localAuditModuleLoader.clear(); agentSessionModuleCache.clear(); runtimeConfigModuleLoader.clear(); replyPayloadModuleLoader.clear(); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index e07f410af291..2f21396170dd 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest" // Register shared mocks before imports bind their production exports. import "./agent-command.test-mocks.js"; import { testing as acpManagerTesting } from "../acp/control-plane/manager.js"; +import { executionIdentity } from "../agents/agent-command-execution-identity.js"; import * as authProfileStoreModule from "../agents/auth-profiles/store.js"; import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js"; import { deliverAgentCommandResult } from "../agents/command/delivery.runtime.js"; @@ -446,6 +447,67 @@ describe("agentCommand", () => { ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); }); + it("strips private recovery identity from runtime-shaped public ingress", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + const record = vi.spyOn(executionIdentity, "record").mockImplementation(() => undefined); + const inheritedAdmission = { + token: { + tokenVersion: 1 as const, + contextId: "inherited-context", + executionId: "inherited-execution", + runId: "public-ingress-run", + createdAt: 1, + }, + retryOnly: true, + }; + const priorDescriptor = Object.getOwnPropertyDescriptor( + Object.prototype, + "executionIdentityAdmission", + ); + // oxlint-disable-next-line no-extend-native -- Simulate a hostile JS plugin's prototype pollution. + Object.defineProperty(Object.prototype, "executionIdentityAdmission", { + configurable: true, + value: inheritedAdmission, + }); + + try { + await agentCommandFromIngress( + { + message: "public plugin turn", + agentId: "main", + runId: "public-ingress-run", + allowModelOverride: false, + executionIdentityAdmission: { + token: { + tokenVersion: 1, + contextId: "forged-context", + executionId: "forged-execution", + runId: "public-ingress-run", + createdAt: 1, + }, + retryOnly: true, + }, + } as never, + runtime, + ); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ admission: undefined, runId: "public-ingress-run" }), + ); + } finally { + record.mockRestore(); + if (priorDescriptor) { + // oxlint-disable-next-line no-extend-native -- Restore the exact pre-test prototype descriptor. + Object.defineProperty(Object.prototype, "executionIdentityAdmission", priorDescriptor); + } else { + delete (Object.prototype as Record).executionIdentityAdmission; + } + } + }); + }); + it("rejects a missing harness-owned session before local CLI dispatch", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/audit.test-support.ts b/src/commands/audit.test-support.ts index 7ae8792e7a76..fd77e3c08ecb 100644 --- a/src/commands/audit.test-support.ts +++ b/src/commands/audit.test-support.ts @@ -1,4 +1,5 @@ import "./audit.js"; +import type { AuditRunInspectResult } from "../../packages/gateway-protocol/src/index.js"; type AuditTestEvent = { occurredAt: number; @@ -13,7 +14,11 @@ type AuditTestEvent = { }; type AuditCommandTestApi = { + formatAuditRunInspection(result: AuditRunInspectResult): string[]; formatAuditRows(events: readonly AuditTestEvent[]): string[]; + hasExplainIncompatibleFilters(options: Record): boolean; + parseAuditDecisionLimit(value: string | undefined): number; + parseAuditExecutionLimit(value: string | undefined): number; parseAuditLimit(value: string | undefined): number; parseAuditTimestamp(value: string | undefined, flag: string): number | undefined; }; @@ -25,9 +30,21 @@ function getTestApi(): AuditCommandTestApi { } export const testApi: AuditCommandTestApi = { + formatAuditRunInspection(result) { + return getTestApi().formatAuditRunInspection(result); + }, formatAuditRows(events) { return getTestApi().formatAuditRows(events); }, + hasExplainIncompatibleFilters(options) { + return getTestApi().hasExplainIncompatibleFilters(options); + }, + parseAuditDecisionLimit(value) { + return getTestApi().parseAuditDecisionLimit(value); + }, + parseAuditExecutionLimit(value) { + return getTestApi().parseAuditExecutionLimit(value); + }, parseAuditLimit(value) { return getTestApi().parseAuditLimit(value); }, diff --git a/src/commands/audit.test.ts b/src/commands/audit.test.ts index 7b529a736679..f1a52481502e 100644 --- a/src/commands/audit.test.ts +++ b/src/commands/audit.test.ts @@ -26,6 +26,13 @@ function unknownActivityMethodError() { }); } +function unknownRunInspectMethodError() { + return Object.assign(new Error("unknown method: audit.run.inspect"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + }); +} + function oldGatewayUnknownMethodScopeError() { return Object.assign(new Error("missing scope: operator.admin"), { name: "GatewayClientRequestError", @@ -308,3 +315,218 @@ describe("audit command gateway compatibility", () => { expect(callGateway).toHaveBeenCalledTimes(1); }); }); + +describe("audit run explanation", () => { + beforeEach(() => { + callGateway.mockReset(); + vi.mocked(runtime.log).mockClear(); + }); + + it("rejects --execution without --explain before querying the Gateway", async () => { + await expect(auditListCommand({ executionId: "execution-1" }, runtime)).rejects.toThrow( + "--execution requires --explain", + ); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("requires one exact run and keeps decision queries bounded", async () => { + await expect(auditListCommand({ explain: true }, runtime)).rejects.toThrow( + "exactly one of --run or --execution ", + ); + await expect( + auditListCommand({ explain: true, runId: "run-1", executionId: "execution-1" }, runtime), + ).rejects.toThrow("exactly one"); + await expect( + auditListCommand({ explain: true, runId: "run-1", agentId: "main" }, runtime), + ).rejects.toThrow("remove activity-list filters"); + expect(testApi.parseAuditDecisionLimit(undefined)).toBe(50); + expect(testApi.parseAuditDecisionLimit("100")).toBe(100); + expect(() => testApi.parseAuditDecisionLimit("101")).toThrow("with --explain"); + expect(testApi.parseAuditExecutionLimit("50")).toBe(50); + expect(() => testApi.parseAuditExecutionLimit("51")).toThrow("run discovery"); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("queries audit.run.inspect and renders all identity fields with explicit state", async () => { + const hmacRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`; + callGateway.mockResolvedValue({ + schemaVersion: 1, + run: { runId: "run-1", executionId: "execution-1", status: "known" }, + identity: { + state: "present", + context: { + schemaVersion: 1, + contextId: "context-1", + executionId: "execution-1", + runId: "run-1", + createdAt: 1, + trustDomain: { kind: "gateway-cell", domainRef: hmacRef, state: "present" }, + invoker: { state: "absent" }, + ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" }, + agentPrincipal: { kind: "agent", domainRef: hmacRef, principalRef: "main" }, + agentDefinition: { definitionRef: "main", state: "present" }, + runtimeInstance: { runtimeRef: hmacRef, kind: "embedded", state: "present" }, + applicableGrants: [], + assurance: [ + { + kind: "runtime-binding", + evidenceRef: hmacRef, + strength: "boundary-verified", + }, + ], + coverageState: "unattributed", + missingEvidence: ["invoker.principal"], + }, + }, + decisions: [ + { + schemaVersion: 1, + receiptId: "context-1:admission", + contextId: "context-1", + executionId: "execution-1", + runId: "run-1", + occurredAt: 1, + action: { family: "run", operation: "admission" }, + decision: { + outcome: "not-applicable", + reasonCode: "run_admission_identity_not_evaluated", + }, + enforcement: { + coverageState: "unattributed", + policyRefs: [], + grantRefs: [], + contextFieldsUsed: [], + }, + source: { + owner: "agent-command", + recordRef: "context-1", + decisionBoundary: "agent-command.run-admission", + }, + missingEvidence: ["invoker.principal"], + remediation: [{ code: "no_claim", text: "Treat this receipt as attribution only." }], + }, + ], + coverage: { state: "unattributed", missingEvidence: ["invoker.principal"] }, + }); + + await auditListCommand({ explain: true, runId: "run-1", cursor: "1", limit: "25" }, runtime); + + expect(callGateway).toHaveBeenCalledWith({ + method: "audit.run.inspect", + params: { + runId: "run-1", + executionCursor: "1", + executionLimit: 25, + decisionCursor: "1", + decisionLimit: 25, + }, + }); + const output = vi.mocked(runtime.log).mock.calls.flat().join("\n"); + for (const label of [ + "Trust domain [present]", + "Invoker [absent]", + "Ingress [present]", + "Agent principal [present]", + "Agent definition [present]", + "Runtime instance [present]", + "Represented subject [absent]", + "Sponsor [absent]", + "Applicable grants [absent]", + "Assurance [present]", + "Parent [absent]", + ]) { + expect(output).toContain(label); + } + expect(output).toContain("not-applicable"); + expect(output).toContain("run_admission_identity_not_evaluated"); + }); + + it("renders ambiguous run discovery and selects an exact execution", async () => { + callGateway.mockResolvedValueOnce({ + schemaVersion: 1, + run: { runId: "session-run", status: "known" }, + identity: { + state: "ambiguous", + reasonCode: "execution_selection_required", + candidates: [ + { executionId: "execution-1", contextId: "context-1", createdAt: 1 }, + { executionId: "execution-2", contextId: "context-2", createdAt: 2 }, + ], + missingEvidence: ["execution.selection"], + remediation: [ + { + code: "select_execution_id", + text: "Select one candidate with openclaw audit --execution --explain.", + }, + ], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["execution.selection"] }, + }); + + await auditListCommand({ explain: true, runId: "session-run" }, runtime); + const output = vi.mocked(runtime.log).mock.calls.flat().join("\n"); + expect(output).toContain("Candidate: execution-1"); + expect(output).toContain("--execution --explain"); + + callGateway.mockReset(); + callGateway.mockResolvedValue({ + schemaVersion: 1, + run: { runId: "session-run", executionId: "execution-2", status: "unknown" }, + identity: { + state: "unknown", + reasonCode: "execution_not_found", + missingEvidence: ["identity.context"], + remediation: [{ code: "verify_execution_id", text: "Verify the exact execution id." }], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["identity.context"] }, + }); + await auditListCommand({ explain: true, executionId: "execution-2", json: true }, runtime); + expect(callGateway).toHaveBeenCalledWith({ + method: "audit.run.inspect", + params: { executionId: "execution-2", decisionLimit: 50 }, + }); + }); + + it("renders expired identity as unsupported without context fields or decisions", async () => { + callGateway.mockResolvedValue({ + schemaVersion: 1, + run: { runId: "expired-run", status: "known" }, + identity: { + state: "unsupported", + reasonCode: "identity_context_unavailable", + missingEvidence: ["identity.context"], + remediation: [ + { + code: "run_again_after_expiry", + text: "This run's identity context is outside the 30-day retention window; run the operation again to record a new context.", + }, + ], + }, + decisions: [], + coverage: { state: "unsupported", missingEvidence: ["identity.context"] }, + }); + + await auditListCommand({ explain: true, runId: "expired-run" }, runtime); + + const output = vi.mocked(runtime.log).mock.calls.flat().join("\n"); + expect(output).toContain("Ingress [unsupported]"); + expect(output).toContain("none [absent]"); + expect(output).toContain("outside the 30-day retention window"); + expect(output).not.toContain("Context:"); + expect(output).not.toContain("run_admission_identity_not_evaluated"); + }); + + it("returns an explicit upgrade state from an older Gateway", async () => { + callGateway.mockRejectedValue(unknownRunInspectMethodError()); + + await auditListCommand({ explain: true, runId: "old-run", json: true }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const output = vi.mocked(runtime.log).mock.calls.flat().join("\n"); + expect(output).toContain('"state": "unsupported"'); + expect(output).toContain("gateway_upgrade_required"); + expect(output).toContain("upgrade_gateway"); + }); +}); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index e32adfba7014..c80adfb1adba 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -6,6 +6,11 @@ import type { AuditActivityListResult, AuditListParams, AuditListResult, + AuditRunInspectParams, + AuditRunInspectResult, + DecisionReceiptV1, + ExecutionIdentityContextV1, + PrincipalRefV1, } from "../../packages/gateway-protocol/src/index.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { parseAbsoluteTimeMs } from "../cron/parse.js"; @@ -15,11 +20,15 @@ import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; const DEFAULT_AUDIT_LIMIT = 100; const MAX_AUDIT_LIMIT = 500; +const DEFAULT_AUDIT_DECISION_LIMIT = 50; +const MAX_AUDIT_DECISION_LIMIT = 100; +const MAX_AUDIT_EXECUTION_LIMIT = 50; export type AuditListCommandOptions = { agentId?: string; sessionKey?: string; runId?: string; + executionId?: string; kind?: AuditActivityListParams["kind"]; status?: AuditActivityListParams["status"]; direction?: AuditActivityListParams["direction"]; @@ -28,6 +37,7 @@ export type AuditListCommandOptions = { before?: string; cursor?: string; limit?: string; + explain?: boolean; json?: boolean; }; @@ -72,6 +82,29 @@ function parseAuditLimit(value: string | undefined): number { return parsed; } +function parseAuditDecisionLimit(value: string | undefined): number { + if (!value) { + return DEFAULT_AUDIT_DECISION_LIMIT; + } + const parsed = parseStrictPositiveInteger(value); + if (parsed === undefined || parsed > MAX_AUDIT_DECISION_LIMIT) { + throw new Error( + `--limit must be between 1 and ${String(MAX_AUDIT_DECISION_LIMIT)} with --explain.`, + ); + } + return parsed; +} + +function parseAuditExecutionLimit(value: string | undefined): number { + const parsed = parseAuditDecisionLimit(value); + if (parsed > MAX_AUDIT_EXECUTION_LIMIT) { + throw new Error( + `--limit must be between 1 and ${String(MAX_AUDIT_EXECUTION_LIMIT)} for run discovery.`, + ); + } + return parsed; +} + function short(value: string | undefined, maxChars: number): string { if (!value) { return "-"; @@ -119,6 +152,16 @@ function isUnsupportedActivityMethodError(value: unknown): value is Error { ); } +function isUnsupportedRunInspectMethodError(value: unknown): value is Error { + return ( + value instanceof Error && + value.name === "GatewayClientRequestError" && + (value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" && + (value.message === "unknown method: audit.run.inspect" || + value.message === "missing scope: operator.admin") + ); +} + function hasMessageSpecificFilters(options: AuditListCommandOptions): boolean { return ( options.kind === "message" || options.direction !== undefined || options.channel !== undefined @@ -171,11 +214,279 @@ async function queryAuditActivity( } } +function unsupportedRunInspection( + selector: { runId: string } | { executionId: string }, +): AuditRunInspectResult { + const missingEvidence = ["identity.context"]; + return { + schemaVersion: 1, + run: { ...selector, status: "unknown" }, + identity: { + state: "unsupported", + reasonCode: "gateway_upgrade_required", + missingEvidence, + remediation: [ + { + code: "upgrade_gateway", + text: "Upgrade the Gateway, run the agent again, and repeat this exact-run inspection.", + }, + ], + }, + decisions: [], + coverage: { state: "unsupported", missingEvidence }, + }; +} + +async function queryAuditRunInspection( + params: AuditRunInspectParams, +): Promise { + try { + return await callGateway({ method: "audit.run.inspect", params }); + } catch (error) { + if (!isUnsupportedRunInspectMethodError(error)) { + throw error; + } + return unsupportedRunInspection( + typeof params.runId === "string" + ? { runId: params.runId } + : { executionId: params.executionId! }, + ); + } +} + +function safe(value: string | undefined): string { + return sanitizeTerminalText(value ?? "") || "-"; +} + +function principalText(principal: PrincipalRefV1): string { + return `${safe(principal.kind)} ${safe(principal.principalRef)} in ${safe(principal.domainRef)}`; +} + +function fieldLine(label: string, state: string, value?: string): string { + return ` ${label} [${safe(state)}]${value ? `: ${safe(value)}` : ""}`; +} + +const IDENTITY_FIELD_LABELS = [ + "Trust domain", + "Invoker", + "Ingress", + "Agent principal", + "Agent definition", + "Runtime instance", + "Represented subject", + "Sponsor", + "Applicable grants", + "Assurance", +] as const; + +function contextIdentityLines(context: ExecutionIdentityContextV1): string[] { + const grants = context.applicableGrants.map((grant) => `${grant.grantRef} [${grant.state}]`); + const assurance = context.assurance.map( + (item) => `${item.kind} ${item.evidenceRef} [${item.strength}]`, + ); + return [ + fieldLine( + "Trust domain", + context.trustDomain.state, + `${context.trustDomain.kind} ${context.trustDomain.domainRef}`, + ), + fieldLine( + "Invoker", + context.invoker.state, + context.invoker.principal ? principalText(context.invoker.principal) : undefined, + ), + fieldLine( + "Ingress", + context.ingress.state, + `${context.ingress.kind} at ${context.ingress.boundary}${ + context.ingress.sourceRef ? ` (${context.ingress.sourceRef})` : "" + }`, + ), + fieldLine("Agent principal", "present", principalText(context.agentPrincipal)), + fieldLine( + "Agent definition", + context.agentDefinition.state, + `${context.agentDefinition.definitionRef}${ + context.agentDefinition.revisionRef ? ` @ ${context.agentDefinition.revisionRef}` : "" + }`, + ), + fieldLine( + "Runtime instance", + context.runtimeInstance.state, + `${context.runtimeInstance.kind} ${context.runtimeInstance.runtimeRef}`, + ), + fieldLine( + "Represented subject", + context.representedSubject?.state ?? "absent", + context.representedSubject ? principalText(context.representedSubject.principal) : undefined, + ), + fieldLine( + "Sponsor", + context.sponsor?.state ?? "absent", + context.sponsor ? principalText(context.sponsor.principal) : undefined, + ), + fieldLine("Applicable grants", grants.length > 0 ? "present" : "absent", grants.join(", ")), + fieldLine("Assurance", assurance.length > 0 ? "present" : "absent", assurance.join(", ")), + ]; +} + +function unavailableIdentityLines(state: "unknown" | "unsupported"): string[] { + return IDENTITY_FIELD_LABELS.map((label) => fieldLine(label, state)); +} + +function decisionLines(receipt: DecisionReceiptV1): string[] { + return [ + ` ${safe(receipt.action.family)}.${safe(receipt.action.operation)}: ${safe(receipt.decision.outcome)}`, + ` Coverage: ${safe(receipt.enforcement.coverageState)}`, + ` Reason: ${safe(receipt.decision.reasonCode)}`, + ` Source: ${safe(receipt.source.owner)} at ${safe(receipt.source.decisionBoundary)}`, + ...(receipt.action.summary ? [` Summary: ${safe(receipt.action.summary)}`] : []), + ]; +} + +function formatAuditRunInspection(result: AuditRunInspectResult): string[] { + const selectorText = result.run.executionId + ? `Execution ${safe(result.run.executionId)}${result.run.runId ? ` (run ${safe(result.run.runId)})` : ""}` + : `Run ${safe(result.run.runId)}`; + const lines = [ + `${selectorText}: ${safe(result.run.status)} (${safe(result.coverage.state)})`, + "", + "Identity", + ]; + if (result.identity.state === "present") { + const identityLines = contextIdentityLines(result.identity.context); + lines.push( + ` Context: ${safe(result.identity.context.contextId)}`, + ` Created: ${timestampMsToIsoString(result.identity.context.createdAt) ?? String(result.identity.context.createdAt)}`, + ...identityLines.slice(0, 8), + "", + "Authority", + ...identityLines.slice(8), + "", + "Lineage", + result.identity.context.lineage + ? fieldLine( + "Parent", + "present", + result.identity.context.lineage.parentRunId ?? + result.identity.context.lineage.parentContextId ?? + `depth ${String(result.identity.context.lineage.depth)}`, + ) + : fieldLine("Parent", "absent"), + ); + } else if (result.identity.state === "ambiguous") { + lines.push( + ` Reason: ${safe(result.identity.reasonCode)}`, + ...result.identity.candidates.map( + (candidate) => + ` Candidate: ${safe(candidate.executionId)} (context ${safe(candidate.contextId)}, ${timestampMsToIsoString(candidate.createdAt) ?? String(candidate.createdAt)})`, + ), + "", + "Authority", + fieldLine("Selection", "unknown"), + "", + "Lineage", + fieldLine("Parent", "unknown"), + ); + } else { + lines.push( + ` Reason: ${safe(result.identity.reasonCode)}`, + ...unavailableIdentityLines(result.identity.state).slice(0, 8), + "", + "Authority", + ...unavailableIdentityLines(result.identity.state).slice(8), + "", + "Lineage", + fieldLine("Parent", result.identity.state), + ); + } + lines.push("", "Decisions"); + if (result.decisions.length === 0) { + lines.push(" none [absent]"); + } else { + for (const receipt of result.decisions) { + lines.push(...decisionLines(receipt)); + } + } + lines.push("", "Missing evidence"); + lines.push( + ...(result.coverage.missingEvidence.length > 0 + ? result.coverage.missingEvidence.map((item) => ` - ${safe(item)}`) + : [" none"]), + ); + const remediation = [ + ...(result.identity.state === "present" ? [] : result.identity.remediation), + ...result.decisions.flatMap((decision) => decision.remediation), + ]; + lines.push("", "Next steps"); + lines.push( + ...(remediation.length > 0 + ? [...new Map(remediation.map((item) => [item.code, item])).values()].map( + (item) => ` - ${safe(item.text)}`, + ) + : [" none"]), + ); + if (result.nextDecisionCursor) { + lines.push(` More decisions: --cursor ${safe(result.nextDecisionCursor)}`); + } + if (result.nextExecutionCursor) { + lines.push(` More executions: --cursor ${safe(result.nextExecutionCursor)}`); + } + return lines; +} + +function hasExplainIncompatibleFilters(options: AuditListCommandOptions): boolean { + return Boolean( + options.agentId || + options.sessionKey || + options.kind || + options.status || + options.direction || + options.channel || + options.after || + options.before, + ); +} + /** Query one stable page. JSON output is a bounded export with its next cursor. */ export async function auditListCommand( options: AuditListCommandOptions, runtime: RuntimeEnv, ): Promise { + if (options.explain) { + const runId = options.runId?.trim(); + const executionId = options.executionId?.trim(); + if (Boolean(runId) === Boolean(executionId)) { + throw new Error("Pass exactly one of --run or --execution with --explain."); + } + if (hasExplainIncompatibleFilters(options)) { + throw new Error( + "--explain accepts only --run or --execution, plus --limit, --cursor, and --json; remove activity-list filters.", + ); + } + const result = await queryAuditRunInspection({ + ...(executionId + ? { executionId } + : { + runId: runId!, + executionLimit: parseAuditExecutionLimit(options.limit), + ...(options.cursor ? { executionCursor: options.cursor } : {}), + }), + decisionLimit: parseAuditDecisionLimit(options.limit), + ...(options.cursor ? { decisionCursor: options.cursor } : {}), + }); + if (options.json) { + writeRuntimeJson(runtime, result); + return; + } + for (const line of formatAuditRunInspection(result)) { + runtime.log(line); + } + return; + } + if (options.executionId) { + throw new Error("--execution requires --explain."); + } validateAuditKind(options.kind); const after = parseAuditTimestamp(options.after, "--after"); const before = parseAuditTimestamp(options.before, "--before"); @@ -212,6 +523,11 @@ const testApi = { formatAuditRows, hasMessageSpecificFilters, isUnsupportedActivityMethodError, + isUnsupportedRunInspectMethodError, + formatAuditRunInspection, + hasExplainIncompatibleFilters, + parseAuditDecisionLimit, + parseAuditExecutionLimit, parseAuditLimit, parseAuditTimestamp, toLegacyAuditListParams, diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index c1aab25c1688..63480c4dd0d2 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -42,6 +42,8 @@ export const CORE_FIELD_HELP: Record = { "Bounded metadata-only audit history for operator review. Run and tool records are enabled by default; message lifecycle metadata is a separate privacy-sensitive opt-in. The background writer is best-effort rather than a lossless compliance archive.", "logging.audit.enabled": "Records new run, tool, and enabled message audit events. Default: true. Disabling event inserts does not immediately delete existing records; retained rows remain queryable until they expire.", + "logging.audit.executionIdentity": + "Retains bounded execution-identity attribution for exact-run inspection. Default: false. Requires logging.audit.enabled; restart the Gateway after changing it.", "logging.audit.messages": 'Controls content-free message lifecycle records: "off" (default), "direct" for known direct conversations only, or "all" for direct, group, channel, and unknown conversation kinds. Both logging.audit.enabled and logging.audit.messages are startup-scoped; restart the Gateway after changing either setting.', diagnostics: diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 2767e6ee7769..384b0db27d11 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -28,6 +28,7 @@ export const FIELD_LABELS: Record = { "wizard.securityAcknowledgedAt": "Wizard Security Acknowledgement Timestamp", "logging.audit": "Audit Ledger", "logging.audit.enabled": "Audit Ledger Enabled", + "logging.audit.executionIdentity": "Execution Identity Audit", "logging.audit.messages": "Message Audit Scope", diagnostics: "Diagnostics", "diagnostics.otel": "OpenTelemetry", diff --git a/src/config/sessions/main-session-recovery.types.ts b/src/config/sessions/main-session-recovery.types.ts index 42589f6577ef..abee619569e5 100644 --- a/src/config/sessions/main-session-recovery.types.ts +++ b/src/config/sessions/main-session-recovery.types.ts @@ -5,6 +5,14 @@ export type MainRestartRecoveryState = { revision: number; /** Attempts charged when their reservation is persisted, before dispatch. */ chargedAttempts: number; + /** Private safe token for one recovered outer turn; raw identity refs never enter session state. */ + executionIdentity?: { + tokenVersion: 1; + contextId: string; + executionId: string; + runId: string; + createdAt: number; + }; reservation?: { runId: string; attempt: number; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index a97fe0817d55..f0eed479a880 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -324,6 +324,11 @@ export type AuditConfig = { * records stay readable until they expire. */ enabled?: boolean; + /** + * Retain bounded execution-identity attribution for exact-run inspection. + * Default: false. Requires the audit ledger and takes effect after Gateway restart. + */ + executionIdentity?: boolean; /** * Record content-free message lifecycle metadata. `direct` records only * known direct conversations; `all` also records group, channel, and diff --git a/src/config/zod-schema.audit-execution-identity.test.ts b/src/config/zod-schema.audit-execution-identity.test.ts new file mode 100644 index 000000000000..293626257f09 --- /dev/null +++ b/src/config/zod-schema.audit-execution-identity.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("logging.audit.executionIdentity", () => { + it("accepts only the explicit boolean config surface", () => { + expect( + OpenClawSchema.safeParse({ + logging: { audit: { executionIdentity: true } }, + }).success, + ).toBe(true); + expect( + OpenClawSchema.safeParse({ + logging: { audit: { executionIdentity: false } }, + }).success, + ).toBe(true); + expect( + OpenClawSchema.safeParse({ + logging: { audit: { executionIdentity: "true" } }, + }).success, + ).toBe(false); + expect( + OpenClawSchema.safeParse({ + logging: { audit: { execution_identity: true } }, + }).success, + ).toBe(false); + }); +}); diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index bf780dcf16f8..821518b02b02 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -117,6 +117,7 @@ export const OpenClawSchemaShape = { audit: z .strictObject({ enabled: z.boolean().optional(), + executionIdentity: z.boolean().optional(), messages: z.union([z.literal("off"), z.literal("direct"), z.literal("all")]).optional(), }) .optional(), diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 3155ab920dab..cb94a539f027 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -8,10 +8,11 @@ import type { SessionScope } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; const agentCommand = vi.fn(); +const localAgentCommand = vi.fn(); vi.mock("../commands/agent.js", () => ({ - agentCommand, - agentCommandFromIngress: agentCommand, + agentCommand: localAgentCommand, + agentCommandFromSystem: agentCommand, })); const { runBootOnce } = await import("./boot.js"); @@ -131,6 +132,7 @@ describe("runBootOnce", () => { }), ).resolves.toEqual({ status: "ran" }); expect(agentCommand).toHaveBeenCalledTimes(1); + expect(localAgentCommand).not.toHaveBeenCalled(); call = requireAgentCall(); }, ); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index 448bfcebe4b8..ea112aba1b7c 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -11,7 +11,7 @@ import { } from "../agents/internal-runtime-context.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { CliDeps } from "../cli/deps.types.js"; -import { agentCommand } from "../commands/agent.js"; +import { agentCommandFromSystem } from "../commands/agent.js"; import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, @@ -150,7 +150,7 @@ export async function runBootOnce(params: { // same session key. Refs #53732. setBootEchoContextForSession(sessionKey, message); try { - await agentCommand( + await agentCommandFromSystem( { message, sessionKey, @@ -158,6 +158,7 @@ export async function runBootOnce(params: { deliver: false, suppressPromptPersistence: true, }, + { boundary: "gateway.boot" }, bootRuntime, params.deps, ); diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index b2ad9f359853..fae08962bd34 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -43,6 +43,7 @@ describe("method scope resolution", () => { ["sessions.resolve", ["operator.read"]], ["tasks.list", ["operator.read"]], ["audit.activity.list", ["operator.read"]], + ["audit.run.inspect", ["operator.read"]], ["audit.list", ["operator.read"]], ["users.list", ["operator.read"]], ["users.self", ["operator.write"]], @@ -636,6 +637,16 @@ describe("operator scope authorization", () => { expect(authorizeOperatorScopesForMethod(method, scopes)).toEqual(expected); }); + it("authorizes execution identity inspection with operator.read and rejects unrelated scopes", () => { + expect(authorizeOperatorScopesForMethod("audit.run.inspect", ["operator.read"])).toEqual({ + allowed: true, + }); + expect(authorizeOperatorScopesForMethod("audit.run.inspect", ["operator.approvals"])).toEqual({ + allowed: false, + missingScope: "operator.read", + }); + }); + it("requires operator.write for write methods", () => { expect(authorizeOperatorScopesForMethod("send", ["operator.read"])).toEqual({ allowed: false, diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 466a28b6878c..c12e4193a734 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -38,6 +38,7 @@ const CURRENT_TRAIN_METHODS = [ "agents.workspace.get", "audit.list", "audit.activity.list", + "audit.run.inspect", "board.widget.appView", "tts.speak", "environments.list", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 81da1b4c836b..4a835d87335f 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -484,6 +484,8 @@ const CORE_GATEWAY_METHOD_SPECS = [ // Additive task recovery RPCs append so older advertised method indices stay stable. ["tasks.retry", "tasks", "operator.write", "2026.7"], ["tasks.dismiss", "tasks", "operator.write", "2026.7"], + // Additive audit inspection appends so older advertised method indices stay stable. + ["audit.run.inspect", "audit", "operator.read", "2026.7"], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 245940dfeb6d..efac94b772b5 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-30)).toEqual([ + expect(listGatewayMethods().slice(-31)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -97,6 +97,7 @@ describe("listGatewayMethods", () => { "hooks.status", "tasks.retry", "tasks.dismiss", + "audit.run.inspect", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -134,6 +135,8 @@ describe("listGatewayMethods", () => { it("advertises the versioned activity audit method", () => { expect(listGatewayMethods()).toContain("audit.activity.list"); expect(coreGatewayHandlers["audit.activity.list"]).toBeTypeOf("function"); + expect(listGatewayMethods()).toContain("audit.run.inspect"); + expect(coreGatewayHandlers["audit.run.inspect"]).toBeTypeOf("function"); }); it("does not advertise hidden core handlers", () => { @@ -162,7 +165,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-37)).toEqual([ + expect(coreMethods.slice(-38)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -200,9 +203,11 @@ describe("listGatewayMethods", () => { "hooks.status", "tasks.retry", "tasks.dismiss", + "audit.run.inspect", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); + expect(methods.indexOf("audit.run.inspect")).toBe(methods.indexOf("tasks.dismiss") + 1); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods/agent-request-preflight.test.ts b/src/gateway/server-methods/agent-request-preflight.test.ts index 064ec4e44705..cc72308eba60 100644 --- a/src/gateway/server-methods/agent-request-preflight.test.ts +++ b/src/gateway/server-methods/agent-request-preflight.test.ts @@ -358,7 +358,11 @@ describe("agent request Swarm preflight", () => { }); describe("agent request restart recovery preflight", () => { - function runRestartRecoveryPreflight(backend: boolean, sourceTool: string) { + function runRestartRecoveryPreflight( + backend: boolean, + sourceTool: string, + internalExecutionIdentityRetry?: boolean, + ) { const respond = vi.fn(); const result = prepareAgentRequestPreflight({ params: { @@ -366,6 +370,7 @@ describe("agent request restart recovery preflight", () => { idempotencyKey: "restart-recovery-run", forceRestartSafeTools: true, forceCodeModeTools: true, + ...(internalExecutionIdentityRetry !== undefined ? { internalExecutionIdentityRetry } : {}), inputProvenance: { kind: "internal_system", sourceSessionKey: "agent:main:main", @@ -385,12 +390,26 @@ describe("agent request restart recovery preflight", () => { } it("accepts the Code Mode override only for backend restart recovery", () => { - const accepted = runRestartRecoveryPreflight(true, "main_session_restart_recovery"); + const accepted = runRestartRecoveryPreflight(true, "main_session_restart_recovery", true); expect(accepted.result).toBeDefined(); expect(accepted.respond).not.toHaveBeenCalled(); }); + it("rejects private execution retry mode outside backend restart recovery", () => { + const rejected = runRestartRecoveryPreflight(false, "main_session_restart_recovery", true); + + expect(rejected.result).toBeUndefined(); + expect(rejected.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("execution identity retry mode"), + }), + ); + }); + it.each([ { backend: false, sourceTool: "main_session_restart_recovery" }, { backend: true, sourceTool: "other_internal_source" }, diff --git a/src/gateway/server-methods/agent-request-preflight.ts b/src/gateway/server-methods/agent-request-preflight.ts index aad3608049cd..096c636f9087 100644 --- a/src/gateway/server-methods/agent-request-preflight.ts +++ b/src/gateway/server-methods/agent-request-preflight.ts @@ -237,6 +237,17 @@ export function prepareAgentRequestPreflight( const inputProvenance = normalizeInputProvenance(request.inputProvenance); const isRestartRecoveryResumeRun = canUseInternalRuntimeHandoff && isMainSessionRestartRecoveryInputProvenance(inputProvenance); + if (request.internalExecutionIdentityRetry !== undefined && !isRestartRecoveryResumeRun) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "internal execution identity retry mode is reserved for main-session restart recovery.", + ), + ); + return undefined; + } if (request.forceCodeModeTools === true && !isRestartRecoveryResumeRun) { params.respond( false, diff --git a/src/gateway/server-methods/agent-request-types.ts b/src/gateway/server-methods/agent-request-types.ts index f6cf165631f2..4b6721b59821 100644 --- a/src/gateway/server-methods/agent-request-types.ts +++ b/src/gateway/server-methods/agent-request-types.ts @@ -37,6 +37,7 @@ export type AgentRunRequest = { bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; acpTurnSource?: "manual_spawn"; internalRuntimeHandoffId?: string; + internalExecutionIdentityRetry?: boolean; execApprovalFollowupExpectedSessionId?: string; internalEvents?: AgentInternalEvent[]; suppressPromptPersistence?: boolean; diff --git a/src/gateway/server-methods/agent-restart-recovery-context.test.ts b/src/gateway/server-methods/agent-restart-recovery-context.test.ts index 49f46d2858aa..49c540d2c303 100644 --- a/src/gateway/server-methods/agent-restart-recovery-context.test.ts +++ b/src/gateway/server-methods/agent-restart-recovery-context.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveAgentRestartRecoveryChannelContext } from "./agent-restart-recovery-context.js"; +import { + resolveAgentRestartRecoveryChannelContext, + resolveAgentRestartRecoveryExecutionIdentityAdmission, +} from "./agent-restart-recovery-context.js"; const matchingParams = { canUseInternalRuntimeHandoff: true, @@ -83,3 +86,96 @@ describe("resolveAgentRestartRecoveryChannelContext", () => { ).toBeUndefined(); }); }); + +describe("resolveAgentRestartRecoveryExecutionIdentityAdmission", () => { + const token = { + tokenVersion: 1 as const, + contextId: "context-1", + executionId: "execution-1", + runId: "recovery-run-1", + createdAt: 1, + }; + + it("rehydrates the durable token for capture and exact retry without runtime reconstruction", () => { + const sessionEntry = { + ...matchingParams.sessionEntry, + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 1, + chargedAttempts: 1, + executionIdentity: token, + }, + }; + expect( + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: true, + isRestartRecoveryResumeRun: true, + retryOnly: false, + runId: token.runId, + sessionEntry, + }), + ).toEqual({ token, retryOnly: false }); + expect( + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: true, + isRestartRecoveryResumeRun: true, + retryOnly: true, + runId: token.runId, + sessionEntry, + }), + ).toEqual({ token, retryOnly: true }); + }); + + it("returns no token for ordinary runs and refuses lost recovery evidence", () => { + expect( + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: true, + isRestartRecoveryResumeRun: false, + retryOnly: false, + runId: token.runId, + sessionEntry: matchingParams.sessionEntry, + }), + ).toBeUndefined(); + expect(() => + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: true, + isRestartRecoveryResumeRun: true, + retryOnly: true, + runId: token.runId, + sessionEntry: matchingParams.sessionEntry, + }), + ).toThrow("token is unavailable"); + }); + + it("omits retained recovery identity while collection is disabled", () => { + const sessionEntry = { + ...matchingParams.sessionEntry, + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 1, + chargedAttempts: 1, + executionIdentity: token, + }, + }; + expect( + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: false, + isRestartRecoveryResumeRun: true, + retryOnly: true, + runId: token.runId, + sessionEntry, + }), + ).toBeUndefined(); + }); + + it("refuses an enabled recovery without an explicit capture or retry mode", () => { + expect(() => + resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: true, + isRestartRecoveryResumeRun: true, + runId: token.runId, + sessionEntry: matchingParams.sessionEntry, + }), + ).toThrow("admission mode is unavailable"); + }); +}); diff --git a/src/gateway/server-methods/agent-restart-recovery-context.ts b/src/gateway/server-methods/agent-restart-recovery-context.ts index 87c7caa7ee70..80bf2ed31d63 100644 --- a/src/gateway/server-methods/agent-restart-recovery-context.ts +++ b/src/gateway/server-methods/agent-restart-recovery-context.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SessionEntry } from "../../config/sessions.js"; +import { parseExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js"; +import type { InternalSessionEntry, SessionEntry } from "../../config/sessions.js"; import { resolveRestartRecoveryChannelAuthority } from "../../config/sessions/restart-recovery-state.js"; type AgentRestartRecoveryChannelContext = { @@ -12,6 +13,32 @@ type AgentRestartRecoveryChannelContext = { sourceTurnId: string; }; +/** Resolve only the private token durably owned by the admitted recovery cycle. */ +export function resolveAgentRestartRecoveryExecutionIdentityAdmission(params: { + collectionEnabled: boolean; + isRestartRecoveryResumeRun: boolean; + retryOnly?: boolean; + runId: string; + sessionEntry?: SessionEntry; +}) { + if (!params.isRestartRecoveryResumeRun || !params.collectionEnabled) { + return undefined; + } + if (params.retryOnly === undefined) { + throw new Error("restart recovery execution identity admission mode is unavailable"); + } + const stored = (params.sessionEntry as InternalSessionEntry | undefined)?.mainRestartRecovery + ?.executionIdentity; + if (!stored) { + throw new Error("restart recovery execution identity token is unavailable"); + } + const token = parseExecutionIdentityAdmissionToken(stored); + if (!params.retryOnly && token.runId !== params.runId) { + throw new Error("restart recovery execution identity token disagrees with the admitted run"); + } + return { token, retryOnly: params.retryOnly }; +} + /** Rehydrates durable channel authority only for the exact host-owned recovery run. */ export function resolveAgentRestartRecoveryChannelContext(params: { canUseInternalRuntimeHandoff: boolean; diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index a98d5cee27d8..d6bf0b94f9f0 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -21,6 +21,7 @@ import { } from "../../agents/main-session-recovery-store.js"; import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; +import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js"; import { setChannelSourceTurnId, setChannelSourceTurnSameThreadRequired, @@ -51,7 +52,10 @@ import { type RestoredCronContinuation, } from "./agent-handler-helpers.js"; import type { AgentRunRequest } from "./agent-request-types.js"; -import { resolveAgentRestartRecoveryChannelContext } from "./agent-restart-recovery-context.js"; +import { + resolveAgentRestartRecoveryChannelContext, + resolveAgentRestartRecoveryExecutionIdentityAdmission, +} from "./agent-restart-recovery-context.js"; import type { PreparedAgentRunDispatch } from "./agent-run-admission-phase.js"; import { resolveAbortedAgentStopReason, @@ -325,6 +329,13 @@ export function startAgentRunExecution(params: { params.client.internal.runtimePluginToolGrant?.pluginId ? params.client.internal.runtimePluginToolGrant : undefined; + const executionIdentityAdmission = resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: isExecutionIdentityCollectionEnabled(params.cfg), + isRestartRecoveryResumeRun: params.isRestartRecoveryResumeRun, + retryOnly: params.request.internalExecutionIdentityRetry, + runId: params.runId, + sessionEntry: params.sessionEntry, + }); const restartRecoveryChannelContext = resolveAgentRestartRecoveryChannelContext({ canUseInternalRuntimeHandoff: params.canUseInternalRuntimeHandoff, expectedExistingSessionId: params.request.expectedExistingSessionId, @@ -421,6 +432,7 @@ export function startAgentRunExecution(params: { swarmOutputSchema: params.request.swarmOutputSchema, forceRestartSafeTools: params.request.forceRestartSafeTools, forceCodeModeTools: params.request.forceCodeModeTools, + ...(executionIdentityAdmission ? { executionIdentityAdmission } : {}), internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls, internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText, suppressPromptPersistence: diff --git a/src/gateway/server-methods/audit.test.ts b/src/gateway/server-methods/audit.test.ts index b50866dfd862..9fa95a9d6abd 100644 --- a/src/gateway/server-methods/audit.test.ts +++ b/src/gateway/server-methods/audit.test.ts @@ -2,13 +2,20 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { auditHandlers, testApi } from "./audit.js"; -const listAuditEvents = vi.hoisted(() => vi.fn()); +const { inspectExecutionIdentityRun, listAuditEvents } = vi.hoisted(() => ({ + inspectExecutionIdentityRun: vi.fn(), + listAuditEvents: vi.fn(), +})); vi.mock("../../audit/audit-event-store.js", () => ({ listAuditEvents })); +vi.mock("../../audit/execution-identity-context.js", () => ({ inspectExecutionIdentityRun })); const accountRef = `hmac-sha256:v1:${"a".repeat(32)}:${"b".repeat(64)}`; -async function runAuditHandler(method: "audit.activity.list" | "audit.list", params: object) { +async function runAuditHandler( + method: "audit.activity.list" | "audit.list" | "audit.run.inspect", + params: object, +) { const respond = vi.fn(); await expectDefined( auditHandlers[method], @@ -40,6 +47,19 @@ describe("audit gateway methods", () => { ], nextCursor: 10, }); + inspectExecutionIdentityRun.mockReset(); + inspectExecutionIdentityRun.mockReturnValue({ + schemaVersion: 1, + run: { runId: "run-1", status: "unknown" }, + identity: { + state: "unknown", + reasonCode: "run_not_found", + missingEvidence: ["run.record"], + remediation: [{ code: "verify_run_id", text: "Verify the exact run id." }], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["run.record"] }, + }); }); it("preserves the exact shipped audit.list request and result shape", async () => { @@ -231,4 +251,46 @@ describe("audit gateway methods", () => { ); }, ); + + it("projects bounded run discovery and exact execution selection", async () => { + await runAuditHandler("audit.run.inspect", { + runId: "run-1", + executionCursor: " 2 ", + executionLimit: 10, + decisionCursor: " 1 ", + decisionLimit: 25, + }); + expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({ + runId: "run-1", + executionOffset: 2, + executionLimit: 10, + decisionOffset: 1, + decisionLimit: 25, + }); + + await runAuditHandler("audit.run.inspect", { + executionId: "execution-1", + decisionLimit: 20, + }); + expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({ + executionId: "execution-1", + decisionLimit: 20, + }); + }); + + it("rejects malformed run inspection before storage access", async () => { + expect( + await runAuditHandler("audit.run.inspect", { runId: "", extra: true }), + ).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + expect( + await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor: "0" }), + ).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + expect( + await runAuditHandler("audit.run.inspect", { + runId: "run-1", + executionId: "execution-1", + }), + ).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + expect(inspectExecutionIdentityRun).not.toHaveBeenCalled(); + }); }); diff --git a/src/gateway/server-methods/audit.ts b/src/gateway/server-methods/audit.ts index 314608a1d484..f08ba5008db4 100644 --- a/src/gateway/server-methods/audit.ts +++ b/src/gateway/server-methods/audit.ts @@ -7,6 +7,7 @@ import { type AuditEvent, validateAuditActivityListParams, validateAuditListParams, + validateAuditRunInspectParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listAuditEvents } from "../../audit/audit-event-store.js"; import type { @@ -14,13 +15,14 @@ import type { AuditEventRecord, ToolActionAuditEventRecord, } from "../../audit/audit-event-types.js"; +import { inspectExecutionIdentityRun } from "../../audit/execution-identity-context.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; const DEFAULT_AUDIT_LIST_LIMIT = 100; const MAX_AUDIT_LIST_LIMIT = 500; -function parseAuditCursor(cursor: string | undefined): number | undefined | null { +function parsePositiveCursor(cursor: string | undefined): number | undefined | null { if (cursor === undefined) { return undefined; } @@ -32,6 +34,10 @@ function parseAuditCursor(cursor: string | undefined): number | undefined | null return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } +const parseAuditCursor = parsePositiveCursor; +const parseDecisionCursor = parsePositiveCursor; +const parseExecutionCursor = parsePositiveCursor; + /** Preserve the shipped audit.list result shape for run/tool-only clients. */ function mapLegacyAuditEvent( event: AgentRunAuditEventRecord | ToolActionAuditEventRecord, @@ -156,6 +162,42 @@ export const auditHandlers: GatewayRequestHandlers = { ...(page.nextCursor !== undefined ? { nextCursor: String(page.nextCursor) } : {}), }); }, + "audit.run.inspect": ({ params, respond }) => { + if (!assertValidParams(params, validateAuditRunInspectParams, "audit.run.inspect", respond)) { + return; + } + const decisionOffset = parseDecisionCursor(params.decisionCursor); + const executionOffset = + typeof params.runId === "string" ? parseExecutionCursor(params.executionCursor) : undefined; + if (decisionOffset === null || executionOffset === null) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid audit.run.inspect cursor"), + ); + return; + } + respond( + true, + inspectExecutionIdentityRun({ + ...(typeof params.runId === "string" + ? { + runId: params.runId, + ...(executionOffset !== undefined ? { executionOffset } : {}), + executionLimit: params.executionLimit ?? 50, + } + : { executionId: params.executionId! }), + ...(decisionOffset !== undefined ? { decisionOffset } : {}), + decisionLimit: params.decisionLimit ?? 50, + }), + ); + }, }; -export const testApi = { mapAuditActivityEvent, mapLegacyAuditEvent, parseAuditCursor }; +export const testApi = { + mapAuditActivityEvent, + mapLegacyAuditEvent, + parseAuditCursor, + parseDecisionCursor, + parseExecutionCursor, +}; diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index be60e1ad05ea..a0d960772dfa 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -1898,6 +1898,25 @@ describe("agent request events", () => { expect(optsRecord.runId).toBe(optsRecord.sessionId); }); + it("preserves session-scoped routing across two distinct agent.request turns", async () => { + loadSessionEntryMock.mockReturnValue( + buildSessionLookup("agent:main:node-repeat", { sessionId: "node-session-1" }), + ); + + for (const message of ["first turn", "second turn"]) { + await handleNodeEvent(buildCtx(), "node-repeat", { + event: "agent.request", + payloadJSON: JSON.stringify({ message, sessionKey: "agent:main:node-repeat" }), + }); + } + + expect(agentCommandMock).toHaveBeenCalledTimes(2); + const calls = agentCommandMock.mock.calls.map(([opts]) => opts as Record); + expect(calls.map((opts) => opts.message)).toEqual(["first turn", "second turn"]); + expect(calls.map((opts) => opts.runId)).toEqual(["node-session-1", "node-session-1"]); + expect(calls.map((opts) => opts.sessionId)).toEqual(["node-session-1", "node-session-1"]); + }); + it("passes supportsInlineImages false for text-only node-session models", async () => { const ctx = buildCtx(); ctx.loadGatewayModelCatalog = async () => [ diff --git a/src/gateway/server-runtime-subscriptions.test.ts b/src/gateway/server-runtime-subscriptions.test.ts index a899e21c6684..b56e8100435a 100644 --- a/src/gateway/server-runtime-subscriptions.test.ts +++ b/src/gateway/server-runtime-subscriptions.test.ts @@ -1,5 +1,10 @@ // Tests for gateway runtime subscription wiring. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + configureExecutionIdentityAdmissionSink, + enqueueExecutionIdentityContextAtAdmission, + hasExecutionIdentityAdmissionSink, +} from "../audit/execution-identity-admission.js"; import { emitAgentAuditEvent, emitAgentEvent, @@ -49,6 +54,7 @@ const auditTestState = vi.hoisted(() => ({ messageMode: "off" as "off" | "direct" | "all", created: 0, recorded: 0, + identityRecorded: 0, stopped: 0, })); const agentEventHandlerMocks = vi.hoisted(() => ({ @@ -73,6 +79,10 @@ vi.mock("../audit/audit-recorder.js", () => ({ }), recordTool: vi.fn(), recordMessage: vi.fn(), + recordExecutionIdentity: vi.fn(() => { + auditTestState.identityRecorded += 1; + return true; + }), stop: vi.fn(async () => { auditTestState.stopped += 1; }), @@ -144,6 +154,7 @@ describe("startGatewayEventSubscriptions", () => { auditTestState.messageMode = "off"; auditTestState.created = 0; auditTestState.recorded = 0; + auditTestState.identityRecorded = 0; auditTestState.stopped = 0; transcriptBroadcastMocks.useActualHandler = false; transcriptBroadcastMocks.readMessageCount.mockReset(); @@ -161,6 +172,7 @@ describe("startGatewayEventSubscriptions", () => { void unsubs?.taskUnsub(); resetAgentEventsForTest(); resetTaskRegistryForTests({ persist: false }); + configureExecutionIdentityAdmissionSink(() => false)(); }); it("records audit events by default and stops the recorder on unsubscribe", async () => { @@ -173,8 +185,22 @@ describe("startGatewayEventSubscriptions", () => { data: { phase: "start", startedAt: 1_000 }, }); expect(auditTestState.recorded).toBe(1); + expect(hasExecutionIdentityAdmissionSink()).toBe(true); + expect( + enqueueExecutionIdentityContextAtAdmission( + { + runId: "gateway-admission", + agentId: "main", + ingress: { kind: "system", boundary: "gateway.boot", state: "present" }, + runtime: { kind: "embedded" }, + }, + { enabled: true, runtimeInstanceId: "runtime-1" }, + )?.accepted, + ).toBe(true); + expect(auditTestState.identityRecorded).toBe(1); await unsubs.agentUnsub(); expect(auditTestState.stopped).toBe(1); + expect(hasExecutionIdentityAdmissionSink()).toBe(false); }); it("keeps retention maintenance but creates no producers when audit.enabled is false", async () => { diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index 614844d3eeb7..fc8230d376ad 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -2,6 +2,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isAuditLedgerEnabled, resolveAuditMessageMode } from "../audit/audit-config.js"; import { createAuditEventRecorder } from "../audit/audit-recorder.js"; +import { configureExecutionIdentityAdmissionSink } from "../audit/execution-identity-admission.js"; import { onTrustedMessageAuditEvent } from "../audit/message-audit-events.js"; import { getRuntimeConfig } from "../config/io.js"; import { onAgentAuditEvent, onAgentRuntimeEvent } from "../infra/agent-events.js"; @@ -71,6 +72,9 @@ export function startGatewayEventSubscriptions(params: { const auditRecorder = createAuditEventRecorder({ messageMode: auditEnabled ? auditMessageMode : "off", }); + const clearExecutionIdentityAdmissionSink = configureExecutionIdentityAdmissionSink( + auditRecorder.recordExecutionIdentity, + ); const sessionObserver = createSessionObserver({ getConfig: getRuntimeConfig, subscribers: params.sessionMessageSubscribers, @@ -322,6 +326,7 @@ export function startGatewayEventSubscriptions(params: { unsubscribePrivateAuditEvents?.(); unsubscribeToolAuditEvents?.(); unsubscribeMessageAuditEvents?.(); + clearExecutionIdentityAdmissionSink(); await agentEventHandlerLoader .peek() ?.then((handler) => handler.dispose()) diff --git a/src/plugin-sdk/agent-runtime-ingress-contract.test.ts b/src/plugin-sdk/agent-runtime-ingress-contract.test.ts new file mode 100644 index 000000000000..933a44ced109 --- /dev/null +++ b/src/plugin-sdk/agent-runtime-ingress-contract.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { agentCommandFromIngress } from "./agent-runtime.js"; + +type PublicIngressOptions = Parameters[0]; +const optionalRunIdCaller: PublicIngressOptions = { + message: "hello", + sessionKey: "agent:main:plugin-session", + allowModelOverride: false, +}; +const privateRecoveryCorrelationIsHidden: "executionIdentityAdmission" extends keyof PublicIngressOptions + ? false + : true = true; + +describe("public agent ingress correlation contract", () => { + it("keeps runId optional and private execution recovery state unavailable", () => { + expect(optionalRunIdCaller).not.toHaveProperty("runId"); + expect(privateRecoveryCorrelationIsHidden).toBe(true); + }); +}); diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 120b5a3aabf5..58f3b1722516 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -5,9 +5,14 @@ import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js"; // v5 records durable cloud-worker result refs on pending workspace fences. export const OPENCLAW_STATE_SCHEMA_VERSION = 6; export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3; +// Privacy-sensitive feature tables remain absent even in fresh databases until +// their feature-local first write. The canonical SQL still owns their shape. +export const FIRST_USE_STATE_TABLES = ["execution_identity_contexts"] as const; +export const FIRST_USE_STATE_INDEXES = ["execution_identity_contexts_run_created_idx"] as const; // Added after v6 shipped. These tables stay optional until their feature-local // lazy ensures run; fold them into the next natural schema-version bump. export const LAZY_ADDITIVE_STATE_TABLES = [ + ...FIRST_USE_STATE_TABLES, "model_catalog_remote", "sidebar_sections", "skill_workshop_proposal_events", @@ -15,6 +20,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [ "skill_workshop_proposal_rollbacks", "skill_workshop_proposals", ] as const; +export const LAZY_ADDITIVE_STATE_INDEXES = [...FIRST_USE_STATE_INDEXES] as const; /** Maximum time one synchronous SQLite call may wait for a lock. */ export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000; /** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */ diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index e5a314311344..4ea181fb89d4 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -640,6 +640,16 @@ export interface ExecApprovalsConfig { updated_at_ms: number; } +export interface ExecutionIdentityContexts { + context_bytes: number; + context_id: string; + context_json: string; + coverage_state: string; + created_at: number; + execution_id: string; + run_id: string; +} + export interface FleetCells { container_name: string; created_at_ms: number; @@ -1588,6 +1598,7 @@ export interface DB { diagnostic_events: DiagnosticEvents; diagnostic_stability_bundles: DiagnosticStabilityBundles; exec_approvals_config: ExecApprovalsConfig; + execution_identity_contexts: ExecutionIdentityContexts; fleet_cells: FleetCells; flow_runs: FlowRuns; gateway_boot_lifecycle: GatewayBootLifecycle; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 3bfd30d973f1..15a2364b5e20 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -19,6 +19,7 @@ import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js"; import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { VERSION } from "../version.js"; +import { FIRST_USE_STATE_TABLES } from "./openclaw-state-db-contract.js"; import { findOpenClawStateDatabaseSchemaMigrationRequiredError, OpenClawStateDatabaseSchemaMigrationRequiredError, @@ -58,6 +59,16 @@ function createTempStateDir(): string { return makeTempDir(stateDbTempDirs, "openclaw-state-db-"); } +function createInitialStateSchemaShape() { + const shape = createSqliteSchemaShapeFromSql( + new URL("./openclaw-state-schema.sql", import.meta.url), + ); + for (const tableName of FIRST_USE_STATE_TABLES) { + delete shape[tableName]; + } + return shape; +} + function expectStateSchemaMigrationRequired( run: () => unknown, expected: { @@ -1076,9 +1087,12 @@ describe("openclaw state database", () => { env: { OPENCLAW_STATE_DIR: stateDir }, }); - expect(collectSqliteSchemaShape(database.db)).toEqual( - createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)), - ); + expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape()); + expect( + database.db + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"), + ).toBeUndefined(); expect(database.path).toBe(path.join(stateDir, "state", "openclaw.sqlite")); expect( database.db @@ -1187,9 +1201,7 @@ INSERT INTO device_identities VALUES ( updated_at_ms: 20, }); expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); - expect(collectSqliteSchemaShape(database.db)).toEqual( - createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)), - ); + expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape()); }); it("adopts a canonical native PortGuardian seed without losing records", () => { @@ -1226,9 +1238,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're timestamp: 42.5, }); expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); - expect(collectSqliteSchemaShape(database.db)).toEqual( - createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)), - ); + expect(collectSqliteSchemaShape(database.db)).toEqual(createInitialStateSchemaShape()); }); it("doctor migrates existing APNs tombstone tables to STRICT without losing rows", () => { @@ -3404,9 +3414,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const rootDir = createTempStateDir(); const moduleUrl = new URL("./openclaw-state-db.ts", import.meta.url).href; const databasePaths = runConcurrentSchemaProbe({ mode: "upgrade", moduleUrl, rootDir }); - const expectedShape = createSqliteSchemaShapeFromSql( - new URL("./openclaw-state-schema.sql", import.meta.url), - ); + const expectedShape = createInitialStateSchemaShape(); const { DatabaseSync } = requireNodeSqlite(); expect(databasePaths).toHaveLength(1); @@ -3438,9 +3446,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const rootDir = createTempStateDir(); const moduleUrl = new URL("./openclaw-state-db.ts", import.meta.url).href; const databasePaths = runConcurrentSchemaProbe({ mode: "fresh", moduleUrl, rootDir }); - const expectedShape = createSqliteSchemaShapeFromSql( - new URL("./openclaw-state-schema.sql", import.meta.url), - ); + const expectedShape = createInitialStateSchemaShape(); const { DatabaseSync } = requireNodeSqlite(); expect(databasePaths).toHaveLength(1); diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index e1ef9cf3252d..d288ea1cd1c6 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -45,7 +45,10 @@ import { } from "./openclaw-quarantine-store.js"; import { repairAuditEventsSchema } from "./openclaw-state-db-audit-migration.js"; import { + FIRST_USE_STATE_INDEXES, + FIRST_USE_STATE_TABLES, OPENCLAW_DATABASE_SCHEMA_DOCS_URL, + LAZY_ADDITIVE_STATE_INDEXES, LAZY_ADDITIVE_STATE_TABLES, OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, OPENCLAW_STATE_SCHEMA_VERSION, @@ -196,19 +199,19 @@ export function assertOpenClawStateDatabaseFreshOpenAllowed( type OpenClawStateMetadataDatabase = Pick; const stateDbLog = createSubsystemLogger("state/db"); -function executeCanonicalStateSchema( - database: DatabaseSync, - options: { includeLazyAdditiveTables: boolean }, -): void { - if (options.includeLazyAdditiveTables) { - database.exec(OPENCLAW_STATE_SCHEMA_SQL); - return; - } - - // Current-version databases may lack lazy cache tables, but the remaining - // canonical DDL must still run so doctor can restore indexes and triggers. +function canonicalStateSchemaForRuntime(options: { + includeVersionLazyAdditiveTables: boolean; +}): string { + // Current-version databases may lack lazy additive tables. First-use tables + // remain absent on every schema path so only their feature owner can create them. let eagerSchema = OPENCLAW_STATE_SCHEMA_SQL; - for (const tableName of LAZY_ADDITIVE_STATE_TABLES) { + const omittedTables = options.includeVersionLazyAdditiveTables + ? FIRST_USE_STATE_TABLES + : LAZY_ADDITIVE_STATE_TABLES; + const omittedIndexes = options.includeVersionLazyAdditiveTables + ? FIRST_USE_STATE_INDEXES + : LAZY_ADDITIVE_STATE_INDEXES; + for (const tableName of omittedTables) { const startMarker = `CREATE TABLE IF NOT EXISTS ${tableName} (`; const start = eagerSchema.indexOf(startMarker); const endMarker = "\n) STRICT;"; @@ -218,7 +221,23 @@ function executeCanonicalStateSchema( } eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + endMarker.length)}`; } - database.exec(eagerSchema); + for (const indexName of omittedIndexes) { + const startMarker = `CREATE INDEX IF NOT EXISTS ${indexName}`; + const start = eagerSchema.indexOf(startMarker); + const end = start >= 0 ? eagerSchema.indexOf(";", start) : -1; + if (start < 0 || end < 0) { + throw new Error(`lazy additive state schema index is missing for ${indexName}`); + } + eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + 1)}`; + } + return eagerSchema; +} + +function executeCanonicalStateSchema( + database: DatabaseSync, + options: { includeVersionLazyAdditiveTables: boolean }, +): void { + database.exec(canonicalStateSchemaForRuntime(options)); } export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabaseOptions = {}): { @@ -279,14 +298,16 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase if (tableExists(db, "audit_events")) { ensureAdditiveStateColumns(db); executeCanonicalStateSchema(db, { - includeLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, + includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, }); if (previousVersion < OPENCLAW_STATE_STRICT_SCHEMA_VERSION) { repairLegacyGatewayRestartHandoffsForStrictMigration(db); } const strictMigration = migrateSqliteSchemaToStrictInTransaction( db, - OPENCLAW_STATE_SCHEMA_SQL, + canonicalStateSchemaForRuntime({ + includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, + }), { databaseLabel: pathname }, ); if (strictMigration.migratedTables.length > 0) { @@ -407,14 +428,18 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { sessionWatchMigration.migrateSessionWatchCursorProvenance(db); assertCanonicalStateSchemaShape(db, pathname); executeCanonicalStateSchema(db, { - includeLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, + includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, }); migrateLegacyCronRunLogsToTaskRuns(db); if (previousVersion < OPENCLAW_STATE_STRICT_SCHEMA_VERSION) { repairLegacyGatewayRestartHandoffsForStrictMigration(db); - migrateSqliteSchemaToStrictInTransaction(db, OPENCLAW_STATE_SCHEMA_SQL, { - databaseLabel: pathname, - }); + migrateSqliteSchemaToStrictInTransaction( + db, + canonicalStateSchemaForRuntime({ + includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, + }), + { databaseLabel: pathname }, + ); } repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { verifyPhysicalIntegrity: false, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index e603e22e0e65..1467323ae5ba 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -196,6 +196,21 @@ CREATE TABLE IF NOT EXISTS audit_identity_keys ( created_at INTEGER NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS execution_identity_contexts ( + context_id TEXT NOT NULL PRIMARY KEY CHECK (length(context_id) BETWEEN 1 AND 256), + execution_id TEXT NOT NULL UNIQUE CHECK (length(execution_id) BETWEEN 1 AND 256), + run_id TEXT NOT NULL CHECK (length(run_id) BETWEEN 1 AND 256), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + coverage_state TEXT NOT NULL CHECK ( + coverage_state IN ('attribution-only', 'unattributed', 'unknown', 'unsupported') + ), + context_bytes INTEGER NOT NULL CHECK (context_bytes BETWEEN 1 AND 16384), + context_json TEXT NOT NULL CHECK (length(context_json) > 0), + UNIQUE (created_at, context_id) +) STRICT; +CREATE INDEX IF NOT EXISTS execution_identity_contexts_run_created_idx + ON execution_identity_contexts (run_id, created_at, execution_id); + CREATE TABLE IF NOT EXISTS session_state_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, dedupe_key TEXT UNIQUE, diff --git a/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts new file mode 100644 index 000000000000..50fca3d2ca14 --- /dev/null +++ b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts @@ -0,0 +1,525 @@ +// QA Lab producer proves exact-run identity inspection through a real local turn and Gateway. +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { pathToFileURL } from "node:url"; +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 type { AuditRunInspectResult } from "../../../../packages/gateway-protocol/src/index.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js"; + +const SCENARIO_ID = "agent-run-identity-inspection"; +const SNAPSHOT_FILE = `${SCENARIO_ID}-summary.json`; +const TEXT_SECTIONS = [ + "Identity", + "Authority", + "Lineage", + "Decisions", + "Missing evidence", + "Next steps", +] as const; +const IDENTITY_FIELDS = [ + "Trust domain", + "Invoker", + "Ingress", + "Agent principal", + "Agent definition", + "Runtime instance", + "Represented subject", + "Sponsor", + "Applicable grants", + "Assurance", +] as const; + +type ProducerOptions = { + artifactBase: string; + repoRoot: string; +}; + +type ProofResult = { + artifacts?: Array<{ filePath: string; kind: string }>; + details?: string; + durationMs: number; + status: QaScriptEvidenceStatus; +}; + +async function updateExecutionIdentityConfig( + configPath: string, + values: { enabled?: boolean; executionIdentity: boolean }, +) { + const raw = await fs.readFile(configPath, "utf8"); + const config = parseJson>(raw || "{}", "QA Gateway config"); + const logging = + config.logging && typeof config.logging === "object" + ? (config.logging as Record) + : {}; + const audit = + logging.audit && typeof logging.audit === "object" + ? (logging.audit as Record) + : {}; + config.logging = { ...logging, audit: { ...audit, ...values } }; + await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function parseOptions(argv: readonly string[]): ProducerOptions { + const readValue = (name: string) => { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; + }; + const artifactBase = readValue("--artifact-base"); + if (!artifactBase) { + throw new Error("--artifact-base is required"); + } + return { + artifactBase: path.resolve(artifactBase), + repoRoot: path.resolve(readValue("--repo-root") ?? process.cwd()), + }; +} + +function parseJson(raw: string, label: string): T { + try { + return JSON.parse(raw) as T; + } catch (error) { + throw new Error(`${label} was not JSON: ${formatErrorMessage(error)}`); + } +} + +function requireIdentityContext(result: AuditRunInspectResult) { + if (result.identity.state !== "present") { + throw new Error( + `identity inspection was ${result.identity.state}: ${result.identity.reasonCode}`, + ); + } + return result.identity.context; +} + +function normalizedContextJson(result: AuditRunInspectResult) { + return JSON.stringify(requireIdentityContext(result)); +} + +function sha256(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +function assertTextProjection(text: string) { + for (const label of [...TEXT_SECTIONS, ...IDENTITY_FIELDS]) { + if (!text.includes(label)) { + throw new Error(`audit text projection omitted ${label}`); + } + } + if (!text.includes("run_admission_identity_not_evaluated") || !text.includes("not-applicable")) { + throw new Error("audit text projection overstated or omitted the admission decision"); + } +} + +function assertJsonProjection(result: AuditRunInspectResult, runId: string) { + const context = requireIdentityContext(result); + if (result.run.runId !== runId || result.coverage.state !== context.coverageState) { + throw new Error(`audit JSON projection did not preserve exact-run coverage: ${runId}`); + } + if ( + context.ingress.kind !== "local-cli" || + context.ingress.state !== "present" || + context.ingress.boundary !== "agent-command.local" + ) { + throw new Error("local agent run did not retain authoritative local-CLI ingress"); + } + const admission = result.decisions.find( + (receipt) => receipt.action.family === "run" && receipt.action.operation === "admission", + ); + if ( + !admission || + admission.decision.outcome !== "not-applicable" || + admission.decision.reasonCode !== "run_admission_identity_not_evaluated" + ) { + throw new Error("audit JSON projection omitted the truthful admission receipt"); + } +} + +function findLocalRunId(gateway: Awaited>) { + const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("QA Gateway did not expose its isolated state directory"); + } + const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), { + readOnly: true, + }); + try { + const rows = database + .prepare( + "SELECT run_id, context_json FROM execution_identity_contexts ORDER BY created_at, context_id", + ) + .all() as Array<{ run_id: string; context_json: string }>; + const localRows = rows.filter((row) => { + const context = parseJson<{ ingress?: { kind?: string } }>( + row.context_json, + "persisted local context", + ); + return context.ingress?.kind === "local-cli"; + }); + if (localRows.length !== 1 || !localRows[0]?.run_id) { + throw new Error( + `local run recorded ${String(localRows.length)} local-CLI execution identity contexts`, + ); + } + return localRows[0].run_id; + } finally { + database.close(); + } +} + +function inspectExecutionIdentityStorage(gateway: Awaited>) { + const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("QA Gateway did not expose its isolated state directory"); + } + const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), { + readOnly: true, + }); + try { + const table = database + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get("execution_identity_contexts"); + if (!table) { + return { rowCount: 0, tablePresent: false }; + } + const row = database + .prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts") + .get() as { count: number }; + return { rowCount: row.count, tablePresent: true }; + } finally { + database.close(); + } +} + +async function runLocalTurn( + gateway: Awaited>, + message: string, +) { + await gateway.runCli([ + "agent", + "--local", + "--agent", + "qa", + "--session-id", + `identity-${randomUUID()}`, + "--message", + message, + "--thinking", + "off", + "--timeout", + "60", + "--json", + ]); +} + +function findRunExecutions( + gateway: Awaited>, + runId: string, +) { + const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("QA Gateway did not expose its isolated state directory"); + } + const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), { + readOnly: true, + }); + try { + return database + .prepare( + "SELECT execution_id, context_id, created_at, context_json FROM execution_identity_contexts WHERE run_id = ? ORDER BY created_at, execution_id", + ) + .all(runId) as Array<{ + execution_id: string; + context_id: string; + created_at: number; + context_json: string; + }>; + } finally { + database.close(); + } +} + +async function runRepeatedIngressTurns( + gateway: Awaited>, + repoRoot: string, + sessionId: string, +): Promise { + const script = path.join( + repoRoot, + "test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts", + ); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", script, sessionId], { + cwd: repoRoot, + env: { ...process.env, ...gateway.runtimeEnv }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const collect = (chunk: Buffer) => { + if (output.length < 8_192) { + output += chunk.toString("utf8").slice(0, 8_192 - output.length); + } + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + const timer = setTimeout(() => child.kill("SIGTERM"), 120_000); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `repeated ingress child failed code=${String(code)} signal=${String(signal)}: ${output}`, + ), + ); + } + }); + }); +} + +async function runProof(options: ProducerOptions): Promise { + const mock = await startQaMockOpenAiServer(); + let gateway: Awaited> | undefined; + try { + gateway = await startQaGatewayChild({ + repoRoot: options.repoRoot, + useRepoCli: true, + providerBaseUrl: `${mock.baseUrl}/v1`, + providerMode: "mock-openai", + transportBaseUrl: "http://127.0.0.1", + controlUiEnabled: false, + }); + 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"); + 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"); + const runId = findLocalRunId(gateway); + const beforeText = await gateway.runCli(["audit", "--run", runId, "--explain"]); + assertTextProjection(beforeText); + const before = parseJson( + await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), + "pre-restart audit inspection", + ); + assertJsonProjection(before, runId); + const beforeContext = normalizedContextJson(before); + + const repeatedRunId = `identity-repeated-${randomUUID()}`; + await runRepeatedIngressTurns(gateway, options.repoRoot, repeatedRunId); + const repeatedRows = findRunExecutions(gateway, repeatedRunId); + if ( + repeatedRows.length !== 2 || + new Set(repeatedRows.map((row) => row.execution_id)).size !== 2 || + new Set(repeatedRows.map((row) => row.context_id)).size !== 2 + ) { + throw new Error( + `repeated same-session run recorded ${String(repeatedRows.length)} non-distinct executions`, + ); + } + const discoveryText = await gateway.runCli(["audit", "--run", repeatedRunId, "--explain"]); + if ( + !discoveryText.includes("execution_selection_required") || + !discoveryText.includes("--execution --explain") + ) { + throw new Error("ambiguous run discovery omitted exact-execution selection guidance"); + } + const discovery = parseJson( + await gateway.runCli(["audit", "--run", repeatedRunId, "--explain", "--json"]), + "repeated-run discovery", + ); + 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); + const exact = parseJson( + await gateway.runCli(["audit", "--execution", row.execution_id, "--explain", "--json"]), + `execution ${row.execution_id}`, + ); + const context = requireIdentityContext(exact); + if ( + exact.run.executionId !== row.execution_id || + context.executionId !== row.execution_id || + context.contextId !== row.context_id || + context.runId !== repeatedRunId || + context.ingress.kind !== "api" || + context.ingress.state !== "unknown" + ) { + throw new Error(`exact execution inspection selected the wrong turn: ${row.execution_id}`); + } + const exactContextJson = normalizedContextJson(exact); + if (exactContextJson !== row.context_json) { + throw new Error(`RPC context bytes differ from persisted bytes: ${row.execution_id}`); + } + repeatedBeforeRestart.set(row.execution_id, exactContextJson); + } + + await gateway.restartAfterStateMutation(async () => {}); + + const afterText = await gateway.runCli(["audit", "--run", runId, "--explain"]); + assertTextProjection(afterText); + const after = parseJson( + await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), + "post-restart audit inspection", + ); + assertJsonProjection(after, runId); + const afterContext = normalizedContextJson(after); + if (afterContext !== beforeContext) { + throw new Error("normalized execution identity context bytes changed across Gateway restart"); + } + for (const [executionId, expectedContext] of repeatedBeforeRestart) { + const afterExact = parseJson( + await gateway.runCli(["audit", "--execution", executionId, "--explain", "--json"]), + `post-restart execution ${executionId}`, + ); + if (normalizedContextJson(afterExact) !== expectedContext) { + throw new Error(`repeated execution changed across Gateway restart: ${executionId}`); + } + } + const retainedBeforeGlobalDisable = inspectExecutionIdentityStorage(gateway).rowCount; + await gateway.restartAfterStateMutation(async ({ configPath }) => { + await updateExecutionIdentityConfig(configPath, { + enabled: false, + executionIdentity: true, + }); + }); + 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"); + } + const afterGlobalDisable = parseJson( + await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), + "global-disabled retained inspection", + ); + if (normalizedContextJson(afterGlobalDisable) !== beforeContext) { + throw new Error("global audit disable hid or changed retained identity evidence"); + } + + const snapshotPath = path.join(options.artifactBase, SNAPSHOT_FILE); + await fs.mkdir(options.artifactBase, { recursive: true }); + await fs.writeFile( + snapshotPath, + `${JSON.stringify( + { + runId, + repeatedRunId, + repeatedExecutions: repeatedRows.map((row) => ({ + executionId: row.execution_id, + contextId: row.context_id, + })), + coverage: before.coverage, + decision: before.decisions[0]?.decision, + contextSha256: sha256(beforeContext), + byteEquivalentAfterRestart: true, + byteEquivalentPersistedReadback: true, + optIn: { + explicitEnablement: true, + freshInstallDisabled: true, + freshInstallTableAbsent: true, + globalAuditDisabled: true, + upgradeStyleExistingInstallDisabled: true, + upgradeStyleTableAbsent: true, + }, + textSections: TEXT_SECTIONS, + identityFields: IDENTITY_FIELDS, + }, + null, + 2, + )}\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)}`; + } finally { + await gateway?.stop().catch(() => undefined); + await mock.stop(); + } +} + +async function produceProof(options: ProducerOptions): Promise { + const startedAt = Date.now(); + try { + const details = await runProof(options); + return { + artifacts: [{ filePath: SNAPSHOT_FILE, kind: "summary" }], + details, + durationMs: Math.max(1, Date.now() - startedAt), + status: "pass", + }; + } catch (error) { + return { + details: formatErrorMessage(error), + durationMs: Math.max(1, Date.now() - startedAt), + status: "fail", + }; + } +} + +async function runProducer(options: ProducerOptions): Promise { + const writer = createQaScriptEvidenceWriter({ + artifactBase: options.artifactBase, + logFileName: `${SCENARIO_ID}.log`, + primaryModel: "mock-openai/gpt-5.6-luna", + providerMode: "mock-openai", + repoRoot: options.repoRoot, + target: { + id: SCENARIO_ID, + title: "Agent-run execution identity inspection", + sourcePath: `qa/scenarios/runtime/${SCENARIO_ID}.yaml`, + docsRefs: ["docs/gateway/audit.md", "docs/cli/audit.md"], + codeRefs: [ + "src/agents/agent-command.ts", + "src/agents/agent-command-execution-identity.ts", + "src/audit/execution-identity-admission.ts", + "src/audit/audit-event-writer.ts", + "src/audit/execution-identity-context.ts", + "src/gateway/server-methods/audit.ts", + "src/commands/audit.ts", + ], + }, + }); + const result = await produceProof(options); + writer.appendLog(`${result.status}: ${result.details ?? "no details"}\n`); + return await writer.write(result); +} + +async function main(argv: readonly string[]) { + const evidence = await runProducer(parseOptions(argv)); + const status = evidence.entries[0]?.result.status; + console.log(`Agent-run identity evidence: ${QA_EVIDENCE_FILENAME}`); + console.log(`Agent-run identity status: ${status}`); + return status === "pass" ? 0 : 1; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(process.argv.slice(2)) + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { + console.error(formatErrorMessage(error)); + process.exitCode = 1; + }); +} diff --git a/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts b/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts new file mode 100644 index 000000000000..07953cc98930 --- /dev/null +++ b/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts @@ -0,0 +1,45 @@ +// Runs two real public-ingress turns in one session so QA can inspect their executions. +import { pathToFileURL } from "node:url"; +import { createAuditEventRecorder } from "../../../../src/audit/audit-recorder.js"; +import { configureExecutionIdentityAdmissionSink } from "../../../../src/audit/execution-identity-admission.js"; +import { agentCommandFromIngress } from "../../../../src/plugin-sdk/agent-runtime.js"; + +async function main() { + const sessionId = process.argv[2]?.trim(); + if (!sessionId) { + throw new Error("session id is required"); + } + const recorder = createAuditEventRecorder({ messageMode: "off" }); + const clearSink = configureExecutionIdentityAdmissionSink(recorder.recordExecutionIdentity); + try { + for (const message of [ + "Reply exactly: REPEATED-TURN-ONE", + "Reply exactly: REPEATED-TURN-TWO", + ]) { + const result = await agentCommandFromIngress( + { + message, + sessionId, + sessionKey: "agent:qa:identity-repeated-turns", + agentId: "qa", + allowModelOverride: false, + deliver: false, + }, + { log: () => {}, error: () => {} } as never, + ); + if (!result?.payloads?.some((payload) => payload.text?.trim())) { + throw new Error("repeated public-ingress turn produced no text payload"); + } + } + } finally { + clearSink(); + await recorder.stop(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +}