From 29625bc310f58dc6d6a7d66bf84b9e06e89709b3 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:13:52 -0700 Subject: [PATCH] feat(audit): record generic tool action decisions (#130358) * feat(audit): record generic tool action decisions * fix(audit): bind OpenClaw action descriptors * fix(audit): keep decision routing private * fix(audit): avoid duplicate void-hook decisions * fix(audit): scope plugin hook ownership * docs(agents): preserve decision audit FIFO ownership --- AGENTS.md | 2 +- config/assertion-safety-baseline.txt | 2 +- src/agents/agent-tool-metadata.ts | 55 +- ...nt-tools.before-tool-call.decision.test.ts | 523 ++++++++++++++++++ .../agent-tools.before-tool-call.decision.ts | 64 +++ .../agent-tools.before-tool-call.policy.ts | 24 +- .../agent-tools.before-tool-call.types.ts | 3 +- .../agent-tools.before-tool-call.wrapper.ts | 24 +- src/agents/agent-tools.ts | 2 + src/agents/channel-tools.ts | 2 +- src/agents/core-coding-tools.ts | 7 + src/agents/openclaw-tools.ts | 4 + src/plugins/hooks.ts | 13 +- src/plugins/tools.ts | 3 + 14 files changed, 708 insertions(+), 20 deletions(-) create mode 100644 src/agents/agent-tools.before-tool-call.decision.test.ts create mode 100644 src/agents/agent-tools.before-tool-call.decision.ts diff --git a/AGENTS.md b/AGENTS.md index 96a774ca0639..ded5caa730ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ Review invariants; full doctrine: `docs/gateway/audit.md`. - Frozen ingress identity facts are diagnostic audit input, not session-ownership state. Session provenance uses the current canonical authenticated profile ID, never a profile display label; only explicitly enabled audit storage may retain its bounded, redacted form. - Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence. - Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent. -- Decision receipts adapt owner-native durable decisions; `execution_decision_facts` is only for boundaries without an owner-native record, never duplicates approvals, and stays dormant until an explicit product-boundary producer with an operator retention opt-in exists — the 30-day retention bound does not authorize default collection. Receipt coverage `enforced` is diagnostic, not authority: emit it only when the owner changed the outcome and the exact context/execution/run tuple validates. For receipts after awaited work, synchronously revalidate the exact live owner immediately before the sink; stale, released, replaced, or throwing authority emits no receipt — not `unknown` — with no intervening await. Same-run wrappers compose owner predicates; distinct admitted runs start a new predicate root. Insufficient decision evidence remains `unknown`. +- Decision receipts adapt owner-native durable decisions; `execution_decision_facts` is only for boundaries without an owner-native record, never duplicates approvals, and stays dormant until an explicit product-boundary producer with an operator retention opt-in exists — the 30-day retention bound does not authorize default collection. Private decision work shares admission's `AuditEventWriter` FIFO so admission is observed first; producers never write the generic store directly, create another writer/key, or pseudonymize locally. Raw refs are HMAC-projected only by the writer before persistence. Receipt coverage `enforced` is diagnostic, not authority: emit it only when the owner changed the outcome and the exact context/execution/run tuple validates. For receipts after awaited work, synchronously revalidate the exact live owner immediately before the sink; stale, released, replaced, or throwing authority emits no receipt — not `unknown` — with no intervening await. Same-run wrappers compose owner predicates; distinct admitted runs start a new predicate root. Insufficient decision evidence remains `unknown`. - `audit.run.inspect` exposes only the Gateway-owned `decisionDisplays` allowlist; display trust comes from owner-held call-path provenance, never receipt-controlled `source.owner` or prose. Pair every selected owner row or event with a required opaque selector from the same query or page result; never derive or requery selectors from private receipt, resolution, or event identifiers, or drop corrupt, oversized, or unlinked outcomes. - Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. - Raw identity references are transient worker-message data. Never persist, export, inspect, or log them. Public Plugin SDK ingress must strip private recovery/admission authority, including JavaScript extra and inherited properties. diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 0436d1d823b0..5eb2e67fceed 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1679,7 +1679,7 @@ src/agents/agent-tool-definition-adapter.ts 4 src/agents/agent-tool-metadata.ts 2 src/agents/agent-tools.abort.ts 1 src/agents/agent-tools.before-tool-call.diagnostics.ts 1 -src/agents/agent-tools.before-tool-call.wrapper.ts 8 +src/agents/agent-tools.before-tool-call.wrapper.ts 4 src/agents/agent-tools.execution-preparer.ts 2 src/agents/agent-tools.params.ts 5 src/agents/agent-tools.policy.ts 1 diff --git a/src/agents/agent-tool-metadata.ts b/src/agents/agent-tool-metadata.ts index 2e8547461efa..cb2b26ee5850 100644 --- a/src/agents/agent-tool-metadata.ts +++ b/src/agents/agent-tool-metadata.ts @@ -1,4 +1,4 @@ -import { copyPluginToolMeta } from "../plugins/tools.js"; +import { copyPluginToolMeta, getPluginToolMeta } from "../plugins/tools.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "./channel-tool-metadata.js"; @@ -6,6 +6,58 @@ import { copyCodeModeControlToolIdentity } from "./code-mode-control-tools.js"; import { copyInternalToolExecutionPreparer } from "./runtime/internal-hooks.js"; import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js"; +export type AgentToolActionDescriptor = Readonly<{ + family: "data" | "tool"; + operation: "filesystem" | "memory" | "openclaw" | "process"; +}>; + +const actionDescriptors = new WeakMap(); + +export function bindAgentToolActionDescriptor( + tool: AnyAgentTool, + descriptor: AgentToolActionDescriptor, +): void { + actionDescriptors.set(tool, descriptor); +} + +export function getAgentToolActionDescriptor( + tool: AnyAgentTool, +): AgentToolActionDescriptor | undefined { + return actionDescriptors.get(tool); +} + +function copyAgentToolActionDescriptor(source: AnyAgentTool, target: AnyAgentTool): void { + const descriptor = actionDescriptors.get(source); + if (descriptor) { + actionDescriptors.set(target, descriptor); + } +} + +/** Preserve only the metadata owned by a before-tool-call wrapper rebuild. */ +export function copyBeforeToolCallWrapperMetadata( + source: AnyAgentTool, + target: AnyAgentTool, +): void { + copyPluginToolMeta(source, target); + // SAFETY: both metadata owners attach to the same runtime tool object shape. + copyChannelAgentToolMeta(source as never, target as never); + copyToolTerminalPresentation(source, target); + copyAgentToolActionDescriptor(source, target); +} + +/** Bind the broad family at final assembly from private, process-stable owner metadata. */ +export function bindAssembledAgentToolActionDescriptor(tool: AnyAgentTool): void { + if (actionDescriptors.has(tool)) { + return; + } + const kind = getPluginToolMeta(tool)?.kind; + const memory = kind === "memory" || (Array.isArray(kind) && kind.includes("memory")); + actionDescriptors.set( + tool, + memory ? { family: "data", operation: "memory" } : { family: "tool", operation: "openclaw" }, + ); +} + /** * Preserve identity-backed tool metadata that object spread cannot carry. * Losing it detaches policy, hooks, presentation, and control-flow ownership. @@ -20,5 +72,6 @@ export function copyAgentToolMetadata(source: AnyAgentTo copyToolTerminalPresentation(source, target); copyCodeModeControlToolIdentity(source, target); copyInternalToolExecutionPreparer(source, target); + copyAgentToolActionDescriptor(source, target); return target; } diff --git a/src/agents/agent-tools.before-tool-call.decision.test.ts b/src/agents/agent-tools.before-tool-call.decision.test.ts new file mode 100644 index 000000000000..59f5b57b9b6a --- /dev/null +++ b/src/agents/agent-tools.before-tool-call.decision.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DecisionReceiptV1 } from "../../packages/gateway-protocol/src/index.js"; +import type { ExecutionDecisionWork } from "../audit/execution-decision-work.js"; +import { configureExecutionDecisionWorkSink } from "../audit/execution-decision-work.js"; +import { createExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js"; +import { configureRuntimeActionDecisionSink } from "../audit/runtime-action-decision.js"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; +import { addTestHook } from "../plugins/hooks.test-fixtures.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { setPluginToolMeta } from "../plugins/tools.js"; +import type { PluginHookRegistration } from "../plugins/types.js"; +import { toToolDefinitions } from "./agent-tool-definition-adapter.js"; +import { + bindAssembledAgentToolActionDescriptor, + copyAgentToolMetadata, +} from "./agent-tool-metadata.js"; +import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; +import { createCoreCodingTools } from "./core-coding-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; +import { getInternalToolExecutionPreparer } from "./runtime/internal-hooks.js"; +import { wrapToolDefinition } from "./sessions/tools/tool-definition-wrapper.js"; +import type { AnyAgentTool } from "./tools/common.js"; +import { withGatewayToolCallerIdentity } from "./tools/gateway-caller-context.js"; + +function assembledTool( + kind: "data" | "tool", + name: string, + execute: AnyAgentTool["execute"], +): AnyAgentTool { + const source = createCoreCodingTools({ + codingRoot: process.cwd(), + containmentRoot: process.cwd(), + includeBaseCodingTools: kind === "data", + includeShellTools: kind === "tool", + workspaceOnly: false, + readOnly: false, + applyPatchEnabled: false, + applyPatchWorkspaceOnly: true, + execDefaults: {}, + processDefaults: { scopeKey: "c02-test" }, + }).find((tool) => tool.name === (kind === "data" ? "read" : "exec")); + if (!source) { + throw new Error(`missing assembled ${kind} tool`); + } + return copyAgentToolMetadata(source, { ...source, name, execute }); +} + +function assembledPluginTool(params: { + pluginId: string; + manifestKind?: "memory"; + execute: AnyAgentTool["execute"]; +}): AnyAgentTool { + const source: AnyAgentTool = { + name: "owner_declared_name", + label: "Owner tool", + description: "Owner tool", + parameters: { type: "object", properties: {} }, + execute: params.execute, + }; + setPluginToolMeta(source, { + pluginId: params.pluginId, + ...(params.manifestKind ? { kind: params.manifestKind } : {}), + optional: false, + }); + bindAssembledAgentToolActionDescriptor(source); + return copyAgentToolMetadata(source, { ...source, name: "arbitrarily_renamed_owner_tool" }); +} + +function admittedRun(params: { + works: ExecutionDecisionWork[]; + authority?: () => boolean | void; + run: () => Promise; +}) { + const token = createExecutionIdentityAdmissionToken("c02-tool-run", { + contextId: "c02-tool-context", + executionId: "c02-tool-execution", + now: 100, + }); + const clear = configureExecutionDecisionWorkSink((work) => { + params.works.push(work); + return true; + }); + return withGatewayToolCallerIdentity( + { + agentId: "main", + sessionKey: "agent:main:c02", + executionIdentityToken: token, + receiptAuthority: params.authority ?? (() => true), + }, + params.run, + ).finally(clear); +} + +describe("generic tool action decision receipts", () => { + beforeEach(() => { + resetGlobalHookRunner(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + afterEach(() => { + resetGlobalHookRunner(); + setActivePluginRegistry(createEmptyPluginRegistry()); + vi.restoreAllMocks(); + }); + + it.each([ + { kind: "data", family: "data", operation: "filesystem" }, + { kind: "tool", family: "tool", operation: "process" }, + ] as const)( + "records ordinary $kind execution as private attribution independent of name and payload", + async ({ kind, family, operation }) => { + vi.spyOn(Date, "now").mockReturnValue(250); + const works: ExecutionDecisionWork[] = []; + const execute = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "SECRET_RESULT" }], + details: { path: "/private/result" }, + }); + const tool = wrapToolWithBeforeToolCallHook( + assembledTool(kind, "renamed_private_tool", execute), + ); + + await admittedRun({ + works, + run: () => tool.execute("same-call", { path: "/private/input" }), + }); + + expect(works).toHaveLength(1); + expect(works[0]).toMatchObject({ + token: { + contextId: "c02-tool-context", + executionId: "c02-tool-execution", + runId: "c02-tool-run", + }, + receipt: { + occurredAt: 250, + action: { family, operation }, + decision: { outcome: "allowed", reasonCode: "generic_action_attributed" }, + enforcement: { coverageState: "attribution-only" }, + source: { owner: "tool-action" }, + }, + }); + expect(works[0]?.refs).toBeUndefined(); + const encoded = JSON.stringify(works); + expect(encoded).not.toContain("renamed_private_tool"); + expect(encoded).not.toContain("/private/input"); + expect(encoded).not.toContain("SECRET_RESULT"); + expect(encoded).not.toContain("/private/result"); + }, + ); + + it.each([ + { + label: "memory manifest kind", + pluginId: "arbitrary-memory-owner", + manifestKind: "memory", + family: "data", + operation: "memory", + }, + { + label: "browser plugin without a canonical generic kind", + pluginId: "arbitrary-browser-owner", + manifestKind: undefined, + family: "tool", + operation: "openclaw", + }, + ] as const)("classifies $label independently of plugin and tool names", async (entry) => { + const works: ExecutionDecisionWork[] = []; + const tool = wrapToolWithBeforeToolCallHook( + assembledPluginTool({ + pluginId: entry.pluginId, + ...(entry.manifestKind ? { manifestKind: entry.manifestKind } : {}), + execute: vi.fn().mockResolvedValue({ content: [], details: { ok: true } }), + }), + ); + + await admittedRun({ works, run: () => tool.execute("plugin-call", {}) }); + + expect(works).toHaveLength(1); + expect(works[0]?.receipt.action).toMatchObject({ + family: entry.family, + operation: entry.operation, + }); + expect(JSON.stringify(works)).not.toMatch(/arbitrary|owner_declared|renamed/u); + }); + + it("records a Gateway-shaped tool assembled without its first hook wrapper", async () => { + const source = createOpenClawTools({ + disablePluginTools: true, + wrapBeforeToolCallHook: false, + }).find((tool) => tool.name === "sessions_list"); + if (!source) { + throw new Error("missing Gateway-shaped sessions_list tool"); + } + const works: ExecutionDecisionWork[] = []; + const tool = wrapToolWithBeforeToolCallHook( + copyAgentToolMetadata(source, { + ...source, + execute: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "SECRET_GATEWAY_RESULT" }], + details: { path: "/private/gateway-result" }, + }), + }), + ); + + await admittedRun({ + works, + run: () => tool.execute("gateway-call", { path: "/private/gateway-input" }), + }); + + expect(works).toHaveLength(1); + expect(works[0]?.receipt).toMatchObject({ + action: { family: "tool", operation: "openclaw" }, + decision: { outcome: "allowed", reasonCode: "generic_action_attributed" }, + enforcement: { coverageState: "attribution-only" }, + }); + expect(JSON.stringify(works)).not.toMatch(/sessions_list|SECRET_GATEWAY|private\/gateway/u); + }); + + it("keeps an execution failure separate from its prior generic decision", async () => { + const works: ExecutionDecisionWork[] = []; + const tool = wrapToolWithBeforeToolCallHook( + assembledTool( + "data", + "throws_after_admission", + vi.fn().mockRejectedValue(new Error("SECRET")), + ), + ); + + await expect( + admittedRun({ + works, + run: () => tool.execute("failed-call", { secret: "PRIVATE" }), + }), + ).rejects.toThrow("SECRET"); + + expect(works).toHaveLength(1); + expect(works[0]?.receipt.decision).toEqual({ + outcome: "allowed", + reasonCode: "generic_action_attributed", + }); + expect(JSON.stringify(works)).not.toMatch(/SECRET|PRIVATE/u); + }); + + it("records a generic trusted-policy veto as enforced without owner prose", async () => { + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "SECRET_PLUGIN", + source: "test", + policy: { + id: "SECRET_POLICY", + description: "private policy", + evaluate: () => ({ block: true, blockReason: "SECRET_REASON" }), + }, + }, + ]; + setActivePluginRegistry(registry); + initializeGlobalHookRunner(registry); + const works: ExecutionDecisionWork[] = []; + const execute = vi.fn(); + const tool = wrapToolWithBeforeToolCallHook(assembledTool("data", "policy_subject", execute)); + + const result = await admittedRun({ + works, + run: () => tool.execute("blocked-call", {}), + }); + + expect(result).toMatchObject({ details: { status: "blocked" } }); + expect(execute).not.toHaveBeenCalled(); + expect(works).toHaveLength(1); + expect(works[0]?.receipt).toMatchObject({ + decision: { outcome: "denied", reasonCode: "generic_action_policy_denied" }, + enforcement: { coverageState: "enforced" }, + source: { owner: "tool-action" }, + }); + expect(JSON.stringify(works)).not.toMatch(/SECRET_PLUGIN|SECRET_POLICY|SECRET_REASON/u); + }); + + it("does not duplicate a normal plugin-hook decision", async () => { + const registry = createEmptyPluginRegistry(); + addTestHook({ + registry, + pluginId: "owner-plugin", + hookName: "before_tool_call", + handler: (() => ({ + block: true, + blockReason: "owned denial", + })) as PluginHookRegistration["handler"], + }); + setActivePluginRegistry(registry); + initializeGlobalHookRunner(registry); + const works: ExecutionDecisionWork[] = []; + const ownerReceipts: DecisionReceiptV1[] = []; + const clearOwnerSink = configureRuntimeActionDecisionSink((receipt) => { + ownerReceipts.push(receipt); + return true; + }); + const tool = wrapToolWithBeforeToolCallHook(assembledTool("data", "hook_subject", vi.fn())); + + try { + await admittedRun({ works, run: () => tool.execute("hook-call", {}) }); + } finally { + clearOwnerSink(); + } + + expect(works).toEqual([]); + expect(ownerReceipts).toHaveLength(1); + expect(ownerReceipts[0]).toMatchObject({ + decision: { outcome: "denied", reasonCode: "plugin_hook_blocked" }, + source: { owner: "plugin-hook" }, + }); + }); + + it.each([ + { + label: "returns adjusted params", + result: { params: { ownerAdjusted: true } }, + matcher: undefined, + toolName: undefined, + expectedHandlerCalls: 1, + expectedGenericReceipts: 0, + expectedOwnerReceipts: 1, + }, + { + label: "returns void", + result: undefined, + matcher: undefined, + toolName: undefined, + expectedHandlerCalls: 1, + expectedGenericReceipts: 0, + expectedOwnerReceipts: 1, + }, + { + label: "does not match the tool", + result: undefined, + matcher: ["exec"], + toolName: "read", + expectedHandlerCalls: 0, + expectedGenericReceipts: 1, + expectedOwnerReceipts: 0, + }, + ] as const)( + "routes generic attribution only when a plugin hook $label", + async ({ result, ...testCase }) => { + const registry = createEmptyPluginRegistry(); + const handler = vi.fn(() => result); + addTestHook({ + registry, + pluginId: "owner-plugin", + hookName: "before_tool_call", + handler: handler as PluginHookRegistration["handler"], + ...(testCase.matcher ? { matcher: [...testCase.matcher] } : {}), + }); + setActivePluginRegistry(registry); + initializeGlobalHookRunner(registry); + const works: ExecutionDecisionWork[] = []; + const ownerReceipts: DecisionReceiptV1[] = []; + const clearOwnerSink = configureRuntimeActionDecisionSink((receipt) => { + ownerReceipts.push(receipt); + return true; + }); + const tool = wrapToolWithBeforeToolCallHook( + assembledTool( + "data", + testCase.toolName ?? "hook_allow_subject", + vi.fn().mockResolvedValue({ content: [] }), + ), + ); + + try { + await admittedRun({ works, run: () => tool.execute("hook-allow-call", {}) }); + } finally { + clearOwnerSink(); + } + + expect(handler).toHaveBeenCalledTimes(testCase.expectedHandlerCalls); + expect(works).toHaveLength(testCase.expectedGenericReceipts); + expect(ownerReceipts).toHaveLength(testCase.expectedOwnerReceipts); + if (testCase.expectedGenericReceipts === 1) { + expect(works[0]?.receipt).toMatchObject({ + decision: { outcome: "allowed", reasonCode: "generic_action_attributed" }, + source: { owner: "tool-action" }, + }); + } + if (testCase.expectedOwnerReceipts === 1) { + expect(ownerReceipts[0]).toMatchObject({ + decision: { outcome: "allowed", reasonCode: "plugin_hook_allowed" }, + source: { owner: "plugin-hook" }, + }); + } + }, + ); + + it("does not duplicate an owner-native approval receipt", async () => { + const registry = createEmptyPluginRegistry(); + addTestHook({ + registry, + pluginId: "owner-plugin", + hookName: "before_tool_call", + handler: (() => ({ + requireApproval: { title: "Owner approval", description: "Owner approval" }, + })) as PluginHookRegistration["handler"], + }); + setActivePluginRegistry(registry); + initializeGlobalHookRunner(registry); + const works: ExecutionDecisionWork[] = []; + const ownerReceipts: DecisionReceiptV1[] = []; + const clearOwnerSink = configureRuntimeActionDecisionSink((receipt) => { + ownerReceipts.push(receipt); + return true; + }); + const execute = vi.fn(); + const tool = wrapToolWithBeforeToolCallHook( + assembledTool("data", "approval_subject", execute), + undefined, + { approvalMode: "report" }, + ); + + try { + await expect( + admittedRun({ works, run: () => tool.execute("approval-call", {}) }), + ).rejects.toThrow(); + } finally { + clearOwnerSink(); + } + + expect(execute).not.toHaveBeenCalled(); + expect(works).toEqual([]); + expect(ownerReceipts).toHaveLength(1); + expect(ownerReceipts[0]).toMatchObject({ + decision: { reasonCode: "plugin_hook_approval_required" }, + source: { owner: "plugin-hook" }, + }); + }); + + it("records prepareControl disposal as suppression without launching the tool", async () => { + const works: ExecutionDecisionWork[] = []; + const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); + const tool = wrapToolWithBeforeToolCallHook( + assembledTool("data", "disposed_before_launch", execute), + ); + const definition = toToolDefinitions([tool])[0]; + if (!definition) { + throw new Error("missing adapted tool definition"); + } + const preparer = getInternalToolExecutionPreparer(wrapToolDefinition(definition)); + if (!preparer) { + throw new Error("missing private execution preparer"); + } + + await admittedRun({ + works, + run: async () => { + const prepared = await preparer({ toolCallId: "disposed-call", args: {} }); + expect(prepared.kind).toBe("ready"); + prepared.dispose(); + await vi.waitFor(() => expect(works).toHaveLength(1)); + }, + }); + + expect(execute).not.toHaveBeenCalled(); + expect(works[0]?.receipt).toMatchObject({ + decision: { outcome: "not-applicable", reasonCode: "generic_action_suppressed" }, + enforcement: { coverageState: "attribution-only" }, + source: { owner: "tool-action" }, + }); + }); + + it.each([ + { name: "missing identity", runAdmitted: false, authority: () => true }, + { name: "stale authority", runAdmitted: true, authority: () => false }, + { + name: "throwing authority", + runAdmitted: true, + authority: () => { + throw new Error("stale"); + }, + }, + ])( + "suppresses receipts with $name without suppressing the tool", + async ({ runAdmitted, authority }) => { + const works: ExecutionDecisionWork[] = []; + const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); + const tool = wrapToolWithBeforeToolCallHook( + assembledTool("data", "authority_subject", execute), + ); + if (runAdmitted) { + await admittedRun({ works, authority, run: () => tool.execute("authority-call", {}) }); + } else { + await tool.execute("authority-call", {}); + } + expect(execute).toHaveBeenCalledOnce(); + expect(works).toEqual([]); + }, + ); + + it("is deterministic for duplicate delivery and harmless without a sink", async () => { + const works: ExecutionDecisionWork[] = []; + const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } }); + const tool = wrapToolWithBeforeToolCallHook(assembledTool("data", "dedupe_subject", execute)); + + await admittedRun({ + works, + run: async () => { + await tool.execute("duplicate-call", {}); + await tool.execute("duplicate-call", {}); + }, + }); + expect(works).toHaveLength(2); + expect(works[0]?.receipt.receiptId).toBe(works[1]?.receipt.receiptId); + expect(works[0]?.receipt.action).toEqual(works[1]?.receipt.action); + expect(works[0]?.receipt.decision).toEqual(works[1]?.receipt.decision); + + await expect(tool.execute("no-sink-call", {})).resolves.toMatchObject({ + details: { ok: true }, + }); + expect(execute).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/agents/agent-tools.before-tool-call.decision.ts b/src/agents/agent-tools.before-tool-call.decision.ts new file mode 100644 index 000000000000..eed37669fdd0 --- /dev/null +++ b/src/agents/agent-tools.before-tool-call.decision.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { recordExecutionDecisionWork } from "../audit/execution-decision-work.js"; +import { getAgentToolActionDescriptor } from "./agent-tool-metadata.js"; +import type { AnyAgentTool } from "./tools/common.js"; +import { getGatewayToolCallerIdentity } from "./tools/gateway-caller-context.js"; + +const genericDecisions = { + allowed: ["allowed", "attribution-only", "generic_action_attributed"], + denied: ["denied", "enforced", "generic_action_policy_denied"], + suppressed: ["not-applicable", "attribution-only", "generic_action_suppressed"], +} as const; + +export function recordGenericToolActionDecision( + tool: AnyAgentTool, + toolCallId: string | undefined, + kind: keyof typeof genericDecisions, +): boolean { + const descriptor = getAgentToolActionDescriptor(tool); + const identity = getGatewayToolCallerIdentity(); + const token = identity?.executionIdentityToken; + const authority = identity?.receiptAuthority; + if (!descriptor || !toolCallId?.trim() || !token || !authority) { + return false; + } + const [outcome, coverageState, reasonCode] = genericDecisions[kind]; + const receiptId = `tool-action:${createHash("sha256") + .update( + JSON.stringify([token.contextId, token.executionId, toolCallId, descriptor, reasonCode]), + ) + .digest("base64url") + .slice(0, 32)}`; + try { + const occurredAt = Date.now(); + if (authority() === false) { + return false; + } + return recordExecutionDecisionWork({ + workVersion: 1, + token, + receipt: { + schemaVersion: 1, + receiptId, + occurredAt, + action: descriptor, + decision: { outcome, reasonCode }, + enforcement: { + coverageState, + policyRefs: kind === "denied" ? ["tool-action-policy"] : [], + grantRefs: [], + contextFieldsUsed: ["contextId", "executionId", "runId"], + }, + source: { + owner: "tool-action", + recordRef: receiptId, + decisionBoundary: "agent-tool.before-execute", + }, + missingEvidence: [], + remediation: [], + }, + }); + } catch { + return false; + } +} diff --git a/src/agents/agent-tools.before-tool-call.policy.ts b/src/agents/agent-tools.before-tool-call.policy.ts index 419899fc06c7..2352b9093733 100644 --- a/src/agents/agent-tools.before-tool-call.policy.ts +++ b/src/agents/agent-tools.before-tool-call.policy.ts @@ -57,6 +57,14 @@ import { getGatewayToolCallerIdentity } from "./tools/gateway-caller-context.js" const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON = "Tool call blocked because before_tool_call hook failed"; +/** Keep receipt routing private without widening observable hook outcomes. */ +function markPrivateDecision( + outcome: HookOutcome, + marker: "genericDecision" | "ownerDecision", +): void { + Object.defineProperty(outcome, marker, { value: true }); +} + export function getBeforeToolCallPolicyDiagnosticState(): BeforeToolCallPolicyDiagnosticState { const policyRegistry = getGlobalHookRunnerRegistry() ?? undefined; return { @@ -134,13 +142,15 @@ export async function runBeforeToolCallHook(args: { args.ctx, ); if (intervention) { - return { + const outcome: HookOutcome = { blocked: true, kind: "veto", deniedReason: "tool-loop", reason: intervention.reason, params, }; + markPrivateDecision(outcome, "genericDecision"); + return outcome; } } } @@ -253,13 +263,15 @@ export async function runBeforeToolCallHook(args: { ) : undefined; if (trustedPolicyResult?.block) { - return { + const outcome: HookOutcome = { blocked: true, kind: "veto", deniedReason: "plugin-before-tool-call", reason: trustedPolicyResult.blockReason || "Tool call blocked by trusted plugin policy", params, }; + markPrivateDecision(outcome, "genericDecision"); + return outcome; } let trustedApprovalParams: unknown; let trustedApprovalResolution: PluginApprovalResolution | undefined; @@ -312,17 +324,22 @@ export async function runBeforeToolCallHook(args: { params: policyAdjustedParams, }; if (trustedApprovalResolution) { + markPrivateDecision(allowed, "ownerDecision"); allowed.approvalResolution = trustedApprovalResolution; } return allowed; } const hookEventParams = isPlainObject(policyAdjustedParams) ? policyAdjustedParams : {}; const callerIdentity = getGatewayToolCallerIdentity(); + let ownerDecisionMarked = false; const receipt = callerIdentity?.executionIdentityToken && callerIdentity.receiptAuthority ? { token: callerIdentity.executionIdentityToken, assertAuthority: callerIdentity.receiptAuthority, + markOwnerDecision: () => { + ownerDecisionMarked = true; + }, } : undefined; const hookResult = await hookRunner.runBeforeToolCall( @@ -397,6 +414,9 @@ export async function runBeforeToolCallHook(args: { blocked: false as const, params: finalParams, }; + if (ownerDecisionMarked || finalApprovalResolution) { + markPrivateDecision(allowed, "ownerDecision"); + } if (finalApprovalResolution) { allowed.approvalResolution = finalApprovalResolution; } diff --git a/src/agents/agent-tools.before-tool-call.types.ts b/src/agents/agent-tools.before-tool-call.types.ts index 20092a8ef4ce..d672a4eadb14 100644 --- a/src/agents/agent-tools.before-tool-call.types.ts +++ b/src/agents/agent-tools.before-tool-call.types.ts @@ -108,7 +108,7 @@ type HookBlockedOutcome = { }; export type HookOutcome = - | (HookBlockedOutcome & { kind: "veto" }) + | (HookBlockedOutcome & { kind: "veto"; genericDecision?: true }) | (HookBlockedOutcome & { kind: "failure"; disposition: BeforeToolCallFailureDisposition; @@ -116,6 +116,7 @@ export type HookOutcome = | { blocked: false; params: unknown; + ownerDecision?: true; approvalResolution?: PluginApprovalResolution; deferredApproval?: DeferredPluginToolApproval; }; diff --git a/src/agents/agent-tools.before-tool-call.wrapper.ts b/src/agents/agent-tools.before-tool-call.wrapper.ts index f32f9bb67e98..e7b65cad60eb 100644 --- a/src/agents/agent-tools.before-tool-call.wrapper.ts +++ b/src/agents/agent-tools.before-tool-call.wrapper.ts @@ -13,12 +13,14 @@ import { freezeDiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; -import { copyPluginToolMeta, getPluginToolMeta } from "../plugins/tools.js"; +import { getPluginToolMeta } from "../plugins/tools.js"; import { recordRunSkillUsage } from "../skills/runtime/run-usage.js"; +import { copyBeforeToolCallWrapperMetadata } from "./agent-tool-metadata.js"; import { copyAgentToolSourceExecutionGuard, runAgentToolSourceExecutionGuard, } from "./agent-tool-source-execution-guard.js"; +import { recordGenericToolActionDecision } from "./agent-tools.before-tool-call.decision.js"; import { buildToolContentPrivateData, emitSkillUsedDiagnostic, @@ -71,7 +73,7 @@ import { getBeforeToolCallSourceTool, type BeforeToolCallDiagnosticOptions, } from "./before-tool-call-metadata.js"; -import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js"; +import { getChannelAgentToolMeta } from "./channel-tools.js"; import { getCodeModeExecBeforeHookMetadata, normalizeCodeModeExecBeforeHookParams, @@ -86,7 +88,6 @@ import { protectNetworkToolExecutionError, registerTrustedToolNoStartError, } from "./tool-result-error.js"; -import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js"; import type { AnyAgentTool } from "./tools/common.js"; type BeforeToolCallWrapperOptions = { @@ -388,7 +389,11 @@ export function wrapToolWithBeforeToolCallHook( reason: string; deniedReason: HookBlockedReason; toolParams: unknown; + genericDecision?: true; }) => { + if (blockedCall.genericDecision) { + recordGenericToolActionDecision(tool, toolCallId, "denied"); + } const eventBase = buildEventBase(blockedCall.toolParams); if (hookOptions.emitDiagnostics) { emitTrustedDiagnosticEvent({ @@ -466,6 +471,7 @@ export function wrapToolWithBeforeToolCallHook( reason: outcome.reason, deniedReason: outcome.deniedReason ?? "plugin-before-tool-call", toolParams: outcome.params ?? hookParams, + genericDecision: outcome.genericDecision, }); } let executeParams: unknown; @@ -499,6 +505,7 @@ export function wrapToolWithBeforeToolCallHook( if (prepareControl) { const decision = await prepareControl.pause(executeParams); if (!decision.launch) { + recordGenericToolActionDecision(tool, toolCallId, "suppressed"); return INTERNAL_DISPOSED_RESULT; } onImplementationStart = decision.start; @@ -520,6 +527,9 @@ export function wrapToolWithBeforeToolCallHook( // Host capabilities can close while hooks, approval, validation, or // steering awaits. Recheck at the final synchronous source boundary. runAgentToolSourceExecutionGuard(tool); + if (!outcome.ownerDecision) { + recordGenericToolActionDecision(tool, toolCallId, "allowed"); + } onImplementationStart?.(); recordAdjustedParamsForToolCall(toolCallId, executeParams, ctx?.runId); const eventBase = buildEventBase(executeParams); @@ -682,9 +692,7 @@ export function wrapToolWithBeforeToolCallHook( prepared.dispose(); } }; - copyPluginToolMeta(tool, wrappedTool); - copyChannelAgentToolMeta(tool as never, wrappedTool as never); - copyToolTerminalPresentation(tool, wrappedTool); + copyBeforeToolCallWrapperMetadata(tool, wrappedTool); Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, { value: true, enumerable: true, @@ -721,9 +729,7 @@ export function rewrapToolWithBeforeToolCallHook( execute: sourceTool.execute, }; clearBeforeToolCallWrappedMarker(rewrapSource); - copyPluginToolMeta(tool, rewrapSource); - copyChannelAgentToolMeta(tool as never, rewrapSource as never); - copyToolTerminalPresentation(tool, rewrapSource); + copyBeforeToolCallWrapperMetadata(tool, rewrapSource); copyAgentToolSourceExecutionGuard(tool, rewrapSource); return wrapToolWithBeforeToolCallHook(rewrapSource, ctx ?? preservedContext, options); } diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 47c7f79b64e9..3ecae05e9aab 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -32,6 +32,7 @@ import type { SkillSnapshot, SkillUsagePath } from "../skills/types.js"; import type { SkillWorkshopRunOptions } from "../skills/workshop/types.js"; import { resolveGatewayMessageChannel } from "../utils/message-channel.js"; import type { OperationalRunInstanceRef } from "./admitted-run-context.js"; +import { bindAssembledAgentToolActionDescriptor } from "./agent-tool-metadata.js"; import type { ToolOutcomeObserver } from "./agent-tools.before-tool-call.js"; import { finalizeAgentTools } from "./agent-tools.finalize.js"; import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js"; @@ -976,6 +977,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) // Collector output is a run contract, not an operator-configurable capability. authorizedTools.push(swarmStructuredOutputTool); } + authorizedTools.forEach(bindAssembledAgentToolActionDescriptor); processToolAvailabilityRef.value = authorizedTools.some((tool) => tool.name === "process"); if (shouldInheritEffectiveToolAllowlist) { // Snapshot exporter only: this copies authorizedTools for descendants and diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index d461a2a82b81..c1af034d0630 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -25,7 +25,7 @@ import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setChannelAgentToolMeta } from "./channel-tool-metadata.js"; -export { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tool-metadata.js"; +export { getChannelAgentToolMeta } from "./channel-tool-metadata.js"; type ChannelMessageActionDiscoveryParams = { cfg?: OpenClawConfig; diff --git a/src/agents/core-coding-tools.ts b/src/agents/core-coding-tools.ts index 3372a7a89fe3..81c24defdcd3 100644 --- a/src/agents/core-coding-tools.ts +++ b/src/agents/core-coding-tools.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { SkillSnapshot } from "../skills/types.js"; +import { bindAgentToolActionDescriptor } from "./agent-tool-metadata.js"; import { createHostWorkspaceEditTool, createHostWorkspaceWriteTool, @@ -280,5 +281,11 @@ export function createCoreCodingTools(options: CoreCodingToolsOptions): AnyAgent } options.recordToolPrepStage?.("shell-tools"); + base.forEach((tool) => + bindAgentToolActionDescriptor(tool, { family: "data", operation: "filesystem" }), + ); + shell.forEach((tool) => + bindAgentToolActionDescriptor(tool, { family: "tool", operation: "process" }), + ); return [...base, ...shell]; } diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b960cc9257a8..68acff143586 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -8,6 +8,7 @@ import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state. import { getActiveRuntimeWebToolsMetadataFromState } from "../secrets/runtime-web-tools-state.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js"; +import { bindAssembledAgentToolActionDescriptor } from "./agent-tool-metadata.js"; import { type HookContext, isToolWrappedWithBeforeToolCallHook, @@ -615,6 +616,9 @@ export function createOpenClawTools(options?: OpenClawToolsOptions): AnyAgentToo allTools = filterToolsByClientCaps(allTools, options?.clientCaps); options?.recordToolPrepStage?.("openclaw-tools:client-capabilities"); + for (const tool of allTools) { + bindAssembledAgentToolActionDescriptor(tool); + } const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId; const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper( diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 91c7cef62b12..32ff0fb61e2a 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -1459,6 +1459,7 @@ export function createHookRunner( receipt?: Readonly<{ token: ExecutionIdentityAdmissionToken; assertAuthority: () => boolean | void; + markOwnerDecision?: () => void; }>, ): Promise { return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>( @@ -1495,22 +1496,26 @@ export function createHookRunner( }, shouldStop: (result) => result.block === true, terminalLabel: "block=true", - onHandlerResult: ({ hook, result }) => + onHandlerResult: ({ hook, result }) => { + receipt?.markOwnerDecision?.(); recordBeforeToolCallDecision({ event, hook, token: receipt?.token, result, receiptAuthority: receipt?.assertAuthority, - }), - onHandlerError: (hook, failOpen) => + }); + }, + onHandlerError: (hook, failOpen) => { + receipt?.markOwnerDecision?.(); recordBeforeToolCallDecision({ event, hook, token: receipt?.token, failOpen, receiptAuthority: receipt?.assertAuthority, - }), + }); + }, }, event.toolName, ); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 6e6243a9ad54..5831e443cb89 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -76,6 +76,7 @@ export type PluginToolMcpMeta = { /** Runtime metadata used to trace an agent tool back to its owning plugin registration. */ type PluginToolMeta = { pluginId: string; + kind?: PluginManifestRecord["kind"]; optional: boolean; replaySafe?: boolean; sideEffecting?: boolean; @@ -904,6 +905,7 @@ function createCachedDescriptorPluginTool(params: { } setPluginToolMeta(tool, { pluginId, + ...(params.plugin.kind ? { kind: params.plugin.kind } : {}), optional: params.descriptor.optional, replaySafe: isManifestToolReplaySafe({ manifestPlugin: params.plugin, @@ -1555,6 +1557,7 @@ export function resolvePluginTools(params: { }); pluginToolMeta.set(tool, { pluginId: entry.pluginId, + ...(manifestPlugin?.kind ? { kind: manifestPlugin.kind } : {}), optional, replaySafe: isManifestToolReplaySafe({ manifestPlugin,