mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(agents): apply tool policy to Anthropic native calls (#128805)
* fix(agents): enforce native policy in Anthropic SDK * fix(agents): enforce canonical native tool policies safely Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -136,11 +136,14 @@ claude update
|
||||
The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run.
|
||||
|
||||
The Agent SDK always runs with Claude Code's default permission mode.
|
||||
OpenClaw's existing effective exec policy remains authoritative through SDK
|
||||
permission callbacks and a `PreToolUse` hook, including when user settings
|
||||
would otherwise preapprove a tool. Per-agent and session restrictions still
|
||||
override broader global policy. OpenClaw-owned MCP tools remain authorized by
|
||||
the Gateway rather than receiving a second Claude-native approval.
|
||||
OpenClaw's SDK permission callback and `PreToolUse` hook keep native tools under
|
||||
host control, including when user or enterprise settings would otherwise
|
||||
preapprove a call. Native requests pass through canonical `before_tool_call`
|
||||
policy before exec policy and approval, with native tool names and file
|
||||
arguments projected into their OpenClaw equivalents. Per-agent and session
|
||||
restrictions still override broader global policy. OpenClaw-owned MCP tools
|
||||
remain authorized by the Gateway rather than receiving duplicate native
|
||||
approval; other MCP tools stay host-permission controlled.
|
||||
|
||||
When the effective exec ask setting is `on-miss` or `always`, OpenClaw relays
|
||||
native or extension tool requests as interactive approvals to the session's
|
||||
|
||||
@@ -107,9 +107,11 @@ OpenClaw release:
|
||||
OpenClaw detects the existing Claude CLI login. Normal agent turns use
|
||||
the official Agent SDK with the installed, authenticated Claude Code
|
||||
executable, including native-tool turns whose approvals remain under
|
||||
OpenClaw control. Imported native OAuth profiles reuse the verified
|
||||
Claude Code login; explicitly selected API-key or token credentials
|
||||
still use protected file-descriptor forwarding. Isolated side-question
|
||||
OpenClaw control. Schema-valid native calls pass through OpenClaw's
|
||||
canonical tool policy before native approval. Imported native OAuth
|
||||
profiles reuse the verified Claude Code login; explicitly selected
|
||||
API-key or token credentials still use protected file-descriptor
|
||||
forwarding. Isolated side-question
|
||||
completions and paired-node execution retain the supervised CLI path.
|
||||
|
||||
Consecutive agent turns reuse the same warm Agent SDK query and Claude
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CliBackendExecute,
|
||||
CliBackendExecuteContext,
|
||||
CliBackendToolPermissionResult,
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "../../plugins/hook-runner-global.js";
|
||||
import type { PluginHookHandlerMap } from "../../plugins/hook-types.js";
|
||||
import { createMockPluginRegistry } from "../../plugins/hooks.test-fixtures.js";
|
||||
import { prepareSystemAgentRunAdmission } from "../admitted-run-context.js";
|
||||
import * as beforeToolCall from "../agent-tools.before-tool-call.js";
|
||||
import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js";
|
||||
import { callGatewayTool } from "../tools/gateway.js";
|
||||
import { executePluginOwnedProcess } from "./execute-plugin.js";
|
||||
import type { PreparedCliRunContext } from "./types.js";
|
||||
|
||||
vi.mock("../tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
const activeAdmissions: Array<ReturnType<typeof prepareSystemAgentRunAdmission>> = [];
|
||||
let nextRunId = 0;
|
||||
|
||||
const SUCCESS_RESULT = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
result: "completed",
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
|
||||
function installBeforeToolCallHook(
|
||||
handler: PluginHookHandlerMap["before_tool_call"],
|
||||
matcher?: [string, ...string[]],
|
||||
) {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_tool_call",
|
||||
handler: (...args) => Reflect.apply(handler, undefined, args),
|
||||
...(matcher ? { matcher } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function createExecution(
|
||||
options: { nativeTools?: string[]; abortSignal?: AbortSignal } = {},
|
||||
) {
|
||||
const runId = "plugin-policy-" + ++nextRunId;
|
||||
const config = { tools: { exec: { security: "full" as const, ask: "off" as const } } };
|
||||
const admission = prepareSystemAgentRunAdmission(config, runId, "main", "plugin-test");
|
||||
activeAdmissions.push(admission);
|
||||
const context = buildPreparedCliRunContext({
|
||||
provider: "claude-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
agentId: "main",
|
||||
runId,
|
||||
sessionId: "sdk-session",
|
||||
sessionKey: "agent:main:main",
|
||||
prompt: "hello",
|
||||
config,
|
||||
executionMode: "agent",
|
||||
timeoutMs: 5_000,
|
||||
...(options.nativeTools
|
||||
? { cliToolAvailability: { native: options.nativeTools, openClaw: [] } }
|
||||
: {}),
|
||||
systemPrompt: "Follow host policy.",
|
||||
backend: { command: "/bin/sh", args: [] },
|
||||
});
|
||||
context.params.admittedRunContext = await admission.admit("plugin-harness");
|
||||
if (options.abortSignal) {
|
||||
context.params.abortSignal = options.abortSignal;
|
||||
}
|
||||
return { admission, context };
|
||||
}
|
||||
|
||||
function runPlugin(context: PreparedCliRunContext, execute: CliBackendExecute) {
|
||||
return executePluginOwnedProcess({
|
||||
context,
|
||||
execute,
|
||||
executionCommand: "/bin/sh",
|
||||
executionArgs: ["-p", "--permission-mode", "bypassPermissions"],
|
||||
env: { PATH: "/bin:/usr/bin" },
|
||||
prompt: context.params.prompt,
|
||||
useResume: false,
|
||||
sessionId: "sdk-session",
|
||||
noOutputTimeoutMs: 2_000,
|
||||
consumeStdout: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function requestNativeTool(
|
||||
execution: CliBackendExecuteContext,
|
||||
toolName = "Bash",
|
||||
toolInput: Record<string, unknown> = { command: "echo approved" },
|
||||
) {
|
||||
return execution.requestToolPermission({
|
||||
toolName,
|
||||
toolInput,
|
||||
toolCallId: "native-" + toolName,
|
||||
...(execution.abortSignal ? { abortSignal: execution.abortSignal } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
for (const admission of activeAdmissions.splice(0)) {
|
||||
admission.close();
|
||||
}
|
||||
mockCallGatewayTool.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin-owned CLI native tool policy", () => {
|
||||
it("runs canonical policy before native approval and carries rewritten params plus run context", async () => {
|
||||
const policy = vi.spyOn(beforeToolCall, "runBeforeToolCallHook");
|
||||
const hook = vi.fn(async (_event: unknown, _context: unknown) => ({
|
||||
params: { url: "https://example.com/rewritten" },
|
||||
}));
|
||||
installBeforeToolCallHook(hook);
|
||||
const { context } = await createExecution({ nativeTools: ["WebFetch"] });
|
||||
Object.assign(context.params, {
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "chat-1",
|
||||
chatId: "chat-1",
|
||||
agentAccountId: "bot-1",
|
||||
senderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
currentThreadTs: "thread-1",
|
||||
});
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, "WebFetch", {
|
||||
url: "https://example.com/original",
|
||||
});
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: { url: "https://example.com/rewritten" },
|
||||
});
|
||||
expect(hook).toHaveBeenCalledOnce();
|
||||
expect(hook.mock.calls[0]?.[0]).toMatchObject({
|
||||
toolName: "web_fetch",
|
||||
params: { url: "https://example.com/original" },
|
||||
toolCallId: "native-WebFetch",
|
||||
runId: context.params.runId,
|
||||
});
|
||||
expect(hook.mock.calls[0]?.[1]).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sdk-session",
|
||||
runId: context.params.runId,
|
||||
channelId: "chat-1",
|
||||
requester: {
|
||||
channel: "telegram",
|
||||
accountId: "bot-1",
|
||||
senderId: "user-1",
|
||||
senderIsOwner: true,
|
||||
},
|
||||
});
|
||||
expect(policy.mock.calls[0]?.[0]).toMatchObject({
|
||||
ctx: {
|
||||
config: context.params.config,
|
||||
cwd: "/tmp",
|
||||
workspaceDir: "/tmp",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "chat-1",
|
||||
turnSourceAccountId: "bot-1",
|
||||
turnSourceThreadId: "thread-1",
|
||||
loopDetection: undefined,
|
||||
},
|
||||
});
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ native: "Bash", canonical: "exec", input: { command: "echo blocked" } },
|
||||
{
|
||||
native: "WebFetch",
|
||||
canonical: "web_fetch",
|
||||
input: { url: "https://example.com", prompt: "summarize" },
|
||||
},
|
||||
{ native: "WebSearch", canonical: "web_search", input: { query: "blocked" } },
|
||||
])(
|
||||
"applies matched $canonical policy to native $native",
|
||||
async ({ native, canonical, input }) => {
|
||||
const hook = vi.fn(async () => ({ block: true, blockReason: `${canonical} blocked` }));
|
||||
installBeforeToolCallHook(hook, [canonical]);
|
||||
const { context } = await createExecution({ nativeTools: [native] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, native, input);
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ behavior: "deny", message: `${canonical} blocked` });
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolName: canonical, params: input }),
|
||||
expect.objectContaining({ toolName: canonical }),
|
||||
);
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ native: "Read", canonical: "read", input: { file_path: "/tmp/private.txt" } },
|
||||
{
|
||||
native: "Write",
|
||||
canonical: "write",
|
||||
input: { file_path: "/tmp/private.txt", content: "private" },
|
||||
},
|
||||
{
|
||||
native: "Edit",
|
||||
canonical: "edit",
|
||||
input: { file_path: "/tmp/private.txt", old_string: "old", new_string: "new" },
|
||||
},
|
||||
])(
|
||||
"applies path-based $canonical policy to native $native",
|
||||
async ({ native, canonical, input }) => {
|
||||
const hook = vi.fn(async (event: { params: Record<string, unknown> }) =>
|
||||
event.params.path === "/tmp/private.txt"
|
||||
? { block: true, blockReason: "private path blocked" }
|
||||
: undefined,
|
||||
);
|
||||
installBeforeToolCallHook(hook, [canonical]);
|
||||
const { context } = await createExecution({ nativeTools: [native] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, native, input);
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ behavior: "deny", message: "private path blocked" });
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: canonical,
|
||||
params: expect.objectContaining({ path: "/tmp/private.txt" }),
|
||||
}),
|
||||
expect.objectContaining({ toolName: canonical }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("projects rewritten canonical file arguments back into the native Edit schema", async () => {
|
||||
const hook = vi.fn(async () => ({
|
||||
params: {
|
||||
path: "/tmp/approved.txt",
|
||||
edits: [{ oldText: "safe-before", newText: "safe-after" }],
|
||||
},
|
||||
}));
|
||||
installBeforeToolCallHook(hook, ["edit"]);
|
||||
const { context } = await createExecution({ nativeTools: ["Edit"] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, "Edit", {
|
||||
file_path: "/tmp/original.txt",
|
||||
old_string: "before",
|
||||
new_string: "after",
|
||||
replace_all: false,
|
||||
});
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(hook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: "edit",
|
||||
params: expect.objectContaining({
|
||||
path: "/tmp/original.txt",
|
||||
edits: [{ oldText: "before", newText: "after" }],
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(decision).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
file_path: "/tmp/approved.txt",
|
||||
old_string: "safe-before",
|
||||
new_string: "safe-after",
|
||||
replace_all: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects conflicting native and canonical paths before invoking policy", async () => {
|
||||
const hook = vi.fn(async () => undefined);
|
||||
installBeforeToolCallHook(hook, ["read"]);
|
||||
const { context } = await createExecution({ nativeTools: ["Read"] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, "Read", {
|
||||
file_path: "/tmp/private.txt",
|
||||
path: "/tmp/allowed.txt",
|
||||
});
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
behavior: "deny",
|
||||
message: expect.stringContaining("conflicting"),
|
||||
}),
|
||||
);
|
||||
expect(hook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects canonical edit rewrites that the native tool cannot represent", async () => {
|
||||
const hook = vi.fn(async () => ({
|
||||
params: {
|
||||
edits: [
|
||||
{ oldText: "first", newText: "one" },
|
||||
{ oldText: "second", newText: "two" },
|
||||
],
|
||||
},
|
||||
}));
|
||||
installBeforeToolCallHook(hook, ["edit"]);
|
||||
const { context } = await createExecution({ nativeTools: ["Edit"] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution, "Edit", {
|
||||
file_path: "/tmp/file.txt",
|
||||
old_string: "before",
|
||||
new_string: "after",
|
||||
});
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
behavior: "deny",
|
||||
message: expect.stringContaining("native edit"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks",
|
||||
handler: vi.fn(async () => ({ block: true, blockReason: "blocked by plugin policy" })),
|
||||
message: "blocked by plugin policy",
|
||||
},
|
||||
{
|
||||
name: "fails",
|
||||
handler: vi.fn(async () => {
|
||||
throw new Error("policy crashed");
|
||||
}),
|
||||
message: "before_tool_call hook failed",
|
||||
},
|
||||
])("fails closed when before_tool_call $name", async ({ handler, message }) => {
|
||||
installBeforeToolCallHook(handler);
|
||||
const { context } = await createExecution({ nativeTools: ["Bash"] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution);
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({ behavior: "deny", message: expect.stringContaining(message) }),
|
||||
);
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts before_tool_call without reaching native approval", async () => {
|
||||
const controller = new AbortController();
|
||||
const hook = vi.fn(
|
||||
async (_event: unknown, hookContext: { abortSignal?: AbortSignal }): Promise<undefined> =>
|
||||
await new Promise((_, reject) => {
|
||||
hookContext.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("policy aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
installBeforeToolCallHook(hook);
|
||||
const { context } = await createExecution({
|
||||
abortSignal: controller.signal,
|
||||
nativeTools: ["Bash"],
|
||||
});
|
||||
const run = runPlugin(context, async function* (execution) {
|
||||
await requestNativeTool(execution);
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
await vi.waitFor(() => expect(hook).toHaveBeenCalledOnce());
|
||||
|
||||
controller.abort(new Error("cancel policy"));
|
||||
|
||||
await expect(run).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when canonical policy returns a non-record rewrite", async () => {
|
||||
vi.spyOn(beforeToolCall, "runBeforeToolCallHook").mockResolvedValueOnce({
|
||||
blocked: false,
|
||||
params: "invalid",
|
||||
});
|
||||
const { context } = await createExecution({ nativeTools: ["Bash"] });
|
||||
let decision: CliBackendToolPermissionResult | undefined;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
decision = await requestNativeTool(execution);
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
behavior: "deny",
|
||||
message: expect.stringContaining("invalid input"),
|
||||
}),
|
||||
);
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,14 @@ import type {
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import type { RunExit, TerminationReason } from "../../process/supervisor/types.js";
|
||||
import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js";
|
||||
import { runBeforeToolCallHook } from "../agent-tools.before-tool-call.js";
|
||||
import type { CliTerminalInterruption } from "../cli-output-contracts.js";
|
||||
import { resolveExecDefaults } from "../exec-defaults.js";
|
||||
import { isSignalTimeoutReason, type FailoverError } from "../failover-error.js";
|
||||
import { runStructuredInput } from "../harness/structured-input-execution.js";
|
||||
import { compileStructuredInputQuestions } from "../harness/structured-input.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
|
||||
import { normalizeToolPolicyName } from "../tool-policy.js";
|
||||
import { callGatewayTool } from "../tools/gateway.js";
|
||||
import {
|
||||
closeCliLiveSession,
|
||||
@@ -75,6 +78,123 @@ function createPluginToolPermissionHandler(params: {
|
||||
return denyTool(`OpenClaw denied native tool ${toolName}: it is unavailable to this run.`);
|
||||
}
|
||||
|
||||
// Provider schemas are not policy schemas: match canonical names and file operands.
|
||||
const canonicalToolName = normalizeToolPolicyName(
|
||||
toolName.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2"),
|
||||
);
|
||||
const nativeFileTool =
|
||||
["read", "write", "edit"].includes(canonicalToolName) &&
|
||||
Object.hasOwn(request.toolInput, "file_path");
|
||||
let policyInput = request.toolInput;
|
||||
if (nativeFileTool) {
|
||||
const nativePath = request.toolInput.file_path;
|
||||
if (typeof nativePath !== "string") {
|
||||
return denyTool("OpenClaw denied native file tool use: invalid file path.");
|
||||
}
|
||||
if (Object.hasOwn(request.toolInput, "path") && request.toolInput.path !== nativePath) {
|
||||
return denyTool("OpenClaw denied native file tool use: conflicting file paths.");
|
||||
}
|
||||
policyInput = { ...request.toolInput, path: nativePath };
|
||||
if (canonicalToolName === "edit") {
|
||||
const { old_string: oldText, new_string: newText, edits } = request.toolInput;
|
||||
if (typeof oldText !== "string" || typeof newText !== "string") {
|
||||
return denyTool("OpenClaw denied native edit tool use: invalid replacement.");
|
||||
}
|
||||
if (
|
||||
edits !== undefined &&
|
||||
(!Array.isArray(edits) ||
|
||||
edits.length !== 1 ||
|
||||
!isRecord(edits[0]) ||
|
||||
edits[0].oldText !== oldText ||
|
||||
edits[0].newText !== newText)
|
||||
) {
|
||||
return denyTool("OpenClaw denied native edit tool use: conflicting replacements.");
|
||||
}
|
||||
policyInput.edits = [{ oldText, newText }];
|
||||
}
|
||||
}
|
||||
|
||||
const requester = {
|
||||
...((run.messageChannel ?? run.messageProvider)
|
||||
? { channel: run.messageChannel ?? run.messageProvider }
|
||||
: {}),
|
||||
...(run.agentAccountId ? { accountId: run.agentAccountId } : {}),
|
||||
...(run.senderId ? { senderId: run.senderId } : {}),
|
||||
...(run.senderIsOwner !== undefined ? { senderIsOwner: run.senderIsOwner } : {}),
|
||||
};
|
||||
const hookResult = await runBeforeToolCallHook({
|
||||
toolName: canonicalToolName,
|
||||
params: policyInput,
|
||||
...(request.toolCallId ? { toolCallId: request.toolCallId } : {}),
|
||||
signal,
|
||||
ctx: {
|
||||
...(run.agentId ? { agentId: run.agentId } : {}),
|
||||
...(run.config ? { config: run.config } : {}),
|
||||
cwd: params.context.cwd ?? params.context.workspaceDir,
|
||||
workspaceDir: params.context.workspaceDir,
|
||||
...(run.sessionKey ? { sessionKey: run.sessionKey } : {}),
|
||||
sessionId: run.sessionId,
|
||||
runId: run.runId,
|
||||
...(run.trigger ? { trigger: run.trigger } : {}),
|
||||
...(run.approvalReviewerDeviceId
|
||||
? { approvalReviewerDeviceId: run.approvalReviewerDeviceId }
|
||||
: {}),
|
||||
...(run.currentChannelId ? { channelId: run.currentChannelId } : {}),
|
||||
...(Object.keys(requester).length > 0 ? { requester } : {}),
|
||||
turnSourceChannel: run.messageChannel ?? run.messageProvider,
|
||||
turnSourceTo: run.chatId ?? run.currentChannelId,
|
||||
turnSourceAccountId: run.agentAccountId,
|
||||
turnSourceThreadId: run.currentThreadTs,
|
||||
loopDetection: resolveToolLoopDetectionConfig({
|
||||
cfg: run.config,
|
||||
agentId: run.agentId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
assertActive();
|
||||
} catch {
|
||||
return denyTool("OpenClaw denied native tool use: the admitted run closed during policy.");
|
||||
}
|
||||
if (hookResult.blocked) {
|
||||
return denyTool(hookResult.reason);
|
||||
}
|
||||
if (!isRecord(hookResult.params)) {
|
||||
return denyTool("OpenClaw denied native tool use: before_tool_call returned invalid input.");
|
||||
}
|
||||
let toolInput = hookResult.params;
|
||||
// SDK permission replies must return the native schema, never policy-only aliases.
|
||||
if (nativeFileTool) {
|
||||
if (typeof toolInput.path !== "string") {
|
||||
return denyTool("OpenClaw denied native file tool use: invalid rewritten file path.");
|
||||
}
|
||||
if (toolInput === policyInput) {
|
||||
toolInput = request.toolInput;
|
||||
} else {
|
||||
toolInput = { ...toolInput, file_path: toolInput.path };
|
||||
if (!Object.hasOwn(request.toolInput, "path")) {
|
||||
delete toolInput.path;
|
||||
}
|
||||
if (canonicalToolName === "edit") {
|
||||
const edits = toolInput.edits;
|
||||
if (
|
||||
!Array.isArray(edits) ||
|
||||
edits.length !== 1 ||
|
||||
!isRecord(edits[0]) ||
|
||||
typeof edits[0].oldText !== "string" ||
|
||||
typeof edits[0].newText !== "string"
|
||||
) {
|
||||
return denyTool("OpenClaw denied an unrepresentable native edit rewrite.");
|
||||
}
|
||||
toolInput.old_string = edits[0].oldText;
|
||||
toolInput.new_string = edits[0].newText;
|
||||
if (!Object.hasOwn(request.toolInput, "edits")) {
|
||||
delete toolInput.edits;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plan = resolveCliNativeToolApprovalPlan(permission);
|
||||
if (plan === "deny") {
|
||||
return denyTool(
|
||||
@@ -84,7 +204,7 @@ function createPluginToolPermissionHandler(params: {
|
||||
const currentGrants = getCliLiveSessionApprovalGrants(params.context) ?? grants;
|
||||
if (plan === "allow" || (permission.ask !== "always" && currentGrants.has(toolName))) {
|
||||
assertActive();
|
||||
return { behavior: "allow", updatedInput: request.toolInput };
|
||||
return { behavior: "allow", updatedInput: toolInput };
|
||||
}
|
||||
|
||||
params.onPendingApproval(1);
|
||||
@@ -92,7 +212,7 @@ function createPluginToolPermissionHandler(params: {
|
||||
try {
|
||||
outcome = await requestCliNativeToolApproval({
|
||||
toolName,
|
||||
toolInput: request.toolInput,
|
||||
toolInput,
|
||||
pluginId: params.context.backendResolved.id,
|
||||
sessionKey: run.sessionKey,
|
||||
agentId: run.agentId,
|
||||
@@ -122,7 +242,7 @@ function createPluginToolPermissionHandler(params: {
|
||||
if (outcome.grantAlways) {
|
||||
currentGrants.add(toolName);
|
||||
}
|
||||
return { behavior: "allow", updatedInput: request.toolInput };
|
||||
return { behavior: "allow", updatedInput: toolInput };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,16 @@ describe("plugin tool hook matchers", () => {
|
||||
"apply_patch",
|
||||
"exec",
|
||||
]);
|
||||
expect(normalizePluginToolMatcher(["read", "write", "edit"])).toEqual([
|
||||
"edit",
|
||||
"read",
|
||||
"write",
|
||||
]);
|
||||
expect(pluginToolMatcherCoversTool(["exec"], "exec")).toBe(true);
|
||||
expect(pluginToolMatcherCoversTool(["exec"], "Bash")).toBe(false);
|
||||
expect(pluginToolMatcherCoversTool(["apply_patch"], "Write")).toBe(false);
|
||||
expect(pluginToolMatcherCoversTool(["write"], "write")).toBe(true);
|
||||
expect(pluginToolMatcherCoversTool(["edit"], "edit")).toBe(true);
|
||||
expect(pluginToolMatcherCoversTool(["spawn_agent"], "Agent")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ const NON_CANONICAL_TOOL_MATCHER_NAMES = new Set([
|
||||
"bash",
|
||||
"exec_command",
|
||||
"apply-patch",
|
||||
"write",
|
||||
"edit",
|
||||
"Write",
|
||||
"Edit",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
@@ -44,7 +44,10 @@ export function normalizePluginToolMatcher(matcher: unknown): PluginToolMatcher
|
||||
if (canonicalToolName === "*") {
|
||||
throw new TypeError("tool hook matcher wildcard entries are not supported");
|
||||
}
|
||||
if (NON_CANONICAL_TOOL_MATCHER_NAMES.has(canonicalToolName)) {
|
||||
if (
|
||||
NON_CANONICAL_TOOL_MATCHER_NAMES.has(canonicalToolName) ||
|
||||
NON_CANONICAL_TOOL_MATCHER_NAMES.has(toolName.trim())
|
||||
) {
|
||||
throw new TypeError("tool hook matcher entries must use canonical OpenClaw tool ids");
|
||||
}
|
||||
normalized.add(canonicalToolName);
|
||||
|
||||
Reference in New Issue
Block a user