mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user