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
This commit is contained in:
Josh Avant
2026-08-26 17:13:52 -07:00
committed by GitHub
parent 64f30c70d7
commit 29625bc310
14 changed files with 708 additions and 20 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+54 -1
View File
@@ -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<AnyAgentTool, AgentToolActionDescriptor>();
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<T extends AnyAgentTool>(source: AnyAgentTo
copyToolTerminalPresentation(source, target);
copyCodeModeControlToolIdentity(source, target);
copyInternalToolExecutionPreparer(source, target);
copyAgentToolActionDescriptor(source, target);
return target;
}
@@ -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<unknown>;
}) {
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);
});
});
@@ -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;
}
}
@@ -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;
}
@@ -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;
};
@@ -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);
}
+2
View File
@@ -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
+1 -1
View File
@@ -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;
+7
View File
@@ -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];
}
+4
View File
@@ -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(
+9 -4
View File
@@ -1459,6 +1459,7 @@ export function createHookRunner(
receipt?: Readonly<{
token: ExecutionIdentityAdmissionToken;
assertAuthority: () => boolean | void;
markOwnerDecision?: () => void;
}>,
): Promise<PluginHookBeforeToolCallResult | undefined> {
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,
);
+3
View File
@@ -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,