mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(agents): relay Claude native tool requests as Gateway approvals (#112918)
* feat(agents): relay Claude native tool requests as Gateway approvals * fix(agents): keep approval relay types local and refresh docs map
This commit is contained in:
committed by
GitHub
parent
6a90ad0573
commit
a229456f48
@@ -3158,6 +3158,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Resolve config secrets with op
|
||||
- H2: Service account setup for headless Gateways
|
||||
- H2: The 1password skill for agents
|
||||
- H2: Browser sign-in with 1Password for Claude
|
||||
- H2: Security notes
|
||||
- H2: Troubleshooting
|
||||
|
||||
@@ -3255,6 +3256,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: How it works
|
||||
- H2: Timeouts and long-running work
|
||||
- H3: Claude CLI specifics
|
||||
- H3: Claude browser tools and 1Password sign-in
|
||||
- H2: Sessions
|
||||
- H2: Fallback prelude from claude-cli sessions
|
||||
- H2: Images
|
||||
|
||||
@@ -7,10 +7,11 @@ read_when:
|
||||
title: "1Password"
|
||||
---
|
||||
|
||||
OpenClaw pairs with **1Password** in two independent ways:
|
||||
OpenClaw pairs with **1Password** in three independent ways:
|
||||
|
||||
- **Config secrets:** any [SecretRef](/gateway/secrets) field in `openclaw.json` can resolve through the `op` CLI at runtime, so API keys never live in the config file.
|
||||
- **Agent workflows:** the bundled `1password` skill teaches agents to sign in and read or inject secrets with `op` for their own tasks.
|
||||
- **Browser sign-in:** the `claude-cli` backend can use Claude Code's Chrome integration with [1Password for Claude](https://support.1password.com/1password-claude/), letting the agent sign in to websites without the password ever reaching the model or OpenClaw.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -76,6 +77,22 @@ OpenClaw bundles a `1password` skill that turns agents into competent `op` opera
|
||||
|
||||
Agents use it for their own workflows, for example reading a deploy token mid-task or injecting env vars into a command. It is independent of config secret resolution; the Gateway resolves SecretRefs without any skill involved.
|
||||
|
||||
## Browser sign-in with 1Password for Claude
|
||||
|
||||
[1Password for Claude](https://support.1password.com/1password-claude/) lets Claude request a login while the 1Password browser extension fills the credential directly into the page over an encrypted channel. The secret never enters the model context, the transcript, or OpenClaw. When OpenClaw runs the [`claude-cli` backend](/gateway/cli-backends#claude-cli-specifics) with Claude Code's Chrome integration enabled, agent tasks can use that flow for websites that need a real signed-in session.
|
||||
|
||||
What this requires, beyond the backend itself:
|
||||
|
||||
- A macOS gateway host with Chrome, the [Claude in Chrome extension](https://code.claude.com/docs/en/chrome) connected, the 1Password desktop app, and the 1Password browser extension (both 8.12.28 or later).
|
||||
- Claude Code signed in to a direct Anthropic plan (Pro, Max, Team, or Enterprise). Chrome integration is not available through Amazon Bedrock, Google Cloud, or other third-party providers.
|
||||
- The one-time 1Password connection on the Anthropic side: 1Password for Claude is set up through the Claude desktop app or extension flow described in [1Password's guide](https://support.1password.com/1password-claude/), and it is currently a macOS beta. On 1Password Business, an administrator must first enable "Allow AI agents to autofill for users" under Policies; Anthropic Team/Enterprise plans also ship with the integration off until an Owner enables it.
|
||||
- A [CLI backend plugin](/plugins/cli-backend-plugins) that adds `--chrome` to the Claude launch args; the bundled backend does not enable Chrome.
|
||||
- A person at the gateway host: every credential use shows a 1Password prompt confirmed there (for example with Touch ID). Under a restrictive exec policy the browser tool calls themselves are also relayed to your channel as OpenClaw approvals first.
|
||||
|
||||
Before wiring this into OpenClaw, verify the pieces in an interactive session on the gateway host: run `claude --chrome`, confirm the extension connects, and check that the `claude-in-chrome` tools include the credential tools. If they do not appear there, they will not appear through OpenClaw either.
|
||||
|
||||
One-time passcodes are filled by 1Password on the same page; never relay verification codes or passwords through chat. Headless or remote gateways cannot use this flow today because the approval and the browser both live on the gateway host.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Secret values resolved through exec providers stay in Gateway memory; config snapshots and `config.get` responses redact SecretRef fields.
|
||||
|
||||
@@ -118,7 +118,13 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f
|
||||
|
||||
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.
|
||||
|
||||
Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.mode: "full"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. Per-agent `agents.entries.*.tools.exec` settings override the global `tools.exec` for that agent. The Anthropic plugin normalizes Claude's permission flags to match the effective policy and host restriction.
|
||||
Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.mode: "full"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root. Per-agent `agents.entries.*.tools.exec` settings override the global `tools.exec` for that agent. The Anthropic plugin normalizes Claude's permission flags to match the effective policy and host restriction.
|
||||
|
||||
Under a restrictive policy, Claude asks OpenClaw over stdio before using one of its native or extension tools (its own Bash, WebFetch, or Claude in Chrome browser tools). When the effective exec ask setting is `on-miss` or `always`, OpenClaw relays each request as an interactive approval to the session's channel: **Allow once** permits the single call, **Allow always** permits that tool name for the rest of the live Claude session (in memory only, never persisted), and **Deny**, a timeout, or an unreachable approval route all deny the call. Policies that never prompt keep their old behavior: `security: "deny"` rejects every request, and ask `off` with less than full security (exec mode `allowlist`) denies without asking.
|
||||
|
||||
### Claude browser tools and 1Password sign-in
|
||||
|
||||
Claude Code can drive a Chrome browser through the [Claude in Chrome extension](https://code.claude.com/docs/en/chrome), including [1Password for Claude](/gateway/1password#browser-sign-in-with-1password-for-claude) credential autofill. The bundled backend does not enable it; register a [CLI backend plugin](/plugins/cli-backend-plugins) that appends `--chrome` to the launch args of a `claude-stream-json`-dialect backend. OpenClaw preserves a configured `--chrome` on normal runs and always forces `--no-chrome` on runs with a restricted tool policy, such as side questions. The Chrome window, the extension, and any 1Password approval prompts live on the gateway host, so someone must be at that machine to approve credential use.
|
||||
|
||||
The backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. This keeps the supported Fable 5 effort levels the same for subscription-backed Claude CLI and API-key routes. `adaptive` removes configured `--effort` flags and supplies no replacement, so Claude Code resolves effective effort from its own environment, settings, and model defaults. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI.
|
||||
|
||||
|
||||
@@ -379,10 +379,13 @@ describe("resolveClaudeCliExecutionArgs", () => {
|
||||
});
|
||||
|
||||
it("preserves Claude customizations when no exact per-run tool restriction exists", () => {
|
||||
// --chrome passthrough is the seam for browser sign-in (for example 1Password
|
||||
// agentic autofill); restricted runs above must keep forcing --no-chrome.
|
||||
const baseArgs = [
|
||||
"-p",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--chrome",
|
||||
"--plugin-dir",
|
||||
"/tmp/plugin",
|
||||
"--agents",
|
||||
|
||||
@@ -103,6 +103,15 @@ Keep the tmux session running so later `op read` / `op run` commands reuse the s
|
||||
|
||||
Use the same `SOCKET` and `SESSION` values for every follow-up command in this standalone signin flow. The `-S "$SOCKET"` flag selects the tmux server socket; keep it in a user-owned `0700` directory, do not share it between users, and choose a new session name for each new signin attempt.
|
||||
|
||||
## Browser sign-in (1Password for Claude)
|
||||
|
||||
If the session exposes Claude in Chrome credential tools (`request_credentials`, `autofill_credential`, `enter_verification_code`), prefer them over `op` for signing in to websites: 1Password fills the page directly and the secret never enters context. Rules:
|
||||
|
||||
- Request every credential the task needs in one `request_credentials` call before navigating.
|
||||
- Approval is a 1Password prompt on the gateway host. If it stays pending, tell the user which host to unlock ("1Password is waiting for approval on this Mac") instead of retrying.
|
||||
- Never ask the user to send passwords or one-time codes through chat; codes go through `enter_verification_code` only.
|
||||
- Do not fall back to `op read` for a website password just because the browser flow needs approval; that defeats the no-exposure design. Use `op` for secrets consumed by commands and config, not for web logins when the browser flow exists.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never paste secrets into logs, chat, or code.
|
||||
|
||||
@@ -83,6 +83,7 @@ import { cliBackendLog, formatCliBackendOutputDigest } from "./cli-runner/log.js
|
||||
import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.test-support.js";
|
||||
import type { PreparedCliRunContext } from "./cli-runner/types.js";
|
||||
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
// Gateway unit coverage owns quiet-admission timing. These spawn cases only
|
||||
// need to drain calls already in flight, so skip the repeated 250 ms quiet window.
|
||||
@@ -106,6 +107,12 @@ vi.mock("../plugin-sdk/anthropic-cli.js", () => ({
|
||||
isClaudeCliProvider: (providerId: string) => providerId === "claude-cli",
|
||||
}));
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
|
||||
type ProcessSupervisor = ReturnType<typeof getProcessSupervisor>;
|
||||
type SupervisorSpawnFn = ProcessSupervisor["spawn"];
|
||||
type ClaudeControlPolicyTestCase = {
|
||||
@@ -138,6 +145,8 @@ beforeEach(() => {
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
});
|
||||
supervisorSpawnMock.mockClear();
|
||||
mockCallGatewayTool.mockReset();
|
||||
mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2815,6 +2824,110 @@ describe("runCliAgent spawn path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("honors allow-once from a Claude native tool Gateway approval", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({
|
||||
id: "claude-native-allow-once",
|
||||
decision: "allow-once",
|
||||
});
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
events: buildClaudeControlRequestEvents({
|
||||
requestId: "req-allow-once",
|
||||
toolUseId: "tool-allow-once-1",
|
||||
input: { command: "ls" },
|
||||
sessionId: "live-control-allow-once",
|
||||
}),
|
||||
pid: 3011,
|
||||
});
|
||||
|
||||
const result = await executePreparedCliRun(
|
||||
buildClaudeLiveRunContext({
|
||||
prompt: "hello",
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toBe("ok");
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true),
|
||||
);
|
||||
expectClaudeControlDecision(live, {
|
||||
behavior: "allow",
|
||||
requestId: "req-allow-once",
|
||||
toolUseId: "tool-allow-once-1",
|
||||
updatedInput: { command: "ls" },
|
||||
});
|
||||
expect(mockCallGatewayTool).toHaveBeenCalledWith(
|
||||
"plugin.approval.request",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
pluginId: "claude-cli",
|
||||
toolName: "Bash",
|
||||
toolCallId: "tool-allow-once-1",
|
||||
}),
|
||||
{ expectFinal: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a Claude native tool Gateway approval is unavailable", async () => {
|
||||
mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable"));
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
events: buildClaudeControlRequestEvents({
|
||||
requestId: "req-approval-unavailable",
|
||||
toolUseId: "tool-approval-unavailable-1",
|
||||
input: { command: "ls" },
|
||||
sessionId: "live-control-approval-unavailable",
|
||||
}),
|
||||
pid: 3013,
|
||||
});
|
||||
|
||||
const result = await executePreparedCliRun(
|
||||
buildClaudeLiveRunContext({
|
||||
prompt: "hello",
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toBe("ok");
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true),
|
||||
);
|
||||
expectClaudeControlDecision(live, {
|
||||
behavior: "deny",
|
||||
requestId: "req-approval-unavailable",
|
||||
messageIncludes: "OpenClaw approval was not granted",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies oversized Claude Bash approval requests before calling the Gateway", async () => {
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
events: buildClaudeControlRequestEvents({
|
||||
requestId: "req-bash-oversized",
|
||||
toolUseId: "tool-bash-oversized-1",
|
||||
input: { command: `echo ${"x".repeat(500)}; rm -rf /tmp/example` },
|
||||
sessionId: "live-control-bash-oversized",
|
||||
}),
|
||||
pid: 3014,
|
||||
});
|
||||
|
||||
const result = await executePreparedCliRun(
|
||||
buildClaudeLiveRunContext({
|
||||
prompt: "hello",
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toBe("ok");
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true),
|
||||
);
|
||||
expectClaudeControlDecision(live, {
|
||||
behavior: "deny",
|
||||
requestId: "req-bash-oversized",
|
||||
messageIncludes: "too large to display",
|
||||
});
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports Claude live stream progress without timer heartbeats", async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
|
||||
@@ -3345,7 +3458,7 @@ describe("runCliAgent spawn path", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("answers Claude live control_request can_use_tool with deny when exec policy is restrictive", async () => {
|
||||
it("answers Claude live control_request can_use_tool with deny when the user rejects approval", async () => {
|
||||
const diagnosticEvents: Array<Record<string, unknown>> = [];
|
||||
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
|
||||
if (
|
||||
@@ -3356,46 +3469,55 @@ describe("runCliAgent spawn path", () => {
|
||||
diagnosticEvents.push(event as unknown as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const controlEvents = buildClaudeControlRequestEvents({
|
||||
requestId: "req-deny",
|
||||
toolUseId: "tool-deny-1",
|
||||
input: { command: "rm -rf /" },
|
||||
sessionId: "live-control-deny",
|
||||
});
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
events: [
|
||||
...buildClaudeControlRequestEvents({
|
||||
requestId: "req-deny",
|
||||
toolUseId: "tool-deny-1",
|
||||
input: { command: "rm -rf /" },
|
||||
sessionId: "live-control-deny",
|
||||
}).slice(0, 2),
|
||||
{
|
||||
type: "assistant",
|
||||
session_id: "live-control-deny",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-deny-1",
|
||||
name: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
},
|
||||
],
|
||||
onWrite: ({ data, emit, writeIndex }) => {
|
||||
if (writeIndex === 0) {
|
||||
emit(controlEvents.slice(0, 2));
|
||||
return;
|
||||
}
|
||||
if (!data.includes('"control_response"')) {
|
||||
return;
|
||||
}
|
||||
emit([
|
||||
{
|
||||
type: "assistant",
|
||||
session_id: "live-control-deny",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-deny-1",
|
||||
name: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
session_id: "live-control-deny",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-deny-1",
|
||||
content: "denied",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
type: "user",
|
||||
session_id: "live-control-deny",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-deny-1",
|
||||
content: "denied",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "result", session_id: "live-control-deny", result: "ok" },
|
||||
],
|
||||
{ type: "result", session_id: "live-control-deny", result: "ok" },
|
||||
]);
|
||||
},
|
||||
pid: 3002,
|
||||
});
|
||||
|
||||
@@ -3407,6 +3529,9 @@ describe("runCliAgent spawn path", () => {
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true),
|
||||
);
|
||||
await waitForDiagnosticEventsDrained();
|
||||
} finally {
|
||||
stopDiagnostics();
|
||||
@@ -3415,7 +3540,7 @@ describe("runCliAgent spawn path", () => {
|
||||
expectClaudeControlDecision(live, {
|
||||
behavior: "deny",
|
||||
requestId: "req-deny",
|
||||
messageIncludes: "security=allowlist",
|
||||
messageIncludes: "OpenClaw user denied Claude native tool use (Bash).",
|
||||
});
|
||||
expect(diagnosticEvents).toMatchObject([
|
||||
{
|
||||
@@ -3436,6 +3561,149 @@ describe("runCliAgent spawn path", () => {
|
||||
expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default");
|
||||
});
|
||||
|
||||
it("reuses a Claude native tool allow-always grant within the live process", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({
|
||||
id: "claude-native-allow-always",
|
||||
decision: "allow-always",
|
||||
});
|
||||
let promptCount = 0;
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
onWrite: ({ data, emit }) => {
|
||||
if (data.includes('"control_response"')) {
|
||||
return;
|
||||
}
|
||||
promptCount += 1;
|
||||
emit(
|
||||
buildClaudeControlRequestEvents({
|
||||
requestId: `req-grant-${promptCount}`,
|
||||
toolUseId: `tool-grant-${promptCount}`,
|
||||
toolName: "Write",
|
||||
input: {
|
||||
file_path: `/tmp/grant-${promptCount}.txt`,
|
||||
content: `content ${promptCount}`,
|
||||
},
|
||||
sessionId: "live-control-allow-always",
|
||||
}),
|
||||
);
|
||||
},
|
||||
pid: 3012,
|
||||
});
|
||||
const buildContext = (runId: string, prompt: string) =>
|
||||
buildClaudeLiveRunContext({
|
||||
runId,
|
||||
prompt,
|
||||
sessionId: "session-allow-always",
|
||||
sessionKey: "agent:main:allow-always",
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
executePreparedCliRun(buildContext("run-grant-1", "first")),
|
||||
).resolves.toMatchObject({ text: "ok" });
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1),
|
||||
);
|
||||
await expect(
|
||||
executePreparedCliRun(buildContext("run-grant-2", "second")),
|
||||
).resolves.toMatchObject({ text: "ok" });
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2),
|
||||
);
|
||||
|
||||
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
|
||||
expectClaudeControlDecision(live, {
|
||||
behavior: "allow",
|
||||
requestId: "req-grant-1",
|
||||
toolUseId: "tool-grant-1",
|
||||
updatedInput: { file_path: "/tmp/grant-1.txt", content: "content 1" },
|
||||
});
|
||||
const secondResponse = live.writes.find(
|
||||
(entry) => entry.includes('"control_response"') && entry.includes("req-grant-2"),
|
||||
);
|
||||
expect(secondResponse).toContain('"behavior":"allow"');
|
||||
});
|
||||
|
||||
it("prompts on every Claude native tool request when exec ask is always", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({
|
||||
id: "claude-native-always-seed",
|
||||
decision: "allow-always",
|
||||
});
|
||||
let promptCount = 0;
|
||||
const live = mockClaudeLiveRun(supervisorSpawnMock, {
|
||||
onWrite: ({ data, emit }) => {
|
||||
if (data.includes('"control_response"')) {
|
||||
return;
|
||||
}
|
||||
promptCount += 1;
|
||||
emit(
|
||||
buildClaudeControlRequestEvents({
|
||||
requestId: `req-always-${promptCount}`,
|
||||
toolUseId: `tool-always-${promptCount}`,
|
||||
toolName: "Write",
|
||||
input: {
|
||||
file_path: `/tmp/always-${promptCount}.txt`,
|
||||
content: `content ${promptCount}`,
|
||||
},
|
||||
sessionId: "live-control-ask-always",
|
||||
}),
|
||||
);
|
||||
},
|
||||
pid: 3015,
|
||||
});
|
||||
const buildContext = (runId: string, prompt: string, ask: "always" | "on-miss") =>
|
||||
buildClaudeLiveRunContext({
|
||||
runId,
|
||||
prompt,
|
||||
sessionId: "session-ask-always",
|
||||
sessionKey: "agent:main:ask-always",
|
||||
sessionEntry: { execAsk: ask } as PreparedCliRunContext["params"]["sessionEntry"],
|
||||
config: { tools: { exec: { security: "full", ask: "on-miss" } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
executePreparedCliRun(buildContext("run-always-seed", "seed", "on-miss")),
|
||||
).resolves.toMatchObject({ text: "ok" });
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1),
|
||||
);
|
||||
mockCallGatewayTool.mockClear();
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({
|
||||
id: "claude-native-always-1",
|
||||
decision: "allow-once",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "claude-native-always-2",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
await expect(
|
||||
executePreparedCliRun(buildContext("run-always-1", "first", "always")),
|
||||
).resolves.toMatchObject({ text: "ok" });
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2),
|
||||
);
|
||||
await expect(
|
||||
executePreparedCliRun(buildContext("run-always-2", "second", "always")),
|
||||
).resolves.toMatchObject({ text: "ok" });
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(3),
|
||||
);
|
||||
|
||||
expect(mockCallGatewayTool).toHaveBeenCalledTimes(2);
|
||||
for (const call of mockCallGatewayTool.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ allowedDecisions: ["allow-once", "deny"] });
|
||||
}
|
||||
const firstResponse = live.writes.find(
|
||||
(entry) => entry.includes('"control_response"') && entry.includes("req-always-2"),
|
||||
);
|
||||
const secondResponse = live.writes.find(
|
||||
(entry) => entry.includes('"control_response"') && entry.includes("req-always-3"),
|
||||
);
|
||||
expect(firstResponse).toContain('"behavior":"allow"');
|
||||
expect(secondResponse).toContain('"behavior":"allow"');
|
||||
});
|
||||
|
||||
it("does not create exec approvals file while resolving Claude live policy", async () => {
|
||||
await withTempOpenClawHome(async (home) => {
|
||||
const approvalsPath = path.join(home, ".openclaw", "exec-approvals.json");
|
||||
@@ -3475,7 +3743,7 @@ describe("runCliAgent spawn path", () => {
|
||||
requestId: "req-approval-default-deny",
|
||||
toolUseId: "tool-approval-default-deny-1",
|
||||
input: { command: "ls" },
|
||||
expected: { behavior: "deny", messageIncludes: "security=allowlist" },
|
||||
expected: { behavior: "deny", messageIncludes: "OpenClaw user denied" },
|
||||
approvals: {
|
||||
version: 1,
|
||||
defaults: { security: "allowlist", ask: "on-miss" },
|
||||
@@ -3494,7 +3762,7 @@ describe("runCliAgent spawn path", () => {
|
||||
requestId: "req-session-ask-deny",
|
||||
toolUseId: "tool-session-ask-deny-1",
|
||||
input: { command: "ls" },
|
||||
expected: { behavior: "deny", messageIncludes: "ask=always" },
|
||||
expected: { behavior: "deny", messageIncludes: "OpenClaw user denied" },
|
||||
context: {
|
||||
backend: {
|
||||
liveSession: "claude-stdio",
|
||||
@@ -3570,6 +3838,9 @@ describe("runCliAgent spawn path", () => {
|
||||
);
|
||||
|
||||
expect(result.text).toBe("ok");
|
||||
await vi.waitFor(() =>
|
||||
expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true),
|
||||
);
|
||||
expectClaudeControlDecision(live, {
|
||||
...testCase.expected,
|
||||
requestId: testCase.requestId,
|
||||
|
||||
@@ -593,6 +593,7 @@ export function buildClaudeControlRequestEvents(params: {
|
||||
toolUseId: string;
|
||||
input: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
toolName?: string;
|
||||
}) {
|
||||
const sessionId = params.sessionId ?? "live-control";
|
||||
return [
|
||||
@@ -601,7 +602,7 @@ export function buildClaudeControlRequestEvents(params: {
|
||||
request_id: params.requestId,
|
||||
request: {
|
||||
subtype: "can_use_tool",
|
||||
tool_name: "Bash",
|
||||
tool_name: params.toolName ?? "Bash",
|
||||
tool_use_id: params.toolUseId,
|
||||
input: params.input,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
import { resolveCliToolTerminalReason } from "../run-termination.js";
|
||||
import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js";
|
||||
import { LIVE_SESSION_LIMITS, resolveClaudeLiveMode } from "./claude-live-session-policy.js";
|
||||
import {
|
||||
requestClaudeNativeToolApproval,
|
||||
resolveClaudeNativeToolApprovalPlan,
|
||||
} from "./claude-live-tool-approval.js";
|
||||
import { buildClaudeOwnerKey } from "./helpers.js";
|
||||
import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js";
|
||||
import { createCliOutputFailoverError } from "./output-error.js";
|
||||
@@ -110,6 +114,11 @@ type ClaudeLiveSession = {
|
||||
cleanupPromise: Promise<void> | null;
|
||||
closing: boolean;
|
||||
mcpCaptureKey?: string;
|
||||
/**
|
||||
* Native-tool allow-always grants are process-session scoped and in-memory only.
|
||||
* They must not survive the Claude CLI process, so persistence is intentionally absent.
|
||||
*/
|
||||
nativeToolApprovalGrants: Set<string>;
|
||||
/**
|
||||
* Subagent/workflow task ids from the latest background_tasks_changed event.
|
||||
* That event lists all CLI background work, but only local_agent and
|
||||
@@ -1035,6 +1044,51 @@ function writeClaudeLiveControlResponse(session: ClaudeLiveSession, response: un
|
||||
stdin.write(`${JSON.stringify(response)}\n`);
|
||||
}
|
||||
|
||||
function writeClaudeLiveToolControlResponse(params: {
|
||||
session: ClaudeLiveSession;
|
||||
requestId: string;
|
||||
toolUseId?: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
decision: { behavior: "allow" } | { behavior: "deny"; message: string };
|
||||
}): void {
|
||||
writeClaudeLiveControlResponse(params.session, {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: "success",
|
||||
request_id: params.requestId,
|
||||
response:
|
||||
params.decision.behavior === "allow"
|
||||
? {
|
||||
behavior: "allow",
|
||||
updatedInput: params.toolInput,
|
||||
...(params.toolUseId ? { toolUseID: params.toolUseId } : {}),
|
||||
}
|
||||
: {
|
||||
behavior: "deny",
|
||||
decisionClassification: "user_reject",
|
||||
message: params.decision.message,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function markClaudeLiveControlToolDenied(params: {
|
||||
turn: ClaudeLiveTurn;
|
||||
toolUseId?: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}): void {
|
||||
if (!params.toolUseId || !params.toolName) {
|
||||
return;
|
||||
}
|
||||
markClaudeLiveToolDenied(params.turn, {
|
||||
toolCallId: params.toolUseId,
|
||||
name: params.toolName,
|
||||
kind: "tool_use",
|
||||
args: params.toolInput,
|
||||
});
|
||||
}
|
||||
|
||||
function handleClaudeLiveControlRequest(
|
||||
session: ClaudeLiveSession,
|
||||
turn: ClaudeLiveTurn,
|
||||
@@ -1054,33 +1108,80 @@ function handleClaudeLiveControlRequest(
|
||||
const toolUseId = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined;
|
||||
const toolName = typeof request.tool_name === "string" ? request.tool_name.trim() : "";
|
||||
const toolInput = isRecord(request.input) ? request.input : {};
|
||||
const allowed = turn.execPermission.security === "full" && turn.execPermission.ask === "off";
|
||||
if (!allowed && toolUseId && toolName) {
|
||||
markClaudeLiveToolDenied(turn, {
|
||||
toolCallId: toolUseId,
|
||||
name: toolName,
|
||||
kind: "tool_use",
|
||||
args: toolInput,
|
||||
const plan = resolveClaudeNativeToolApprovalPlan(turn.execPermission);
|
||||
if (
|
||||
plan === "allow" ||
|
||||
(plan === "prompt" &&
|
||||
turn.execPermission.ask !== "always" &&
|
||||
session.nativeToolApprovalGrants.has(toolName))
|
||||
) {
|
||||
writeClaudeLiveToolControlResponse({
|
||||
session,
|
||||
requestId,
|
||||
toolUseId,
|
||||
toolInput,
|
||||
decision: { behavior: "allow" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeClaudeLiveControlResponse(session, {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: "success",
|
||||
request_id: requestId,
|
||||
response: allowed
|
||||
? {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
...(toolUseId ? { toolUseID: toolUseId } : {}),
|
||||
}
|
||||
: {
|
||||
behavior: "deny",
|
||||
decisionClassification: "user_reject",
|
||||
message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (plan === "deny") {
|
||||
markClaudeLiveControlToolDenied({ turn, toolUseId, toolName, toolInput });
|
||||
writeClaudeLiveToolControlResponse({
|
||||
session,
|
||||
requestId,
|
||||
toolUseId,
|
||||
toolInput,
|
||||
decision: {
|
||||
behavior: "deny",
|
||||
message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const outcome = await requestClaudeNativeToolApproval({
|
||||
toolName,
|
||||
toolInput,
|
||||
pluginId: session.providerId,
|
||||
sessionKey: turn.diagnosticRefs.sessionKey,
|
||||
agentId: turn.diagnosticRefs.agentId,
|
||||
toolCallId: toolUseId,
|
||||
abortSignal: turn.abortSignal,
|
||||
ask: turn.execPermission.ask,
|
||||
});
|
||||
const runAborted = turn.abortSignal?.aborted === true;
|
||||
const allowed = !runAborted && outcome.kind === "allow";
|
||||
if (!runAborted && outcome.kind === "allow" && outcome.grantAlways) {
|
||||
session.nativeToolApprovalGrants.add(toolName);
|
||||
}
|
||||
if (!allowed) {
|
||||
markClaudeLiveControlToolDenied({ turn, toolUseId, toolName, toolInput });
|
||||
}
|
||||
if (session.closing || !session.managedRun.stdin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeClaudeLiveToolControlResponse({
|
||||
session,
|
||||
requestId,
|
||||
toolUseId,
|
||||
toolInput,
|
||||
decision: allowed
|
||||
? { behavior: "allow" }
|
||||
: {
|
||||
behavior: "deny",
|
||||
message:
|
||||
outcome.kind === "deny" && outcome.reason === "policy-oversized"
|
||||
? "OpenClaw denied Claude native tool use (Bash): the command is too large to display for out-of-band approval. Split it into smaller commands and retry."
|
||||
: outcome.kind === "deny" && outcome.reason === "user" && !runAborted
|
||||
? `OpenClaw user denied Claude native tool use (${toolName}).`
|
||||
: `OpenClaw approval was not granted for Claude native tool use (${toolName}).`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// The live process may close while an out-of-band approval is pending.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void {
|
||||
@@ -1370,6 +1471,7 @@ async function createClaudeLiveSession(params: {
|
||||
cleanupPromise: null,
|
||||
closing: false,
|
||||
mcpCaptureKey: params.mcpCaptureKey,
|
||||
nativeToolApprovalGrants: new Set(),
|
||||
outstandingBackgroundTaskIds: new Set(),
|
||||
};
|
||||
void managedRun.wait().then(
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS } from "../../infra/plugin-approvals.js";
|
||||
import { callGatewayTool } from "../tools/gateway.js";
|
||||
import {
|
||||
requestClaudeNativeToolApproval,
|
||||
resolveClaudeNativeToolApprovalPlan,
|
||||
} from "./claude-live-tool-approval.js";
|
||||
|
||||
vi.mock("../tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
|
||||
afterEach(() => {
|
||||
mockCallGatewayTool.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("resolveClaudeNativeToolApprovalPlan", () => {
|
||||
it.each([
|
||||
["deny", "off", "deny"],
|
||||
["deny", "on-miss", "deny"],
|
||||
["deny", "always", "deny"],
|
||||
// Exec mode "allowlist" maps to allowlist/off: deny without prompting.
|
||||
["allowlist", "off", "deny"],
|
||||
["allowlist", "on-miss", "prompt"],
|
||||
["allowlist", "always", "prompt"],
|
||||
["full", "off", "allow"],
|
||||
["full", "on-miss", "prompt"],
|
||||
["full", "always", "prompt"],
|
||||
] as const)("resolves security=%s ask=%s to %s", (security, ask, expected) => {
|
||||
expect(resolveClaudeNativeToolApprovalPlan({ security, ask })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestClaudeNativeToolApproval", () => {
|
||||
it("registers and waits for a matching approval decision", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "approval-1", status: "pending" })
|
||||
.mockResolvedValueOnce({ id: "approval-1", decision: "allow-once" });
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: { command: "ls" },
|
||||
pluginId: "claude-cli",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
toolCallId: "tool-1",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "allow", grantAlways: false });
|
||||
|
||||
const gatewayTimeoutMs = DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS + 10_000;
|
||||
expect(mockCallGatewayTool).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"plugin.approval.request",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
{
|
||||
pluginId: "claude-cli",
|
||||
toolName: "Bash",
|
||||
toolCallId: "tool-1",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
title: "Claude native tool: Bash",
|
||||
description: '{"command":"ls"}',
|
||||
severity: "warning",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
timeoutMs: DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
twoPhase: true,
|
||||
},
|
||||
{ expectFinal: false },
|
||||
);
|
||||
expect(mockCallGatewayTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"plugin.approval.waitDecision",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
{ id: "approval-1" },
|
||||
{ signal: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("honors an immediate decision without waiting", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({
|
||||
id: "approval-2",
|
||||
decision: "allow-always",
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "WebFetch",
|
||||
toolInput: { url: "https://example.com" },
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "allow", grantAlways: true });
|
||||
expect(mockCallGatewayTool).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when the approval wait times out", async () => {
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "approval-3" })
|
||||
.mockRejectedValueOnce(new Error("gateway timeout"));
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: {},
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("fails closed when the gateway request errors", async () => {
|
||||
mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable"));
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: {},
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("fails closed when the run aborts while waiting", async () => {
|
||||
const abortController = new AbortController();
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "approval-4" })
|
||||
.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const approval = requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: {},
|
||||
pluginId: "claude-cli",
|
||||
abortSignal: abortController.signal,
|
||||
ask: "on-miss",
|
||||
});
|
||||
|
||||
abortController.abort(new Error("run stopped"));
|
||||
|
||||
await expect(approval).resolves.toEqual({ kind: "deny", reason: "unavailable" });
|
||||
});
|
||||
|
||||
it("fails closed when the run aborts while registering the approval", async () => {
|
||||
const abortController = new AbortController();
|
||||
mockCallGatewayTool.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const approval = requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: {},
|
||||
pluginId: "claude-cli",
|
||||
abortSignal: abortController.signal,
|
||||
ask: "on-miss",
|
||||
});
|
||||
|
||||
abortController.abort(new Error("run stopped"));
|
||||
|
||||
await expect(approval).resolves.toEqual({ kind: "deny", reason: "unavailable" });
|
||||
expect(mockCallGatewayTool).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows head and tail of oversized non-Bash inputs and withholds allow-always", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-5", decision: "deny" });
|
||||
const content = `safe-prefix ${"x".repeat(500)} destructive-tail`;
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Write",
|
||||
toolInput: { file_path: "/tmp/output.txt", content },
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "user" });
|
||||
|
||||
const requestPayload = mockCallGatewayTool.mock.calls[0]?.[2] as
|
||||
| { description?: string; allowedDecisions?: unknown }
|
||||
| undefined;
|
||||
expect(requestPayload?.description).toContain("destructive-tail");
|
||||
expect(requestPayload?.description).toContain(
|
||||
'{"file_path":"/tmp/output.txt","content":"safe-prefix',
|
||||
);
|
||||
expect(requestPayload?.description).toMatch(/…\[\+\d+ chars hidden\]…/u);
|
||||
expect(requestPayload?.description?.length).toBeLessThanOrEqual(512);
|
||||
expect(requestPayload?.allowedDecisions).toEqual(["allow-once", "deny"]);
|
||||
});
|
||||
|
||||
it("never offers or honors allow-always for Bash", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-5b", decision: "allow-always" });
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: { command: "ls" },
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "unavailable" });
|
||||
|
||||
expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({
|
||||
description: '{"command":"ls"}',
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("denies oversized Bash input without calling the gateway", async () => {
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: { command: `echo ${"x".repeat(500)}; rm -rf /tmp/example` },
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "policy-oversized" });
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withholds allow-always when ask is always", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-5c", decision: "deny" });
|
||||
|
||||
await expect(
|
||||
requestClaudeNativeToolApproval({
|
||||
toolName: "WebFetch",
|
||||
toolInput: { url: "https://example.com" },
|
||||
pluginId: "claude-cli",
|
||||
ask: "always",
|
||||
}),
|
||||
).resolves.toEqual({ kind: "deny", reason: "user" });
|
||||
expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates only the display title for long native tool names", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-6", decision: "deny" });
|
||||
const toolName = `mcp__claude-in-chrome__${"long-tool-segment-".repeat(6)}`;
|
||||
|
||||
await requestClaudeNativeToolApproval({
|
||||
toolName,
|
||||
toolInput: {},
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
});
|
||||
|
||||
const requestPayload = mockCallGatewayTool.mock.calls[0]?.[2] as
|
||||
| { title?: unknown; toolName?: unknown }
|
||||
| undefined;
|
||||
expect(requestPayload?.title).toHaveLength(80);
|
||||
expect(requestPayload?.title).toMatch(/^Claude native tool: /u);
|
||||
expect(requestPayload?.toolName).toBe(toolName);
|
||||
});
|
||||
|
||||
it("uses an object fallback when JSON serialization returns undefined", async () => {
|
||||
mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-7", decision: "deny" });
|
||||
|
||||
await requestClaudeNativeToolApproval({
|
||||
toolName: "Bash",
|
||||
toolInput: { toJSON: () => undefined },
|
||||
pluginId: "claude-cli",
|
||||
ask: "on-miss",
|
||||
});
|
||||
|
||||
expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ description: "{}" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { addTimerTimeoutGraceMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import type { ExecAsk, ExecSecurity } from "../../infra/exec-approvals.js";
|
||||
import {
|
||||
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
PLUGIN_APPROVAL_TITLE_MAX_LENGTH,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "../../utils.js";
|
||||
import { callGatewayTool } from "../tools/gateway.js";
|
||||
|
||||
type ClaudeNativeToolApprovalPlan = "allow" | "deny" | "prompt";
|
||||
type ClaudeNativeToolApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
type ClaudeNativeToolApprovalOutcome =
|
||||
| { kind: "allow"; grantAlways: boolean }
|
||||
| { kind: "deny"; reason: "policy-oversized" | "user" | "unavailable" };
|
||||
|
||||
const CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS = 300;
|
||||
const CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS = 80;
|
||||
const CLAUDE_NATIVE_TOOL_DESCRIPTION_MAX_CHARS =
|
||||
CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS + CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS;
|
||||
const CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS = 10_000;
|
||||
const CLAUDE_NATIVE_TOOL_ALLOWED_DECISIONS = [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
"deny",
|
||||
] as const satisfies readonly ClaudeNativeToolApprovalDecision[];
|
||||
// A standing grant must never be minted from a partially displayed input, so
|
||||
// oversized inputs offer one-shot decisions only.
|
||||
const CLAUDE_NATIVE_TOOL_TRUNCATED_DECISIONS = [
|
||||
"allow-once",
|
||||
"deny",
|
||||
] as const satisfies readonly ClaudeNativeToolApprovalDecision[];
|
||||
// Claude Code's Bash tool is arbitrary shell execution, so a name-wide grant is unrestricted.
|
||||
// A truncated command can hide a destructive middle, so oversized Bash requests fail closed.
|
||||
const CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL = "Bash";
|
||||
|
||||
export function resolveClaudeNativeToolApprovalPlan(execPermission: {
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
}): ClaudeNativeToolApprovalPlan {
|
||||
if (execPermission.security === "deny") {
|
||||
return "deny";
|
||||
}
|
||||
// ask "off" means never prompt (exec mode "allowlist" relies on this): full
|
||||
// security auto-allows, anything stricter denies without an approval request.
|
||||
if (execPermission.ask === "off") {
|
||||
return execPermission.security === "full" ? "allow" : "deny";
|
||||
}
|
||||
return "prompt";
|
||||
}
|
||||
|
||||
type ClaudeNativeToolDescription = { text: string; truncated: boolean };
|
||||
|
||||
/**
|
||||
* The gateway caps approval descriptions (PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH),
|
||||
* so full inputs cannot ride this channel. Head+tail display defeats padded
|
||||
* prefixes hiding an executable tail, and the quantified marker makes a partial
|
||||
* view an explicit operator decision. Accepted tradeoff: the middle stays
|
||||
* unreviewable; oversized inputs therefore never earn allow-always.
|
||||
*/
|
||||
function formatClaudeNativeToolDescription(
|
||||
toolInput: Record<string, unknown>,
|
||||
): ClaudeNativeToolDescription {
|
||||
const compact = JSON.stringify(toolInput) ?? "{}";
|
||||
if (compact.length <= CLAUDE_NATIVE_TOOL_DESCRIPTION_MAX_CHARS) {
|
||||
return { text: compact, truncated: false };
|
||||
}
|
||||
const head = truncateUtf16Safe(compact, CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS);
|
||||
const tail = sliceUtf16Safe(compact, compact.length - CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS);
|
||||
const hiddenChars = compact.length - head.length - tail.length;
|
||||
return {
|
||||
text: `${head} …[+${hiddenChars} chars hidden]… ${tail}`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatClaudeNativeToolTitle(toolName: string): string {
|
||||
return truncateUtf16Safe(`Claude native tool: ${toolName}`, PLUGIN_APPROVAL_TITLE_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function resolveClaudeNativeToolAllowedDecisions(params: {
|
||||
ask: ExecAsk;
|
||||
toolName: string;
|
||||
descriptionTruncated: boolean;
|
||||
}): readonly ClaudeNativeToolApprovalDecision[] {
|
||||
return params.ask === "always" ||
|
||||
params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL ||
|
||||
params.descriptionTruncated
|
||||
? CLAUDE_NATIVE_TOOL_TRUNCATED_DECISIONS
|
||||
: CLAUDE_NATIVE_TOOL_ALLOWED_DECISIONS;
|
||||
}
|
||||
|
||||
function toAbortError(reason: unknown): Error {
|
||||
return reason instanceof Error ? reason : new Error("Claude native tool approval aborted");
|
||||
}
|
||||
|
||||
async function raceClaudeNativeToolApprovalAbort<T>(
|
||||
promise: Promise<T>,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
): Promise<T> {
|
||||
if (!abortSignal) {
|
||||
return promise;
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
if (abortSignal.aborted) {
|
||||
reject(toAbortError(abortSignal.reason));
|
||||
return;
|
||||
}
|
||||
onAbort = () => reject(toAbortError(abortSignal.reason));
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, abortPromise]);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForClaudeNativeToolApproval(params: {
|
||||
id: string;
|
||||
gatewayTimeoutMs: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{ id?: string; decision?: unknown }> {
|
||||
return raceClaudeNativeToolApprovalAbort(
|
||||
callGatewayTool(
|
||||
"plugin.approval.waitDecision",
|
||||
{ timeoutMs: params.gatewayTimeoutMs },
|
||||
{ id: params.id },
|
||||
// Abort must reach the RPC too, or the gateway keeps the approval prompt
|
||||
// live for its full timeout after the Claude run already ended.
|
||||
{ signal: params.abortSignal },
|
||||
),
|
||||
params.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestClaudeNativeToolApproval(params: {
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
pluginId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
toolCallId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
ask: ExecAsk;
|
||||
}): Promise<ClaudeNativeToolApprovalOutcome> {
|
||||
try {
|
||||
const timeoutMs = DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS;
|
||||
const gatewayTimeoutMs =
|
||||
addTimerTimeoutGraceMs(timeoutMs, CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS) ??
|
||||
timeoutMs + CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS;
|
||||
const description = formatClaudeNativeToolDescription(params.toolInput);
|
||||
if (params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL && description.truncated) {
|
||||
return { kind: "deny", reason: "policy-oversized" };
|
||||
}
|
||||
const allowedDecisions = resolveClaudeNativeToolAllowedDecisions({
|
||||
ask: params.ask,
|
||||
toolName: params.toolName,
|
||||
descriptionTruncated: description.truncated,
|
||||
});
|
||||
const requestResult: {
|
||||
id?: string;
|
||||
decision?: unknown;
|
||||
} = await raceClaudeNativeToolApprovalAbort(
|
||||
callGatewayTool(
|
||||
"plugin.approval.request",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
{
|
||||
pluginId: params.pluginId,
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
title: formatClaudeNativeToolTitle(params.toolName),
|
||||
description: description.text,
|
||||
severity: "warning",
|
||||
allowedDecisions,
|
||||
timeoutMs,
|
||||
twoPhase: true,
|
||||
},
|
||||
{ expectFinal: false, signal: params.abortSignal },
|
||||
),
|
||||
params.abortSignal,
|
||||
);
|
||||
const id = typeof requestResult?.id === "string" ? requestResult.id : "";
|
||||
if (!id) {
|
||||
return { kind: "deny", reason: "unavailable" };
|
||||
}
|
||||
let decision: unknown;
|
||||
if (Object.hasOwn(requestResult ?? {}, "decision")) {
|
||||
decision = requestResult.decision;
|
||||
} else {
|
||||
const waitResult = await waitForClaudeNativeToolApproval({
|
||||
id,
|
||||
gatewayTimeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
decision = waitResult?.id === id ? waitResult.decision : undefined;
|
||||
}
|
||||
if (params.abortSignal?.aborted) {
|
||||
return { kind: "deny", reason: "unavailable" };
|
||||
}
|
||||
if (decision === "allow-once") {
|
||||
return { kind: "allow", grantAlways: false };
|
||||
}
|
||||
if (decision === "allow-always" && allowedDecisions.includes(decision)) {
|
||||
return { kind: "allow", grantAlways: true };
|
||||
}
|
||||
if (decision === "deny") {
|
||||
return { kind: "deny", reason: "user" };
|
||||
}
|
||||
return { kind: "deny", reason: "unavailable" };
|
||||
} catch {
|
||||
return { kind: "deny", reason: "unavailable" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user