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:
Vincent Koc
2026-08-25 14:58:23 +08:00
committed by GitHub
parent b6df91fdd9
commit df83ff516f
6 changed files with 578 additions and 14 deletions
+8 -5
View File
@@ -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
+5 -3
View File
@@ -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();
});
});
+123 -3
View File
@@ -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 };
};
}
+7
View File
@@ -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);
});
+6 -3
View File
@@ -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);