Download src/agents/cli-runner/cli-native-tool-approval.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/cli-runner/cli-native-tool-approval.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/cli-runner/cli-native-tool-approval.test.ts
-
curl -L -o cli-native-tool-approval.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/cli-runner/cli-native-tool-approval.test.ts
18.6 kB
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { afterEach, describe, expect, it, vi } from "vitest"; | |
| import { | |
| makeExecutable, | |
| makeExecApprovalsTempDir, | |
| } from "../../infra/exec-approvals-test-helpers.js"; | |
| import { loadExecApprovals, saveExecApprovals } from "../../infra/exec-approvals.js"; | |
| import { | |
| DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS, | |
| PLUGIN_APPROVAL_DETAIL_MAX_LENGTH, | |
| } from "../../infra/plugin-approvals.js"; | |
| import { APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE } from "../../infra/system-run-approval-binding.js"; | |
| import { callGatewayTool } from "../tools/gateway.js"; | |
| import { | |
| requestCliNativeToolApproval, | |
| resolveCliNativeToolApprovalPlan, | |
| } from "./cli-native-tool-approval.js"; | |
| vi.mock("../tools/gateway.js", () => ({ | |
| callGatewayTool: vi.fn(), | |
| })); | |
| const mockCallGatewayTool = vi.mocked(callGatewayTool); | |
| afterEach(() => { | |
| vi.unstubAllEnvs(); | |
| mockCallGatewayTool.mockReset(); | |
| vi.restoreAllMocks(); | |
| vi.useRealTimers(); | |
| }); | |
| describe("resolveCliNativeToolApprovalPlan", () => { | |
| 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(resolveCliNativeToolApprovalPlan({ security, ask })).toBe(expected); | |
| }); | |
| }); | |
| describe("requestCliNativeToolApproval", () => { | |
| it.each(["gog calendar list", "absolute"])( | |
| "auto-allows and records an allowlisted native command: %s", | |
| async (input) => { | |
| const dir = makeExecApprovalsTempDir(); | |
| vi.stubEnv("OPENCLAW_STATE_DIR", dir); | |
| const binary = makeExecutable(dir, "gog"); | |
| saveExecApprovals({ version: 1, agents: { main: { allowlist: [{ pattern: binary }] } } }); | |
| const command = input === "absolute" ? `${binary} calendar list` : input; | |
| const outcome = await requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command }, | |
| pluginId: "claude-cli", | |
| agentId: "main", | |
| cwd: dir, | |
| env: { PATH: dir }, | |
| ask: "on-miss", | |
| }); | |
| expect(outcome).toMatchObject({ kind: "allow", grantAlways: false }); | |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); | |
| expect(loadExecApprovals().agents?.main?.allowlist?.[0]).toMatchObject({ | |
| lastUsedCommand: command, | |
| lastUsedAt: expect.any(Number), | |
| }); | |
| }, | |
| ); | |
| it.each([ | |
| ["gog calendar list | missing-binary", "pipeline", false], | |
| ["gog | gog", "pipeline", false], | |
| ["gog $(date)", "command-substitution", false], | |
| ["gog '", "syntax-error", false], | |
| ["MODE=test gog", "MODE=test gog", true], | |
| ["gog > output.txt", "redirect", false], | |
| ["(gog)", "subshell", false], | |
| ["exec gog", "exec", true], | |
| ["gog *", "shell expansion", true], | |
| ])( | |
| "keeps the binding guard and explains allowlist misses for %s", | |
| async (command, reason, prompts) => { | |
| const dir = makeExecApprovalsTempDir(); | |
| vi.stubEnv("OPENCLAW_STATE_DIR", dir); | |
| const binary = makeExecutable(dir, "gog"); | |
| saveExecApprovals({ version: 1, agents: { main: { allowlist: [{ pattern: binary }] } } }); | |
| mockCallGatewayTool.mockResolvedValueOnce({ id: "native-miss", decision: "deny" }); | |
| const outcome = await requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command }, | |
| pluginId: "claude-cli", | |
| agentId: "main", | |
| cwd: dir, | |
| env: { PATH: dir }, | |
| ask: "on-miss", | |
| }); | |
| if (prompts) { | |
| expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ | |
| description: expect.stringContaining(reason), | |
| allowedDecisions: ["allow-once", "deny"], | |
| }); | |
| } else { | |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); | |
| expect(outcome).toMatchObject({ | |
| kind: "deny", | |
| reason: "operand-binding", | |
| message: expect.stringContaining(reason), | |
| }); | |
| } | |
| expect(loadExecApprovals().agents?.main?.allowlist?.[0]?.lastUsedAt).toBeUndefined(); | |
| }, | |
| ); | |
| it("still prompts for an allowlisted Bash command when ask is always", async () => { | |
| const dir = makeExecApprovalsTempDir(); | |
| vi.stubEnv("OPENCLAW_STATE_DIR", dir); | |
| const binary = makeExecutable(dir, "gog"); | |
| saveExecApprovals({ version: 1, agents: { main: { allowlist: [{ pattern: binary }] } } }); | |
| mockCallGatewayTool.mockResolvedValueOnce({ id: "always", decision: "allow-once" }); | |
| expect( | |
| await requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: `${binary} calendar list` }, | |
| pluginId: "claude-cli", | |
| agentId: "main", | |
| cwd: dir, | |
| ask: "always", | |
| }), | |
| ).toEqual({ kind: "allow", grantAlways: false }); | |
| expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ | |
| allowedDecisions: ["allow-once", "deny"], | |
| }); | |
| expect(loadExecApprovals().agents?.main?.allowlist?.[0]?.lastUsedAt).toBeUndefined(); | |
| }); | |
| it("binds manual approval to the native PATH when exec prepends differ", async () => { | |
| const dir = makeExecApprovalsTempDir(); | |
| const nativeDir = makeExecApprovalsTempDir(); | |
| vi.stubEnv("OPENCLAW_STATE_DIR", dir); | |
| makeExecutable(dir, "gog"); | |
| const nativeBinary = makeExecutable(nativeDir, "gog"); | |
| saveExecApprovals({ version: 1, agents: { main: { allowlist: [] } } }); | |
| mockCallGatewayTool.mockImplementationOnce(async () => { | |
| fs.writeFileSync(nativeBinary, "changed during approval"); | |
| return { id: "path-drift", decision: "allow-once" }; | |
| }); | |
| expect( | |
| await requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "gog" }, | |
| pluginId: "claude-cli", | |
| agentId: "main", | |
| cwd: dir, | |
| ask: "on-miss", | |
| env: { PATH: dir }, | |
| bindingEnv: { PATH: nativeDir }, | |
| }), | |
| ).toMatchObject({ kind: "deny", reason: "operand-binding" }); | |
| }); | |
| it("rechecks current grants before recording an auto-allow", async () => { | |
| const dir = makeExecApprovalsTempDir(); | |
| vi.stubEnv("OPENCLAW_STATE_DIR", dir); | |
| const binary = makeExecutable(dir, "gog"); | |
| saveExecApprovals({ version: 1, agents: { main: { allowlist: [{ pattern: binary }] } } }); | |
| const outcome = await requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: binary }, | |
| pluginId: "claude-cli", | |
| agentId: "main", | |
| cwd: dir, | |
| ask: "on-miss", | |
| assertActive: () => saveExecApprovals({ version: 1, agents: { main: { allowlist: [] } } }), | |
| }); | |
| expect(outcome).toEqual({ kind: "deny", reason: "unavailable" }); | |
| expect(loadExecApprovals().agents?.main?.allowlist).toEqual([]); | |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); | |
| }); | |
| 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( | |
| requestCliNativeToolApproval({ | |
| 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-cli native tool: Bash", | |
| description: '{"command":"ls"}\nExec allowlist miss: ls', | |
| detail: '{"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( | |
| requestCliNativeToolApproval({ | |
| toolName: "WebFetch", | |
| toolInput: { url: "https://example.com" }, | |
| pluginId: "claude-cli", | |
| ask: "on-miss", | |
| }), | |
| ).resolves.toEqual({ kind: "allow", grantAlways: true }); | |
| expect(mockCallGatewayTool).toHaveBeenCalledOnce(); | |
| }); | |
| it("identifies the owning backend when another provider requests native approval", async () => { | |
| mockCallGatewayTool.mockResolvedValueOnce({ | |
| id: "approval-other-provider", | |
| decision: "allow-once", | |
| }); | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Read", | |
| toolInput: { file_path: "/tmp/example.txt" }, | |
| pluginId: "gemini-cli", | |
| ask: "on-miss", | |
| }), | |
| ).resolves.toEqual({ kind: "allow", grantAlways: false }); | |
| expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ | |
| pluginId: "gemini-cli", | |
| title: "gemini-cli native tool: Read", | |
| }); | |
| }); | |
| it("fails closed when the approval wait times out", async () => { | |
| mockCallGatewayTool | |
| .mockResolvedValueOnce({ id: "approval-3" }) | |
| .mockRejectedValueOnce(new Error("gateway timeout")); | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "ls" }, | |
| 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( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "ls" }, | |
| 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 = requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "ls" }, | |
| 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 = requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "ls" }, | |
| 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( | |
| requestCliNativeToolApproval({ | |
| 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; detail?: 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?.detail).toBe(JSON.stringify({ file_path: "/tmp/output.txt", content })); | |
| 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( | |
| requestCliNativeToolApproval({ | |
| 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"}', | |
| detail: '{"command":"ls"}', | |
| allowedDecisions: ["allow-once", "deny"], | |
| }); | |
| }); | |
| it("checks Bash script drift before rejecting an unexpected allow-always", async () => { | |
| const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-always-drift-")); | |
| const script = path.join(cwd, "script.sh"); | |
| try { | |
| fs.writeFileSync(script, "#!/bin/sh\necho approved\n"); | |
| mockCallGatewayTool.mockImplementationOnce(async () => { | |
| fs.writeFileSync(script, "#!/bin/sh\necho changed\n"); | |
| return { id: "approval-unexpected-always", decision: "allow-always" }; | |
| }); | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "sh script.sh" }, | |
| pluginId: "claude-cli", | |
| cwd, | |
| ask: "on-miss", | |
| }), | |
| ).resolves.toEqual({ | |
| kind: "deny", | |
| reason: "operand-binding", | |
| message: APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE, | |
| }); | |
| } finally { | |
| fs.rmSync(cwd, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("denies Bash whose channel description truncates even when detail would fit", async () => { | |
| // Channel/push approvers never see the reviewer detail, so a Bash command | |
| // hidden by description truncation must not be approvable from anywhere. | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| 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("denies Bash input beyond the reviewer detail limit without calling the gateway", async () => { | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) }, | |
| pluginId: "claude-cli", | |
| ask: "on-miss", | |
| }), | |
| ).resolves.toEqual({ kind: "deny", reason: "policy-oversized" }); | |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); | |
| }); | |
| it("denies Bash whose short raw command expands past the summary bound when sanitized", async () => { | |
| // ~70 bidi override chars stay under the raw description budget but escape | |
| // to \u{202E} sequences that overflow the 512-char channel summary. | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: `echo ${"".repeat(70)}; rm -rf /tmp/example` }, | |
| pluginId: "claude-cli", | |
| ask: "on-miss", | |
| }), | |
| ).resolves.toEqual({ kind: "deny", reason: "policy-oversized" }); | |
| expect(mockCallGatewayTool).not.toHaveBeenCalled(); | |
| }); | |
| it("denies Bash when reviewer sanitization would hide the command tail", async () => { | |
| await expect( | |
| requestCliNativeToolApproval({ | |
| toolName: "Bash", | |
| toolInput: { command: `# ${"\u202e".repeat(3_000)}\necho destructive-tail` }, | |
| 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( | |
| requestCliNativeToolApproval({ | |
| 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 requestCliNativeToolApproval({ | |
| 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-cli 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 requestCliNativeToolApproval({ | |
| toolName: "WebFetch", | |
| toolInput: { toJSON: () => undefined }, | |
| pluginId: "claude-cli", | |
| ask: "on-miss", | |
| }); | |
| expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ | |
| description: "{}", | |
| detail: "{}", | |
| }); | |
| }); | |
| }); | |