Download src/node-host/invoke-system-run.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 127 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/node-host/invoke-system-run.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/node-host/invoke-system-run.test.ts
-
curl -L -o invoke-system-run.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/node-host/invoke-system-run.test.ts
127 kB
| /** Tests node-host system.run policy, approval, allowlist, and execution behavior. */ | |
| import crypto from "node:crypto"; | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { | |
| afterAll, | |
| afterEach, | |
| beforeAll, | |
| beforeEach, | |
| describe, | |
| expect, | |
| it, | |
| type Mock, | |
| vi, | |
| } from "vitest"; | |
| import { | |
| clearRuntimeConfigSnapshot, | |
| getRuntimeConfigSnapshot, | |
| setRuntimeConfigSnapshot, | |
| } from "../config/runtime-snapshot.js"; | |
| import { deleteExecApprovalsConfigRow } from "../infra/exec-approvals-sqlite.js"; | |
| import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; | |
| import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../infra/exec-approvals.js"; | |
| import { | |
| commitExecAuthorizationLocked, | |
| createExecApprovalPolicySnapshot, | |
| loadExecApprovals, | |
| saveExecApprovals, | |
| } from "../infra/exec-approvals.js"; | |
| import type { ExecAutoReviewer } from "../infra/exec-auto-review.js"; | |
| import * as commandResolution from "../infra/exec-command-resolution.js"; | |
| import type { ExecHostResponse } from "../infra/exec-host.js"; | |
| import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; | |
| import { formatExecCommand } from "../infra/system-run-command.js"; | |
| import { | |
| closeOpenClawStateDatabaseForTest, | |
| openOpenClawStateDatabase, | |
| } from "../state/openclaw-state-db.js"; | |
| import { withEnvAsync } from "../test-utils/env.js"; | |
| import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; | |
| import { handleSystemRunInvoke } from "./invoke-system-run.js"; | |
| type HandleSystemRunInvokeOptions = Parameters<typeof handleSystemRunInvoke>[0]; | |
| vi.mock("../logger.js", async (importOriginal) => ({ | |
| ...(await importOriginal<typeof import("../logger.js")>()), | |
| logWarn: vi.fn(), | |
| })); | |
| type MockedRunCommand = Mock<HandleSystemRunInvokeOptions["runCommand"]>; | |
| type MockedRunViaMacAppExecHost = Mock<HandleSystemRunInvokeOptions["runViaMacAppExecHost"]>; | |
| type MockedSendInvokeResult = Mock<HandleSystemRunInvokeOptions["sendInvokeResult"]>; | |
| type MockedSendExecFinishedEvent = Mock<HandleSystemRunInvokeOptions["sendExecFinishedEvent"]>; | |
| type MockedSendNodeEvent = Mock<HandleSystemRunInvokeOptions["sendNodeEvent"]>; | |
| type InvokeSpies = { | |
| runCommand: MockedRunCommand; | |
| runViaMacAppExecHost: MockedRunViaMacAppExecHost; | |
| sendInvokeResult: MockedSendInvokeResult; | |
| sendExecFinishedEvent: MockedSendExecFinishedEvent; | |
| sendNodeEvent: MockedSendNodeEvent; | |
| }; | |
| type InvokeResult = { | |
| ok?: boolean; | |
| payloadJSON?: string; | |
| error?: { code?: string; message?: string }; | |
| }; | |
| type MacExecHostCall = { | |
| approvals?: { agent?: { security?: string; ask?: string } }; | |
| request?: { | |
| command?: string[]; | |
| rawCommand?: string; | |
| cwd?: string; | |
| approvalDecision?: string | null; | |
| approvalSource?: string | null; | |
| policySnapshot?: unknown; | |
| }; | |
| }; | |
| describe("handleSystemRunInvoke mac app exec host routing", () => { | |
| let sharedFixtureRoot = ""; | |
| let sharedOpenClawHome = ""; | |
| let sharedRuntimeBinDir = ""; | |
| let sharedFixtureId = 0; | |
| let previousOpenClawHome: string | undefined; | |
| const sharedRuntimeBins = new Set<string>(); | |
| beforeAll(() => { | |
| closeOpenClawStateDatabaseForTest(); | |
| sharedFixtureRoot = fs.realpathSync( | |
| fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-fixtures-")), | |
| ); | |
| sharedOpenClawHome = path.join(sharedFixtureRoot, "openclaw-home"); | |
| sharedRuntimeBinDir = path.join(sharedFixtureRoot, "bin"); | |
| fs.mkdirSync(sharedOpenClawHome, { recursive: true }); | |
| fs.mkdirSync(sharedRuntimeBinDir, { recursive: true }); | |
| }); | |
| afterAll(() => { | |
| closeOpenClawStateDatabaseForTest(); | |
| if (sharedFixtureRoot) { | |
| fs.rmSync(sharedFixtureRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| function createFixtureDir(prefix: string): string { | |
| const dir = path.join(sharedFixtureRoot, `${prefix}${sharedFixtureId++}`); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| return dir; | |
| } | |
| beforeEach(() => { | |
| previousOpenClawHome = process.env.OPENCLAW_HOME; | |
| process.env.OPENCLAW_HOME = sharedOpenClawHome; | |
| execApprovalsStoreTesting.reset(); | |
| // Cases isolate the canonical policy row, not shared-state schema bootstrap. | |
| deleteExecApprovalsConfigRow(openOpenClawStateDatabase().db); | |
| clearRuntimeConfigSnapshot(); | |
| }); | |
| afterEach(() => { | |
| execApprovalsStoreTesting.reset(); | |
| clearRuntimeConfigSnapshot(); | |
| if (previousOpenClawHome === undefined) { | |
| delete process.env.OPENCLAW_HOME; | |
| } else { | |
| process.env.OPENCLAW_HOME = previousOpenClawHome; | |
| } | |
| }); | |
| function createLocalRunResult(stdout = "local-ok") { | |
| return { | |
| success: true, | |
| stdout, | |
| stderr: "", | |
| timedOut: false, | |
| truncated: false, | |
| exitCode: 0, | |
| error: null, | |
| }; | |
| } | |
| function createTempExecutable(dir: string, name: string): string { | |
| const fileName = process.platform === "win32" ? `${name}.exe` : name; | |
| const executablePath = path.join(dir, fileName); | |
| fs.writeFileSync(executablePath, ""); | |
| fs.chmodSync(executablePath, 0o755); | |
| return executablePath; | |
| } | |
| function createStrictInlineEvalApprovalPlan(prefix: string): SystemRunApprovalPlan { | |
| const tempDir = createFixtureDir(prefix); | |
| const executablePath = createTempExecutable(tempDir, "gawk"); | |
| const scriptPath = path.join(tempDir, "library.awk"); | |
| fs.writeFileSync(scriptPath, "{ print }\n"); | |
| const prepared = buildSessionApprovalPlan( | |
| [executablePath, "-f", scriptPath, '--source=BEGIN{print "safe"}'], | |
| "agent:main:main", | |
| ); | |
| if (!prepared.ok) { | |
| throw new Error(prepared.message); | |
| } | |
| return prepared.plan; | |
| } | |
| function bindCurrentPolicyToPlan(plan: SystemRunApprovalPlan): SystemRunApprovalPlan { | |
| const agentId = plan.agentId ?? "main"; | |
| return { | |
| ...plan, | |
| agentId, | |
| sessionKey: plan.sessionKey ?? "agent:main:main", | |
| policySnapshot: createExecApprovalPolicySnapshot({ | |
| file: loadExecApprovals(), | |
| agentId, | |
| }), | |
| }; | |
| } | |
| function requireApprovalPlan( | |
| prepared: ReturnType<typeof buildSystemRunApprovalPlan>, | |
| message: string, | |
| ): asserts prepared is Extract<ReturnType<typeof buildSystemRunApprovalPlan>, { ok: true }> { | |
| if (!prepared.ok) { | |
| throw new Error(message); | |
| } | |
| } | |
| function buildSessionApprovalPlan(command: string[], sessionKey: string) { | |
| return buildSystemRunApprovalPlan({ command, sessionKey }); | |
| } | |
| function buildCwdApprovalPlan(command: string[], cwd: string) { | |
| return buildSystemRunApprovalPlan({ command, cwd }); | |
| } | |
| function buildCwdSessionApprovalPlan(command: string[], cwd: string, sessionKey: string) { | |
| return buildSystemRunApprovalPlan({ command, cwd, sessionKey }); | |
| } | |
| function expectInvokeOk(sendInvokeResult: MockedSendInvokeResult, payloadContains?: string) { | |
| const params = payloadContains === undefined ? undefined : { payloadContains }; | |
| const result = requireInvokeResult(sendInvokeResult); | |
| expect(result.ok).toBe(true); | |
| if (params?.payloadContains) { | |
| expect(result.payloadJSON).toContain(params.payloadContains); | |
| } | |
| } | |
| function expectInvokeErrorMessage( | |
| sendInvokeResult: MockedSendInvokeResult, | |
| expectedMessage: string, | |
| exact = false, | |
| ) { | |
| const params = { message: expectedMessage, exact }; | |
| const result = requireInvokeResult(sendInvokeResult); | |
| expect(result.ok).toBe(false); | |
| const message = result.error?.message; | |
| if (params.exact) { | |
| expect(message).toBe(params.message); | |
| } else { | |
| expect(message).toContain(params.message); | |
| } | |
| } | |
| function requireInvokeResult(sendInvokeResult: MockedSendInvokeResult): InvokeResult { | |
| const result = firstMockCallArg(sendInvokeResult, "sendInvokeResult", 0); | |
| return result as InvokeResult; | |
| } | |
| function requireFirstRunCommandArgs(runCommand: MockedRunCommand): string[] { | |
| return firstMockCallArg(vi.mocked(runCommand), "runCommand", 0) as string[]; | |
| } | |
| function requireMacExecHostCall( | |
| runViaMacAppExecHost: MockedRunViaMacAppExecHost, | |
| ): MacExecHostCall { | |
| const call = firstMockCallArg(runViaMacAppExecHost, "runViaMacAppExecHost", 0); | |
| return call as MacExecHostCall; | |
| } | |
| function firstMockCallArg( | |
| mock: { mock: { calls: readonly unknown[][] } }, | |
| label: string, | |
| argIndex: number, | |
| ): unknown { | |
| const [call] = mock.mock.calls; | |
| if (!call) { | |
| throw new Error(`expected ${label} call`); | |
| } | |
| return call[argIndex]; | |
| } | |
| function expectExecDeniedEvent( | |
| sendNodeEvent: MockedSendNodeEvent, | |
| reason = "approval-required", | |
| ): void { | |
| const call = sendNodeEvent.mock.calls[0]; | |
| if (!call) { | |
| throw new Error("expected sendNodeEvent call"); | |
| } | |
| expect(call[1]).toBe("exec.denied"); | |
| expect((call[2] as { reason?: string }).reason).toBe(reason); | |
| } | |
| function expectApprovalRequiredDenied( | |
| sendNodeEvent: MockedSendNodeEvent, | |
| sendInvokeResult: MockedSendInvokeResult, | |
| ) { | |
| const params = { sendNodeEvent, sendInvokeResult }; | |
| expectExecDeniedEvent(params.sendNodeEvent); | |
| expectInvokeErrorMessage(params.sendInvokeResult, "SYSTEM_RUN_DENIED: approval required", true); | |
| } | |
| function expectApprovalStateWriteDenied(params: { | |
| sendNodeEvent: MockedSendNodeEvent; | |
| sendInvokeResult: MockedSendInvokeResult; | |
| }) { | |
| expectExecDeniedEvent(params.sendNodeEvent, "approval-state-write-failed"); | |
| expect(requireInvokeResult(params.sendInvokeResult)).toMatchObject({ | |
| ok: false, | |
| error: { | |
| code: "SYSTEM_RUN_DENIED", | |
| message: "SYSTEM_RUN_DENIED: approval state could not be persisted", | |
| }, | |
| }); | |
| } | |
| function createMutableScriptOperandFixture(tmp: string): { | |
| command: string[]; | |
| scriptPath: string; | |
| initialBody: string; | |
| changedBody: string; | |
| } { | |
| if (process.platform === "win32") { | |
| const scriptPath = path.join(tmp, "run.js"); | |
| return { | |
| command: [process.execPath, "./run.js"], | |
| scriptPath, | |
| initialBody: 'console.log("SAFE");\n', | |
| changedBody: 'console.log("PWNED");\n', | |
| }; | |
| } | |
| const scriptPath = path.join(tmp, "run.sh"); | |
| return { | |
| command: ["/bin/sh", "./run.sh"], | |
| scriptPath, | |
| initialBody: "#!/bin/sh\necho SAFE\n", | |
| changedBody: "#!/bin/sh\necho PWNED\n", | |
| }; | |
| } | |
| function createRuntimeScriptOperandFixture( | |
| tmp: string, | |
| runtime: "bun" | "deno" | "jiti" | "tsx", | |
| ): { | |
| command: string[]; | |
| scriptPath: string; | |
| initialBody: string; | |
| changedBody: string; | |
| } { | |
| const scriptPath = path.join(tmp, "run.ts"); | |
| const initialBody = 'console.log("SAFE");\n'; | |
| const changedBody = 'console.log("PWNED");\n'; | |
| switch (runtime) { | |
| case "bun": | |
| return { | |
| command: ["bun", "run", "./run.ts"], | |
| scriptPath, | |
| initialBody, | |
| changedBody, | |
| }; | |
| case "deno": | |
| return { | |
| command: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], | |
| scriptPath, | |
| initialBody, | |
| changedBody, | |
| }; | |
| case "jiti": | |
| return { | |
| command: ["jiti", "./run.ts"], | |
| scriptPath, | |
| initialBody, | |
| changedBody, | |
| }; | |
| case "tsx": | |
| return { | |
| command: ["tsx", "./run.ts"], | |
| scriptPath, | |
| initialBody, | |
| changedBody, | |
| }; | |
| } | |
| const unsupportedRuntime: never = runtime; | |
| throw new Error(`unsupported runtime fixture: ${String(unsupportedRuntime)}`); | |
| } | |
| function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { | |
| return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; | |
| } | |
| function createMacExecHostSuccess(stdout = "app-ok"): ExecHostResponse { | |
| return { | |
| ok: true, | |
| payload: { | |
| success: true, | |
| stdout, | |
| stderr: "", | |
| timedOut: false, | |
| exitCode: 0, | |
| error: null, | |
| }, | |
| }; | |
| } | |
| function createAllowlistOnMissApprovals(params?: { | |
| autoAllowSkills?: boolean; | |
| agents?: Parameters<typeof saveExecApprovals>[0]["agents"]; | |
| }): Parameters<typeof saveExecApprovals>[0] { | |
| return { | |
| version: 1, | |
| defaults: { | |
| security: "allowlist", | |
| ask: "on-miss", | |
| askFallback: "deny", | |
| ...(params?.autoAllowSkills ? { autoAllowSkills: true } : {}), | |
| }, | |
| agents: params?.agents ?? {}, | |
| }; | |
| } | |
| function createApprovals( | |
| security: ExecSecurity, | |
| ask: ExecAsk, | |
| askFallback: ExecSecurity, | |
| agents?: Parameters<typeof saveExecApprovals>[0]["agents"], | |
| ): Parameters<typeof saveExecApprovals>[0] { | |
| return { | |
| version: 1, | |
| defaults: { security, ask, askFallback }, | |
| ...(agents === undefined ? {} : { agents }), | |
| }; | |
| } | |
| function createExactCommandPattern(commandText: string): string { | |
| return `=command:${crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16)}`; | |
| } | |
| function resolveProductionExecSecurity(value?: string): "deny" | "allowlist" | "full" { | |
| return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist"; | |
| } | |
| function resolveProductionExecAsk(value?: string): "off" | "on-miss" | "always" { | |
| return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss"; | |
| } | |
| function createInvokeSpies(params?: { | |
| runCommand?: HandleSystemRunInvokeOptions["runCommand"]; | |
| runViaMacAppExecHost?: HandleSystemRunInvokeOptions["runViaMacAppExecHost"]; | |
| sendInvokeResult?: HandleSystemRunInvokeOptions["sendInvokeResult"]; | |
| sendExecFinishedEvent?: HandleSystemRunInvokeOptions["sendExecFinishedEvent"]; | |
| sendNodeEvent?: HandleSystemRunInvokeOptions["sendNodeEvent"]; | |
| }): InvokeSpies { | |
| return { | |
| runCommand: vi.fn(params?.runCommand ?? (async () => createLocalRunResult())), | |
| runViaMacAppExecHost: vi.fn(params?.runViaMacAppExecHost ?? (async () => null)), | |
| sendInvokeResult: vi.fn(params?.sendInvokeResult ?? (async () => {})), | |
| sendExecFinishedEvent: vi.fn(params?.sendExecFinishedEvent ?? (async () => {})), | |
| sendNodeEvent: vi.fn(params?.sendNodeEvent ?? (async () => {})), | |
| }; | |
| } | |
| function createPolicyMutationCommit( | |
| mutate: (current: ReturnType<typeof loadExecApprovals>) => void, | |
| ): Mock<NonNullable<HandleSystemRunInvokeOptions["commitExecAuthorization"]>> { | |
| return vi.fn(async (params) => { | |
| const current = loadExecApprovals(); | |
| mutate(current); | |
| saveExecApprovals(current); | |
| return await commitExecAuthorizationLocked(params); | |
| }); | |
| } | |
| async function withTempApprovalsHome<T>( | |
| approvals: Parameters<typeof saveExecApprovals>[0], | |
| run: (ctx: { tempHome: string }) => Promise<T>, | |
| ): Promise<T> { | |
| const tempHome = sharedOpenClawHome; | |
| return await withEnvAsync({ OPENCLAW_HOME: tempHome }, async () => { | |
| saveExecApprovals(approvals); | |
| return await run({ tempHome }); | |
| }); | |
| } | |
| async function withPathTokenCommand<T>( | |
| tmpPrefix: string, | |
| run: (ctx: { link: string; expected: string }) => Promise<T>, | |
| ): Promise<T> { | |
| const tmp = createFixtureDir(tmpPrefix); | |
| const binDir = path.join(tmp, "bin"); | |
| fs.mkdirSync(binDir, { recursive: true }); | |
| const link = path.join(binDir, "poccmd"); | |
| fs.symlinkSync("/bin/echo", link); | |
| const expected = fs.realpathSync(link); | |
| return await withEnvAsync({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` }, () => | |
| run({ link, expected }), | |
| ); | |
| } | |
| async function withFakeRuntimeOnPath<T>( | |
| runtime: "bun" | "deno" | "jiti" | "tsx", | |
| run: () => Promise<T>, | |
| ): Promise<T> { | |
| if (!sharedRuntimeBins.has(runtime)) { | |
| const runtimePath = | |
| process.platform === "win32" | |
| ? path.join(sharedRuntimeBinDir, `${runtime}.cmd`) | |
| : path.join(sharedRuntimeBinDir, runtime); | |
| const runtimeBody = | |
| process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; | |
| fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); | |
| if (process.platform !== "win32") { | |
| fs.chmodSync(runtimePath, 0o755); | |
| } | |
| sharedRuntimeBins.add(runtime); | |
| } | |
| return await withEnvAsync( | |
| { PATH: `${sharedRuntimeBinDir}${path.delimiter}${process.env.PATH ?? ""}` }, | |
| run, | |
| ); | |
| } | |
| function expectCommandPinnedToCanonicalPath( | |
| runCommand: MockedRunCommand, | |
| expected: string, | |
| commandTail: string[], | |
| cwd?: string, | |
| ) { | |
| const params = { runCommand, expected, commandTail, cwd }; | |
| expect(params.runCommand).toHaveBeenCalledWith( | |
| [params.expected, ...params.commandTail], | |
| params.cwd, | |
| undefined, | |
| undefined, | |
| undefined, | |
| expect.any(Function), | |
| ); | |
| } | |
| function resolveStatTargetPath(target: string | Buffer | URL | number): string { | |
| if (typeof target === "string") { | |
| return path.resolve(target); | |
| } | |
| if (Buffer.isBuffer(target)) { | |
| return path.resolve(target.toString()); | |
| } | |
| if (target instanceof URL) { | |
| return path.resolve(target.pathname); | |
| } | |
| return path.resolve(String(target)); | |
| } | |
| async function withMockedCwdIdentityDrift<T>(params: { | |
| canonicalCwd: string; | |
| driftDir: string; | |
| stableHitsBeforeDrift?: number; | |
| run: () => Promise<T>; | |
| }): Promise<T> { | |
| const stableHitsBeforeDrift = params.stableHitsBeforeDrift ?? 2; | |
| const realStatSync = fs.statSync.bind(fs); | |
| const baselineStat = realStatSync(params.canonicalCwd); | |
| const driftStat = realStatSync(params.driftDir); | |
| let canonicalHits = 0; | |
| const statSpy = vi.spyOn(fs, "statSync").mockImplementation((...args) => { | |
| const resolvedTarget = resolveStatTargetPath(args[0]); | |
| if (resolvedTarget === params.canonicalCwd) { | |
| canonicalHits += 1; | |
| if (canonicalHits > stableHitsBeforeDrift) { | |
| return driftStat; | |
| } | |
| return baselineStat; | |
| } | |
| return realStatSync(...args); | |
| }); | |
| try { | |
| return await params.run(); | |
| } finally { | |
| statSpy.mockRestore(); | |
| } | |
| } | |
| async function runSystemInvoke(params: { | |
| preferMacAppExecHost: boolean; | |
| execHostFallbackAllowed?: boolean; | |
| runViaResponse?: ExecHostResponse | null; | |
| command?: string[]; | |
| env?: Record<string, string>; | |
| rawCommand?: string | null; | |
| systemRunPlan?: SystemRunApprovalPlan | null; | |
| preparedPlan?: SystemRunApprovalPlan; | |
| cwd?: string; | |
| agentId?: string; | |
| security?: "full" | "allowlist"; | |
| ask?: "off" | "on-miss" | "always"; | |
| approvalDecision?: "allow" | "allow-once" | "allow-always" | "deny" | null; | |
| approvalSource?: string | null; | |
| approved?: boolean; | |
| needsScreenRecording?: boolean; | |
| suppressNotifyOnExit?: boolean; | |
| runCommand?: HandleSystemRunInvokeOptions["runCommand"]; | |
| runViaMacAppExecHost?: HandleSystemRunInvokeOptions["runViaMacAppExecHost"]; | |
| sendInvokeResult?: HandleSystemRunInvokeOptions["sendInvokeResult"]; | |
| sendExecFinishedEvent?: HandleSystemRunInvokeOptions["sendExecFinishedEvent"]; | |
| sendNodeEvent?: HandleSystemRunInvokeOptions["sendNodeEvent"]; | |
| skillBinsCurrent?: () => Promise<Array<{ name: string; resolvedPath: string }>>; | |
| isCmdExeInvocation?: HandleSystemRunInvokeOptions["isCmdExeInvocation"]; | |
| sanitizeEnv?: HandleSystemRunInvokeOptions["sanitizeEnv"]; | |
| resolveExecSecurity?: HandleSystemRunInvokeOptions["resolveExecSecurity"]; | |
| resolveExecAsk?: HandleSystemRunInvokeOptions["resolveExecAsk"]; | |
| autoReviewer?: ExecAutoReviewer; | |
| commitExecAuthorization?: HandleSystemRunInvokeOptions["commitExecAuthorization"]; | |
| prepareDelayedApprovalPlan?: boolean; | |
| signal?: AbortSignal; | |
| }): Promise<InvokeSpies> { | |
| const spies = createInvokeSpies({ | |
| runCommand: params.runCommand, | |
| runViaMacAppExecHost: | |
| params.runViaMacAppExecHost ?? (async () => params.runViaResponse ?? null), | |
| sendInvokeResult: params.sendInvokeResult, | |
| sendExecFinishedEvent: params.sendExecFinishedEvent, | |
| sendNodeEvent: params.sendNodeEvent, | |
| }); | |
| const command = params.command ?? params.preparedPlan?.argv ?? ["echo", "ok"]; | |
| let dispatchCommand = command; | |
| let dispatchRawCommand = params.rawCommand ?? params.preparedPlan?.commandText; | |
| let dispatchCwd = params.cwd ?? params.preparedPlan?.cwd ?? undefined; | |
| let dispatchAgentId: string | undefined = params.agentId ?? "main"; | |
| const forwardsDelayedApproval = | |
| params.approvalSource === "auto-review" || | |
| params.approved === true || | |
| params.approvalDecision === "allow" || | |
| params.approvalDecision === "allow-once" || | |
| params.approvalDecision === "allow-always"; | |
| const providedPlan = params.preparedPlan ?? params.systemRunPlan ?? undefined; | |
| let systemRunPlan: SystemRunApprovalPlan | undefined = providedPlan | |
| ? { | |
| ...providedPlan, | |
| agentId: providedPlan.agentId ?? dispatchAgentId, | |
| sessionKey: providedPlan.sessionKey ?? "agent:main:main", | |
| } | |
| : undefined; | |
| if (forwardsDelayedApproval && params.prepareDelayedApprovalPlan !== false) { | |
| if (!systemRunPlan) { | |
| const prepared = buildSystemRunApprovalPlan({ | |
| command, | |
| rawCommand: params.rawCommand, | |
| cwd: params.cwd, | |
| agentId: dispatchAgentId, | |
| sessionKey: "agent:main:main", | |
| }); | |
| if (!prepared.ok) { | |
| throw new Error(prepared.message); | |
| } | |
| systemRunPlan = prepared.plan; | |
| dispatchCommand = prepared.plan.argv; | |
| dispatchRawCommand = prepared.plan.commandText; | |
| dispatchCwd = prepared.plan.cwd ?? undefined; | |
| dispatchAgentId = prepared.plan.agentId ?? undefined; | |
| } | |
| systemRunPlan = bindCurrentPolicyToPlan(systemRunPlan); | |
| } | |
| await handleSystemRunInvoke({ | |
| client: {} as never, | |
| params: { | |
| command: dispatchCommand, | |
| env: params.env, | |
| rawCommand: dispatchRawCommand, | |
| systemRunPlan, | |
| cwd: dispatchCwd, | |
| agentId: dispatchAgentId, | |
| approvalDecision: params.approvalDecision, | |
| approvalSource: params.approvalSource, | |
| approved: params.approved, | |
| needsScreenRecording: params.needsScreenRecording, | |
| suppressNotifyOnExit: params.suppressNotifyOnExit, | |
| sessionKey: "agent:main:main", | |
| }, | |
| skillBins: { | |
| current: params.skillBinsCurrent ?? (async () => []), | |
| }, | |
| signal: params.signal, | |
| execHostEnforced: false, | |
| execHostFallbackAllowed: params.execHostFallbackAllowed ?? true, | |
| resolveExecSecurity: params.resolveExecSecurity ?? (() => params.security ?? "full"), | |
| resolveExecAsk: params.resolveExecAsk ?? (() => params.ask ?? "off"), | |
| isCmdExeInvocation: params.isCmdExeInvocation ?? (() => false), | |
| sanitizeEnv: params.sanitizeEnv ?? (() => undefined), | |
| ...spies, | |
| buildExecEventPayload: (payload) => payload, | |
| preferMacAppExecHost: params.preferMacAppExecHost, | |
| getRuntimeConfig: () => getRuntimeConfigSnapshot() ?? {}, | |
| autoReviewer: params.autoReviewer, | |
| commitExecAuthorization: params.commitExecAuthorization, | |
| }); | |
| return spies; | |
| } | |
| type SystemInvokeFixtureParams = Parameters<typeof runSystemInvoke>[0]; | |
| async function runLocalSystemInvoke( | |
| params: Omit<SystemInvokeFixtureParams, "preferMacAppExecHost"> = {}, | |
| ) { | |
| return await runSystemInvoke({ ...params, preferMacAppExecHost: false }); | |
| } | |
| async function runMacSystemInvoke( | |
| params: Omit<SystemInvokeFixtureParams, "preferMacAppExecHost"> = {}, | |
| ) { | |
| return await runSystemInvoke({ ...params, preferMacAppExecHost: true }); | |
| } | |
| type ExplicitSystemInvokePolicy = { | |
| security: NonNullable<SystemInvokeFixtureParams["security"]>; | |
| ask: NonNullable<SystemInvokeFixtureParams["ask"]>; | |
| }; | |
| async function runLocalSystemInvokeWithPolicy( | |
| security: ExplicitSystemInvokePolicy["security"], | |
| ask: ExplicitSystemInvokePolicy["ask"], | |
| params: Omit<SystemInvokeFixtureParams, "preferMacAppExecHost" | "security" | "ask"> = {}, | |
| ) { | |
| return await runLocalSystemInvoke({ ...params, security, ask }); | |
| } | |
| async function runMacSystemInvokeWithPolicy( | |
| security: ExplicitSystemInvokePolicy["security"], | |
| ask: ExplicitSystemInvokePolicy["ask"], | |
| params: Omit<SystemInvokeFixtureParams, "preferMacAppExecHost" | "security" | "ask"> = {}, | |
| ) { | |
| return await runMacSystemInvoke({ ...params, security, ask }); | |
| } | |
| it("preserves a native cwd refusal without labelling it approval-required", async () => { | |
| const result = await runMacSystemInvoke({ | |
| runViaResponse: { | |
| ok: false, | |
| error: { | |
| code: "UNAVAILABLE", | |
| reason: "cwd-unavailable", | |
| message: "Working directory does not exist, is inaccessible, or is not a directory.", | |
| }, | |
| }, | |
| }); | |
| expectExecDeniedEvent(result.sendNodeEvent, "cwd-unavailable"); | |
| expect(result.runCommand).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps a lost companion response ambiguous", async () => { | |
| const result = await runMacSystemInvoke({ execHostFallbackAllowed: false }); | |
| expect(result.runViaMacAppExecHost).toHaveBeenCalledOnce(); | |
| expect(result.runCommand).not.toHaveBeenCalled(); | |
| expect(requireInvokeResult(result.sendInvokeResult)).toMatchObject({ | |
| ok: false, | |
| error: { code: "UNAVAILABLE" }, | |
| }); | |
| }); | |
| it("forwards cancellation to locally spawned node commands", async () => { | |
| const controller = new AbortController(); | |
| const result = await runLocalSystemInvoke({ signal: controller.signal }); | |
| expect(result.runCommand.mock.calls[0]?.[4]).toBe(controller.signal); | |
| }); | |
| it("does not spawn an already-cancelled node command", async () => { | |
| const controller = new AbortController(); | |
| controller.abort(); | |
| const result = await runLocalSystemInvoke({ signal: controller.signal }); | |
| expect(result.runCommand).not.toHaveBeenCalled(); | |
| expect(result.runViaMacAppExecHost).not.toHaveBeenCalled(); | |
| }); | |
| it("does not publish a cancelled local command completion", async () => { | |
| const controller = new AbortController(); | |
| const result = await runLocalSystemInvoke({ | |
| signal: controller.signal, | |
| runCommand: async () => { | |
| controller.abort(); | |
| return createLocalRunResult("cancelled"); | |
| }, | |
| }); | |
| expect(result.runCommand).toHaveBeenCalledOnce(); | |
| expect(result.sendInvokeResult).not.toHaveBeenCalled(); | |
| expect(result.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| }); | |
| it.each([null, createMacExecHostSuccess()])( | |
| "cancels pending Mac exec without replay or publication (%j)", | |
| async (response) => { | |
| const controller = new AbortController(); | |
| const result = await runMacSystemInvoke({ | |
| signal: controller.signal, | |
| runViaMacAppExecHost: ({ signal }) => { | |
| expect(signal).toBe(controller.signal); | |
| return new Promise((resolve) => { | |
| signal?.addEventListener("abort", () => resolve(response), { once: true }); | |
| queueMicrotask(() => controller.abort()); | |
| }); | |
| }, | |
| }); | |
| expect(result.runViaMacAppExecHost).toHaveBeenCalledOnce(); | |
| expect(result.runCommand).not.toHaveBeenCalled(); | |
| expect(result.sendNodeEvent).not.toHaveBeenCalled(); | |
| expect(result.sendInvokeResult).not.toHaveBeenCalled(); | |
| expect(result.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it("routes local, mac host, and canonical shell-wrapper requests", async () => { | |
| const localInvoke = await runLocalSystemInvoke({}); | |
| expect(localInvoke.runViaMacAppExecHost).not.toHaveBeenCalled(); | |
| expect(localInvoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(localInvoke.sendInvokeResult, "local-ok"); | |
| const macHostInvoke = await runMacSystemInvoke({ runViaResponse: createMacExecHostSuccess() }); | |
| const macHostCall = requireMacExecHostCall(macHostInvoke.runViaMacAppExecHost); | |
| expect(macHostCall.approvals?.agent?.security).toBe("full"); | |
| expect(macHostCall.approvals?.agent?.ask).toBe("off"); | |
| expect(macHostCall.request?.command).toEqual(["echo", "ok"]); | |
| expect(macHostInvoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeOk(macHostInvoke.sendInvokeResult, "app-ok"); | |
| const shellWrapperInvoke = await runMacSystemInvoke({ | |
| command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], | |
| runViaResponse: createMacExecHostSuccess(), | |
| }); | |
| const shellWrapperCall = requireMacExecHostCall(shellWrapperInvoke.runViaMacAppExecHost); | |
| if (shellWrapperCall.approvals === undefined) { | |
| throw new Error("Expected shell-wrapper approvals"); | |
| } | |
| expect(shellWrapperCall.request?.command).toEqual([ | |
| "/bin/sh", | |
| "-lc", | |
| '$0 "$1"', | |
| "/usr/bin/touch", | |
| "/tmp/marker", | |
| ]); | |
| expect(shellWrapperCall.request?.rawCommand).toBe( | |
| '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', | |
| ); | |
| }); | |
| it.each(["low", "medium"] as const)( | |
| "uses auto reviewer for system.run approval misses with %s risk when exec mode is auto", | |
| async (risk) => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-"); | |
| const executablePath = createTempExecutable(tmp, "read-info"); | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| mode: "auto", | |
| }, | |
| }, | |
| }); | |
| try { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "allow-once", | |
| rationale: "reads fixture metadata only", | |
| risk, | |
| })); | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const runCommand = vi.fn(async () => createLocalRunResult("auto-reviewed")); | |
| const prepared = buildCwdApprovalPlan([executablePath], tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const invoke = await runLocalSystemInvoke({ | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| runCommand, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(autoReviewer).toHaveBeenCalledTimes(1); | |
| expect(autoReviewer).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| command: executablePath, | |
| argv: [executablePath], | |
| cwd: tmp, | |
| host: "node", | |
| reason: "approval-required", | |
| analysis: expect.objectContaining({ | |
| parsed: true, | |
| allowlistMatched: false, | |
| inlineEval: false, | |
| }), | |
| }), | |
| ); | |
| expect(runCommand).toHaveBeenCalledTimes(1); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "auto-review" }), | |
| }), | |
| ); | |
| expectInvokeOk(invoke.sendInvokeResult, "auto-reviewed"); | |
| const macInvoke = await runMacSystemInvoke({ | |
| runViaResponse: createMacExecHostSuccess(), | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| const macCall = requireMacExecHostCall(macInvoke.runViaMacAppExecHost); | |
| expect(macCall.request?.approvalSource).toBe("auto-review"); | |
| expect(macCall.request?.approvalDecision).toBeNull(); | |
| expect(macCall.request?.policySnapshot).toEqual( | |
| createExecApprovalPolicySnapshot({ file: loadExecApprovals(), agentId: undefined }), | |
| ); | |
| expect(macInvoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeOk(macInvoke.sendInvokeResult, "app-ok"); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }, | |
| ); | |
| it("does not auto-review direct system.run approval misses without an approval plan", async () => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-no-plan-"); | |
| const executablePath = createTempExecutable(tmp, "read-info"); | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| mode: "auto", | |
| }, | |
| }, | |
| }); | |
| try { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "allow-once", | |
| rationale: "reads fixture metadata only", | |
| risk: "low", | |
| })); | |
| const runCommand = vi.fn(async () => createLocalRunResult("should-not-run")); | |
| const invoke = await runLocalSystemInvoke({ | |
| command: [executablePath], | |
| cwd: tmp, | |
| runCommand, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| expect(autoReviewer).not.toHaveBeenCalled(); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(invoke.sendInvokeResult, "SYSTEM_RUN_DENIED: approval required"); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it.each([ | |
| { | |
| name: "throws synchronously", | |
| reviewer: () => { | |
| throw new Error("provider\n\u001b[31mfailed\u001b[0m\u202e"); | |
| }, | |
| }, | |
| { | |
| name: "rejects asynchronously", | |
| reviewer: async () => { | |
| throw new Error("provider\n\u001b[31mfailed\u001b[0m\u202e"); | |
| }, | |
| }, | |
| ])("denies direct system.run when its reviewer $name", async ({ reviewer }) => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-failure-"); | |
| const executablePath = createTempExecutable(tmp, "read-info"); | |
| setRuntimeConfigSnapshot({ tools: { exec: { mode: "auto" } } }); | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(reviewer); | |
| const runCommand = vi.fn(async () => createLocalRunResult("should-not-run")); | |
| const prepared = buildCwdApprovalPlan([executablePath], tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "expected a bound system.run approval plan"); | |
| const invoke = await runLocalSystemInvoke({ | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| runCommand, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| expect(autoReviewer).toHaveBeenCalledTimes(1); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "exec auto-review deferred to human approval: exec reviewer failed: provider\\nfailed", | |
| ); | |
| }); | |
| it.runIf(process.platform !== "win32").each(["bash", "sh", "/bin/sh"])( | |
| "does not auto-review direct %s login-shell startup", | |
| async (shell) => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-login-"); | |
| setRuntimeConfigSnapshot({ tools: { exec: { mode: "auto" } } }); | |
| try { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "allow-once", | |
| rationale: "unsafe startup wrapper must not reach the reviewer", | |
| risk: "low", | |
| })); | |
| const loginCommand = `${shell} -lc "echo auto-review-startup-proof"`; | |
| const command = ["/bin/sh", "-lc", loginCommand]; | |
| // The real plan builder already rejects this wrapper. Exercise the | |
| // node trust boundary against a hostile, otherwise well-formed plan. | |
| const approvalPlan = { | |
| argv: command, | |
| cwd: tmp, | |
| commandText: formatExecCommand(command), | |
| agentId: "main", | |
| sessionKey: "agent:main:main", | |
| } satisfies SystemRunApprovalPlan; | |
| const invoke = await runLocalSystemInvoke({ | |
| command, | |
| rawCommand: approvalPlan.commandText, | |
| cwd: tmp, | |
| systemRunPlan: approvalPlan, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| expect(autoReviewer).not.toHaveBeenCalled(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(invoke.sendInvokeResult, "SYSTEM_RUN_DENIED: approval required"); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }, | |
| ); | |
| it("does not auto-review direct system.run security audit suppression edits", async () => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-suppression-"); | |
| const executablePath = createTempExecutable(tmp, "openclaw"); | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| mode: "auto", | |
| }, | |
| }, | |
| }); | |
| try { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "allow-once", | |
| rationale: "test reviewer would allow it", | |
| risk: "low", | |
| })); | |
| const runCommand = vi.fn(async () => createLocalRunResult("should-not-run")); | |
| const prepared = buildCwdApprovalPlan( | |
| [executablePath, "config", "set", "security.audit.suppressions", "[]"], | |
| tmp, | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const invoke = await runLocalSystemInvoke({ | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| runCommand, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| expect(autoReviewer).not.toHaveBeenCalled(); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(invoke.sendInvokeResult, "SYSTEM_RUN_DENIED: approval required"); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it.each(["ask", "deny"] as const)( | |
| "does not execute when system.run auto reviewer returns %s", | |
| async (decision) => { | |
| const tmp = createFixtureDir("openclaw-system-run-auto-review-ask-"); | |
| const executablePath = createTempExecutable(tmp, "read-info"); | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| mode: "auto", | |
| }, | |
| }, | |
| }); | |
| try { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision, | |
| rationale: "needs a person", | |
| risk: "medium", | |
| })); | |
| const runCommand = vi.fn(async () => createLocalRunResult("should-not-run")); | |
| const prepared = buildCwdApprovalPlan([executablePath], tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const invoke = await runLocalSystemInvoke({ | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| runCommand, | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| }); | |
| expect(autoReviewer).toHaveBeenCalledTimes(1); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| if (decision === "deny") { | |
| expect(requireInvokeResult(invoke.sendInvokeResult)).toEqual({ | |
| ok: false, | |
| error: { | |
| code: "SYSTEM_RUN_DENIED", | |
| message: | |
| "SYSTEM_RUN_DENIED: auto-review denied (risk=medium): needs a person\n" + | |
| "Do not attempt the same outcome through a workaround, indirect execution, or policy circumvention. Proceed only with a materially safer alternative, or ask the user to approve this exact command after explaining the risk.", | |
| }, | |
| }); | |
| expect(invoke.sendNodeEvent).toHaveBeenCalledWith( | |
| expect.anything(), | |
| "exec.denied", | |
| expect.objectContaining({ reason: "auto-review-denied" }), | |
| ); | |
| } else { | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "exec auto-review deferred to human approval", | |
| ); | |
| } | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }, | |
| ); | |
| const approvedEnvShellWrapperCases = [ | |
| { | |
| name: "preserves wrapper argv for approved env shell commands in local execution", | |
| preferMacAppExecHost: false, | |
| }, | |
| { | |
| name: "preserves wrapper argv for approved env shell commands in mac app exec host forwarding", | |
| preferMacAppExecHost: true, | |
| }, | |
| ] as const; | |
| it.runIf(process.platform !== "win32")( | |
| "preserves wrapper argv for approved env shell commands", | |
| async () => { | |
| for (const testCase of approvedEnvShellWrapperCases) { | |
| const tmp = createFixtureDir("openclaw-approved-wrapper-"); | |
| const marker = path.join(tmp, "marker"); | |
| const attackerScript = path.join(tmp, "sh"); | |
| fs.writeFileSync(attackerScript, "#!/bin/sh\necho exploited > marker\n"); | |
| fs.chmodSync(attackerScript, 0o755); | |
| const runCommand = vi.fn(async (argv: string[]) => { | |
| if (argv[0] === "/bin/sh" && argv[1] === "sh" && argv[2] === "-c") { | |
| fs.writeFileSync(marker, "rewritten"); | |
| } | |
| return createLocalRunResult(); | |
| }); | |
| const sendInvokeResult = vi.fn(async () => {}); | |
| const invoke = await runSystemInvoke({ | |
| preferMacAppExecHost: testCase.preferMacAppExecHost, | |
| command: ["env", "sh", "-c", "echo SAFE"], | |
| cwd: tmp, | |
| approved: true, | |
| security: "allowlist", | |
| ask: "on-miss", | |
| runCommand, | |
| sendInvokeResult, | |
| runViaResponse: testCase.preferMacAppExecHost | |
| ? { | |
| ok: true, | |
| payload: { | |
| success: true, | |
| stdout: "app-ok", | |
| stderr: "", | |
| timedOut: false, | |
| exitCode: 0, | |
| error: null, | |
| }, | |
| } | |
| : undefined, | |
| }); | |
| if (testCase.preferMacAppExecHost) { | |
| const canonicalCwd = fs.realpathSync(tmp); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| const macHostCall = requireMacExecHostCall(invoke.runViaMacAppExecHost); | |
| if (macHostCall.approvals === undefined) { | |
| throw new Error("Expected Mac host approvals"); | |
| } | |
| expect(macHostCall.request?.command).toEqual(["env", "sh", "-c", "echo SAFE"]); | |
| expect(macHostCall.request?.rawCommand).toBe('env sh -c "echo SAFE"'); | |
| expect(macHostCall.request?.cwd).toBe(canonicalCwd); | |
| expect(macHostCall.request?.approvalDecision).toBe("allow-once"); | |
| expect(macHostCall.request?.approvalSource).toBeUndefined(); | |
| expect(macHostCall.request?.policySnapshot).toEqual( | |
| createExecApprovalPolicySnapshot({ file: loadExecApprovals(), agentId: undefined }), | |
| ); | |
| expectInvokeOk(invoke.sendInvokeResult, "app-ok"); | |
| continue; | |
| } | |
| expect(requireFirstRunCommandArgs(invoke.runCommand)).toEqual([ | |
| "env", | |
| "sh", | |
| "-c", | |
| "echo SAFE", | |
| ]); | |
| expect(fs.existsSync(marker)).toBe(false); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| } | |
| }, | |
| ); | |
| it("handles transparent and semantic env wrappers in allowlist mode", async () => { | |
| const oldPath = process.env.PATH; | |
| if (process.platform !== "win32") { | |
| process.env.PATH = "/usr/bin:/bin"; | |
| } | |
| try { | |
| const transparent = await runLocalSystemInvoke({ | |
| security: "allowlist", | |
| command: ["env", "tr", "a", "b"], | |
| }); | |
| if (process.platform === "win32") { | |
| expect(transparent.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(transparent.sendInvokeResult, "allowlist miss"); | |
| } else { | |
| const expectedTrPath = fs.realpathSync( | |
| fs.existsSync("/usr/bin/tr") ? "/usr/bin/tr" : "/bin/tr", | |
| ); | |
| expect(requireFirstRunCommandArgs(transparent.runCommand)).toEqual([ | |
| expectedTrPath, | |
| "a", | |
| "b", | |
| ]); | |
| expectInvokeOk(transparent.sendInvokeResult); | |
| } | |
| const semantic = await runLocalSystemInvoke({ | |
| security: "allowlist", | |
| command: ["env", "FOO=bar", "tr", "a", "b"], | |
| }); | |
| expect(semantic.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(semantic.sendInvokeResult, "allowlist miss"); | |
| } finally { | |
| if (oldPath === undefined) { | |
| delete process.env.PATH; | |
| } else { | |
| process.env.PATH = oldPath; | |
| } | |
| } | |
| }); | |
| it("denies shell payload carriers in allowlist mode without explicit approval", async () => { | |
| const shellPayloadCases: Array< | |
| | { | |
| label: string; | |
| command: string[]; | |
| ask?: "off" | "on-miss"; | |
| message: string; | |
| approvalRequired?: false; | |
| } | |
| | { | |
| label: string; | |
| command: string[]; | |
| ask?: "off" | "on-miss"; | |
| approvalRequired: true; | |
| } | |
| > = [ | |
| { | |
| label: "env -S", | |
| command: ["env", "-S", 'sh -c "echo pwned"'], | |
| message: "allowlist miss", | |
| ask: "off", | |
| }, | |
| { | |
| label: "semicolon chain simple command", | |
| command: | |
| process.platform === "win32" | |
| ? ["cmd.exe", "/d", "/s", "/c", "openclaw status; id"] | |
| : ["/bin/sh", "-lc", "openclaw status; id"], | |
| approvalRequired: true, | |
| }, | |
| { | |
| label: "semicolon chain path read", | |
| command: | |
| process.platform === "win32" | |
| ? ["cmd.exe", "/d", "/s", "/c", "openclaw status; cat /etc/passwd"] | |
| : ["/bin/sh", "-lc", "openclaw status; cat /etc/passwd"], | |
| approvalRequired: true, | |
| }, | |
| { | |
| label: "PowerShell encoded command", | |
| command: ["pwsh", "-EncodedCommand", "ZQBjAGgAbwAgAHAAdwBuAGUAZAA="], | |
| approvalRequired: true, | |
| }, | |
| ]; | |
| for (const testCase of shellPayloadCases) { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| testCase.ask ?? "on-miss", | |
| { command: testCase.command }, | |
| ); | |
| expect(runCommand, testCase.label).not.toHaveBeenCalled(); | |
| if (testCase.approvalRequired) { | |
| expectApprovalRequiredDenied(sendNodeEvent, sendInvokeResult); | |
| } else if ("message" in testCase) { | |
| expectInvokeErrorMessage(sendInvokeResult, testCase.message); | |
| } | |
| } | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "denies safe-bin shell expansion carriers in allowlist mode", | |
| async () => { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "off", | |
| { | |
| command: ["/bin/sh", "-lc", "head -c${IFS}16${IFS}${OPENCLAW_CONFIG_PATH}"], | |
| rawCommand: "head -c${IFS}16${IFS}${OPENCLAW_CONFIG_PATH}", | |
| }, | |
| ); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(sendInvokeResult, "allowlist miss"); | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "rewrites safe-bin shell payloads before execution in allowlist mode", | |
| async () => { | |
| const oldPath = process.env.PATH; | |
| process.env.PATH = "/usr/bin:/bin"; | |
| try { | |
| const expectedHeadPath = fs.realpathSync( | |
| fs.existsSync("/usr/bin/head") ? "/usr/bin/head" : "/bin/head", | |
| ); | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "off", | |
| { command: ["/bin/sh", "-lc", "head -c 16"], rawCommand: "head -c 16" }, | |
| ); | |
| expect(requireFirstRunCommandArgs(runCommand)).toEqual([ | |
| "/bin/sh", | |
| "-lc", | |
| `${expectedHeadPath} -c 16`, | |
| ]); | |
| expectInvokeOk(sendInvokeResult); | |
| } finally { | |
| if (oldPath === undefined) { | |
| delete process.env.PATH; | |
| } else { | |
| process.env.PATH = oldPath; | |
| } | |
| } | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "rewrites nested safe-bin shell chains before execution in allowlist mode", | |
| async () => { | |
| const oldPath = process.env.PATH; | |
| process.env.PATH = "/usr/bin:/bin"; | |
| try { | |
| const expectedTrPath = fs.realpathSync( | |
| fs.existsSync("/usr/bin/tr") ? "/usr/bin/tr" : "/bin/tr", | |
| ); | |
| const expectedHeadPath = fs.realpathSync( | |
| fs.existsSync("/usr/bin/head") ? "/usr/bin/head" : "/bin/head", | |
| ); | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "off", | |
| { | |
| command: ["/bin/sh", "-lc", "sh -c 'tr a b && head -c 16'"], | |
| rawCommand: "sh -c 'tr a b && head -c 16'", | |
| }, | |
| ); | |
| const payload = requireFirstRunCommandArgs(runCommand)[2] ?? ""; | |
| expect(payload).not.toContain("tr a b && head -c 16"); | |
| expect(payload).toContain(expectedTrPath); | |
| expect(payload).toContain(expectedHeadPath); | |
| expectInvokeOk(sendInvokeResult); | |
| } finally { | |
| if (oldPath === undefined) { | |
| delete process.env.PATH; | |
| } else { | |
| process.env.PATH = oldPath; | |
| } | |
| } | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "does not apply POSIX safe-bin shell rewrites to PowerShell wrappers", | |
| async () => { | |
| const oldPath = process.env.PATH; | |
| process.env.PATH = "/usr/bin:/bin"; | |
| try { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "off", | |
| { command: ["pwsh", "-Command", "head -c 16"] }, | |
| ); | |
| expect(requireFirstRunCommandArgs(runCommand)).toEqual(["pwsh", "-Command", "head -c 16"]); | |
| expectInvokeOk(sendInvokeResult); | |
| } finally { | |
| if (oldPath === undefined) { | |
| delete process.env.PATH; | |
| } else { | |
| process.env.PATH = oldPath; | |
| } | |
| } | |
| }, | |
| ); | |
| it("denies abbreviated PowerShell encoded payloads even when the wrapper is allowlisted", async () => { | |
| const binDir = createFixtureDir("openclaw-pwsh-allowlist-"); | |
| const executablePath = createTempExecutable(binDir, "pwsh"); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [{ pattern: executablePath }], | |
| }, | |
| }, | |
| }), | |
| async () => { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = | |
| await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [ | |
| executablePath, | |
| "-win", | |
| "hidden", | |
| "-if", | |
| "XML", | |
| "-config", | |
| "SomeConfig", | |
| "/NoProfile", | |
| "/ec", | |
| "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", | |
| ], | |
| }); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(sendNodeEvent, sendInvokeResult); | |
| const commandWithArgs = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [executablePath, "-cwa", "Write-Output", "hi"], | |
| }); | |
| expect(commandWithArgs.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied( | |
| commandWithArgs.sendNodeEvent, | |
| commandWithArgs.sendInvokeResult, | |
| ); | |
| }, | |
| ); | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "pins PATH-token executable to canonical path", | |
| async () => { | |
| await withPathTokenCommand("openclaw-approval-path-pin-", async ({ expected }) => { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "full", | |
| "off", | |
| { command: ["poccmd", "-n", "SAFE"], approved: true }, | |
| ); | |
| expectCommandPinnedToCanonicalPath( | |
| runCommand, | |
| expected, | |
| ["-n", "SAFE"], | |
| fs.realpathSync(process.cwd()), | |
| ); | |
| expectInvokeOk(sendInvokeResult); | |
| }); | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "pins PATH-token executable to canonical path for allowlist runs", | |
| async () => { | |
| const runCommand = vi.fn(async () => ({ | |
| ...createLocalRunResult(), | |
| })); | |
| const sendInvokeResult = vi.fn(async () => {}); | |
| await withPathTokenCommand( | |
| "openclaw-allowlist-path-pin-", | |
| async ({ link: _link, expected }) => { | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "off", "deny", { | |
| main: { | |
| allowlist: [{ pattern: expected }], | |
| }, | |
| }), | |
| async () => { | |
| await runLocalSystemInvokeWithPolicy("allowlist", "off", { | |
| command: ["poccmd", "-n", "SAFE"], | |
| runCommand, | |
| sendInvokeResult, | |
| }); | |
| }, | |
| ); | |
| expectCommandPinnedToCanonicalPath( | |
| runCommand, | |
| expected, | |
| ["-n", "SAFE"], | |
| fs.realpathSync(process.cwd()), | |
| ); | |
| expectInvokeOk(sendInvokeResult); | |
| }, | |
| ); | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "rejects symlinked cwd paths during approval preparation", | |
| async () => { | |
| for (const testCase of [ | |
| { | |
| label: "cwd symlink", | |
| setup: () => { | |
| const tmp = createFixtureDir("openclaw-approval-cwd-link-"); | |
| const safeDir = path.join(tmp, "safe"); | |
| const linkDir = path.join(tmp, "cwd-link"); | |
| const script = path.join(safeDir, "run.sh"); | |
| fs.mkdirSync(safeDir, { recursive: true }); | |
| fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); | |
| fs.chmodSync(script, 0o755); | |
| fs.symlinkSync(safeDir, linkDir, "dir"); | |
| return { | |
| cwd: linkDir, | |
| message: "canonical cwd", | |
| }; | |
| }, | |
| }, | |
| { | |
| label: "parent symlink", | |
| setup: () => { | |
| const tmp = createFixtureDir("openclaw-approval-cwd-parent-link-"); | |
| const safeSymlinkRoot = path.join(tmp, "safe-root"); | |
| const safeSymlinkSub = path.join(safeSymlinkRoot, "sub"); | |
| const linkRoot = path.join(tmp, "approved-link"); | |
| fs.mkdirSync(safeSymlinkSub, { recursive: true }); | |
| fs.symlinkSync(safeSymlinkRoot, linkRoot, "dir"); | |
| return { | |
| cwd: path.join(linkRoot, "sub"), | |
| message: "no symlink path components", | |
| }; | |
| }, | |
| }, | |
| ]) { | |
| const { cwd, message } = testCase.setup(); | |
| const prepared = buildSystemRunApprovalPlan({ | |
| command: ["./run.sh"], | |
| cwd, | |
| }); | |
| expect(prepared.ok, testCase.label).toBe(false); | |
| if (!prepared.ok) { | |
| expect(prepared.message, testCase.label).toContain(message); | |
| } | |
| } | |
| }, | |
| ); | |
| it("uses canonical executable path for approval-based relative command execution", async () => { | |
| const tmp = createFixtureDir("openclaw-approval-cwd-real-"); | |
| const script = path.join(tmp, "run.sh"); | |
| fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); | |
| fs.chmodSync(script, 0o755); | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command: ["./run.sh", "--flag"], | |
| cwd: tmp, | |
| approved: true, | |
| }); | |
| if (process.platform === "win32") { | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval requires a stable executable path", | |
| true, | |
| ); | |
| return; | |
| } | |
| expectCommandPinnedToCanonicalPath( | |
| runCommand, | |
| fs.realpathSync(script), | |
| ["--flag"], | |
| fs.realpathSync(tmp), | |
| ); | |
| expectInvokeOk(sendInvokeResult); | |
| }); | |
| it("denies approval-based execution when cwd identity drifts before execution", async () => { | |
| const tmp = createFixtureDir("openclaw-approval-cwd-drift-"); | |
| const fallback = createFixtureDir("openclaw-approval-cwd-drift-alt-"); | |
| const script = path.join(tmp, "run.sh"); | |
| fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); | |
| fs.chmodSync(script, 0o755); | |
| const canonicalCwd = fs.realpathSync(tmp); | |
| const prepared = buildCwdSessionApprovalPlan(["./run.sh"], tmp, "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withMockedCwdIdentityDrift({ | |
| canonicalCwd, | |
| driftDir: fallback, | |
| run: async () => { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "full", | |
| "off", | |
| { preparedPlan: prepared.plan, cwd: prepared.plan.cwd ?? tmp, approved: true }, | |
| ); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| if (process.platform === "win32") { | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval requires a stable executable path", | |
| true, | |
| ); | |
| return; | |
| } | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval cwd changed before execution", | |
| true, | |
| ); | |
| }, | |
| }); | |
| }); | |
| it("validates approved script operand bindings at dispatch", async () => { | |
| for (const mutate of [true, false]) { | |
| const tmp = createFixtureDir( | |
| mutate ? "openclaw-approval-script-drift-" : "openclaw-approval-script-stable-", | |
| ); | |
| const fixture = createMutableScriptOperandFixture(tmp); | |
| fs.writeFileSync(fixture.scriptPath, fixture.initialBody); | |
| if (process.platform !== "win32") { | |
| fs.chmodSync(fixture.scriptPath, 0o755); | |
| } | |
| const prepared = buildCwdApprovalPlan(fixture.command, tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| if (mutate) { | |
| fs.writeFileSync(fixture.scriptPath, fixture.changedBody); | |
| } | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| approved: true, | |
| }); | |
| if (mutate) { | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval script operand changed before execution", | |
| true, | |
| ); | |
| } else { | |
| expect(runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(sendInvokeResult); | |
| } | |
| } | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "revalidates approved cwd identity after authorization commit", | |
| async () => { | |
| const tmp = createFixtureDir("openclaw-approval-cwd-post-commit-drift-"); | |
| const moved = `${tmp}-approved`; | |
| const script = path.join(tmp, "run.sh"); | |
| fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); | |
| fs.chmodSync(script, 0o755); | |
| const commitAuthorization: HandleSystemRunInvokeOptions["commitExecAuthorization"] = async ( | |
| params, | |
| ) => { | |
| const assertCurrent = await commitExecAuthorizationLocked(params); | |
| fs.renameSync(tmp, moved); | |
| fs.mkdirSync(tmp); | |
| fs.writeFileSync(path.join(tmp, "run.sh"), "#!/bin/sh\necho CHANGED\n", { mode: 0o755 }); | |
| return assertCurrent; | |
| }; | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command: ["./run.sh"], | |
| cwd: tmp, | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval cwd changed before execution", | |
| true, | |
| ); | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32").each([ | |
| { boundary: "commit", revoke: true }, | |
| { boundary: "commit", revoke: false }, | |
| { boundary: "callback", revoke: true }, | |
| { boundary: "callback", revoke: false }, | |
| ] as const)( | |
| "checks live node policy at $boundary before real execution (revoke=$revoke)", | |
| async ({ boundary, revoke }) => { | |
| const { testing } = await import("./invoke.test-support.js"); | |
| const cwd = createFixtureDir("openclaw-node-policy-before-spawn-"); | |
| fs.writeFileSync(path.join(cwd, "approved.txt"), ""); | |
| const revokePolicy = () => { | |
| if (!revoke) { | |
| return; | |
| } | |
| const current = loadExecApprovals(); | |
| current.defaults = { ...current.defaults, security: "deny", ask: "off" }; | |
| current.agents = { ...current.agents, main: { security: "deny", ask: "off" } }; | |
| saveExecApprovals(current); | |
| }; | |
| let stdout = ""; | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command: ["/bin/ls", "approved.txt"], | |
| cwd, | |
| commitExecAuthorization: async (params) => { | |
| const assertCurrent = await commitExecAuthorizationLocked(params); | |
| if (boundary === "commit") { | |
| revokePolicy(); | |
| } | |
| return assertCurrent; | |
| }, | |
| runCommand: async (argv, runCwd, _env, timeoutMs, signal, assertCurrent) => { | |
| await Promise.resolve(); | |
| if (boundary === "callback") { | |
| revokePolicy(); | |
| } | |
| const result = await testing.runCommand( | |
| argv, | |
| runCwd, | |
| { PATH: "/usr/bin:/bin", HOME: cwd }, | |
| timeoutMs, | |
| signal, | |
| assertCurrent, | |
| ); | |
| stdout = result.stdout; | |
| return result; | |
| }, | |
| }); | |
| expect(stdout).toBe(revoke ? "" : "approved.txt\n"); | |
| expect(requireInvokeResult(invoke.sendInvokeResult).ok).toBe(!revoke); | |
| expect(invoke.sendExecFinishedEvent.mock.calls.length).toBe(revoke ? 0 : 1); | |
| if (revoke) { | |
| expect(requireInvokeResult(invoke.sendInvokeResult).error?.code).toBe("SYSTEM_RUN_DENIED"); | |
| expectInvokeErrorMessage(invoke.sendInvokeResult, "exec approval changed before execution"); | |
| expectExecDeniedEvent(invoke.sendNodeEvent); | |
| } | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32").each([ | |
| { approval: "auto", driftAt: "unchanged" }, | |
| { approval: "human", driftAt: "commit" }, | |
| { approval: "human", driftAt: "unchanged" }, | |
| ] as const)( | |
| "checks executable identity for $approval approval when resolution is $driftAt", | |
| async ({ approval, driftAt }) => { | |
| const tmp = createFixtureDir("openclaw-approval-executable-identity-"); | |
| const prepared = buildCwdApprovalPlan(["/bin/sh", "-c", "ls *.ts"], tmp); | |
| requireApprovalPlan(prepared, "expected a bound shell command plan"); | |
| const resolveCommand = commandResolution.resolveCommandResolutionFromArgv; | |
| let changed = false; | |
| const resolutionSpy = vi | |
| .spyOn(commandResolution, "resolveCommandResolutionFromArgv") | |
| .mockImplementation((...args) => { | |
| const resolution = resolveCommand(...args); | |
| if (!changed || args[0][0] !== "ls" || !resolution) { | |
| return resolution; | |
| } | |
| return { | |
| ...resolution, | |
| execution: { | |
| ...resolution.execution, | |
| resolvedPath: "/synthetic/changed/ls", | |
| resolvedRealPath: "/synthetic/changed/ls", | |
| }, | |
| }; | |
| }); | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "allow-once", | |
| rationale: "lists fixture files", | |
| risk: "low", | |
| })); | |
| const commitAuthorization: HandleSystemRunInvokeOptions["commitExecAuthorization"] = async ( | |
| params, | |
| ) => { | |
| const assertCurrent = await commitExecAuthorizationLocked(params); | |
| changed = driftAt === "commit"; | |
| return assertCurrent; | |
| }; | |
| setRuntimeConfigSnapshot({ tools: { exec: { mode: "auto" } } }); | |
| try { | |
| const invoke = await runLocalSystemInvoke({ | |
| command: prepared.plan.argv, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| systemRunPlan: prepared.plan, | |
| ...(approval === "human" ? { approvalDecision: "allow-once" } : {}), | |
| resolveExecSecurity: resolveProductionExecSecurity, | |
| resolveExecAsk: resolveProductionExecAsk, | |
| autoReviewer, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(autoReviewer).not.toHaveBeenCalled(); | |
| if (approval === "auto") { | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "Exec auto-review skipped: dispatch chain cannot be bound", | |
| ); | |
| } else if (driftAt === "unchanged") { | |
| expect(invoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| } else { | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval script operand changed before execution", | |
| true, | |
| ); | |
| } | |
| } finally { | |
| resolutionSpy.mockRestore(); | |
| } | |
| }, | |
| ); | |
| it("revalidates approved script operands after authorization commit", async () => { | |
| const tmp = createFixtureDir("openclaw-approval-script-post-commit-drift-"); | |
| const fixture = createMutableScriptOperandFixture(tmp); | |
| fs.writeFileSync(fixture.scriptPath, fixture.initialBody); | |
| if (process.platform !== "win32") { | |
| fs.chmodSync(fixture.scriptPath, 0o755); | |
| } | |
| const prepared = buildCwdApprovalPlan(fixture.command, tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commitAuthorization: HandleSystemRunInvokeOptions["commitExecAuthorization"] = async ( | |
| params, | |
| ) => { | |
| const assertCurrent = await commitExecAuthorizationLocked(params); | |
| fs.writeFileSync(fixture.scriptPath, fixture.changedBody); | |
| return assertCurrent; | |
| }; | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval script operand changed before execution", | |
| true, | |
| ); | |
| }); | |
| it("validates approved runtime script operand bindings at dispatch", async () => { | |
| await withFakeRuntimeOnPath("tsx", async () => { | |
| const tmp = createFixtureDir("openclaw-approval-tsx-script-drift-"); | |
| const fixture = createRuntimeScriptOperandFixture(tmp, "tsx"); | |
| fs.writeFileSync(fixture.scriptPath, fixture.initialBody); | |
| const prepared = buildCwdApprovalPlan(fixture.command, tmp); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| fs.writeFileSync(fixture.scriptPath, fixture.changedBody); | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tmp, | |
| approved: true, | |
| }); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval script operand changed before execution", | |
| true, | |
| ); | |
| const missingBindingTmp = createFixtureDir("openclaw-approval-tsx-missing-binding-"); | |
| const missingBindingFixture = createRuntimeScriptOperandFixture(missingBindingTmp, "tsx"); | |
| fs.writeFileSync(missingBindingFixture.scriptPath, missingBindingFixture.initialBody); | |
| const missingBindingPrepared = buildCwdApprovalPlan( | |
| missingBindingFixture.command, | |
| missingBindingTmp, | |
| ); | |
| expect(missingBindingPrepared.ok).toBe(true); | |
| if (!missingBindingPrepared.ok) { | |
| throw new Error("unreachable"); | |
| } | |
| const planWithoutBinding = { ...missingBindingPrepared.plan }; | |
| delete planWithoutBinding.mutableFileOperand; | |
| const missingBindingRun = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: planWithoutBinding, | |
| cwd: missingBindingPrepared.plan.cwd ?? missingBindingTmp, | |
| approved: true, | |
| }); | |
| expect(missingBindingRun.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| missingBindingRun.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval missing script operand binding", | |
| true, | |
| ); | |
| }); | |
| }); | |
| it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { | |
| const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); | |
| const runCommand = vi.fn(async () => { | |
| fs.writeFileSync(marker, "executed"); | |
| return createLocalRunResult(); | |
| }); | |
| const sendInvokeResult = vi.fn(async () => {}); | |
| const sendNodeEvent = vi.fn(async () => {}); | |
| await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: ["./sh", "-lc", "/bin/echo approved-only"], | |
| runCommand, | |
| sendInvokeResult, | |
| sendNodeEvent, | |
| }); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expect(fs.existsSync(marker)).toBe(false); | |
| expectApprovalRequiredDenied(sendNodeEvent, sendInvokeResult); | |
| try { | |
| fs.unlinkSync(marker); | |
| } catch { | |
| // no-op | |
| } | |
| }); | |
| it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = createInvokeSpies(); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ autoAllowSkills: true }), | |
| async ({ tempHome }) => { | |
| const skillBinPath = path.join(tempHome, "skill-bin"); | |
| fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); | |
| fs.chmodSync(skillBinPath, 0o755); | |
| await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: ["./skill-bin", "--help"], | |
| cwd: tempHome, | |
| skillBinsCurrent: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], | |
| runCommand, | |
| sendInvokeResult, | |
| sendNodeEvent, | |
| }); | |
| }, | |
| ); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(sendNodeEvent, sendInvokeResult); | |
| }); | |
| it("rejects unsafe environment inputs before execution", async () => { | |
| const shellCommand = | |
| process.platform === "win32" | |
| ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] | |
| : ["/bin/sh", "-lc", "echo ok"]; | |
| const cases: Array<{ | |
| label: string; | |
| command?: string[]; | |
| env?: Record<string, string>; | |
| message: string; | |
| details: string[]; | |
| }> = [ | |
| { | |
| label: "blocked override", | |
| env: { CLASSPATH: "/tmp/evil-classpath" }, | |
| message: "SYSTEM_RUN_DENIED: environment override rejected", | |
| details: ["CLASSPATH"], | |
| }, | |
| { | |
| label: "blocked override for shell-wrapper", | |
| command: shellCommand, | |
| env: { | |
| CLASSPATH: "/tmp/evil-classpath", | |
| LANG: "C", | |
| }, | |
| message: "SYSTEM_RUN_DENIED: environment override rejected", | |
| details: ["CLASSPATH"], | |
| }, | |
| { | |
| label: "blocked argv assignment", | |
| command: ["/usr/bin/env", "SHELLOPTS=xtrace", "PS4=$(id)", "bash", "-lc", "echo ok"], | |
| message: "SYSTEM_RUN_DENIED: command env assignment rejected", | |
| details: ["SHELLOPTS", "PS4"], | |
| }, | |
| { | |
| label: "invalid override key", | |
| env: { "BAD-KEY": "x" }, | |
| message: "SYSTEM_RUN_DENIED: environment override rejected", | |
| details: ["BAD-KEY"], | |
| }, | |
| ]; | |
| for (const testCase of cases) { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command: testCase.command, | |
| env: testCase.env, | |
| }); | |
| expect(runCommand, testCase.label).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(sendInvokeResult, testCase.message); | |
| for (const detail of testCase.details) { | |
| expectInvokeErrorMessage(sendInvokeResult, detail); | |
| } | |
| } | |
| }); | |
| it.each([ | |
| ["echo", "ok"], | |
| ["/bin/sh", "./script.sh"], | |
| ])("normalizes pager overrides before node execution: %j", async (...command) => { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command, | |
| env: { GIT_PAGER: "cat", PAGER: "cat" }, | |
| sanitizeEnv: (overrides) => sanitizeHostExecEnv({ baseEnv: {}, overrides }), | |
| }); | |
| expect(runCommand).toHaveBeenCalledTimes(1); | |
| expect(firstMockCallArg(runCommand, "runCommand", 2)).toMatchObject({ | |
| GIT_PAGER: "", | |
| PAGER: "", | |
| }); | |
| expectInvokeOk(sendInvokeResult); | |
| }); | |
| it("applies shell-wrapper env allowlist for shell executable commands without inline payload", async () => { | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| command: ["/bin/sh", "./script.sh"], | |
| env: { | |
| OPENCLAW_TEST: "1", | |
| LANG: "C", | |
| LC_TIME: "C", | |
| }, | |
| sanitizeEnv: (overrides) => overrides ?? undefined, | |
| }); | |
| expect(runCommand).toHaveBeenCalledTimes(1); | |
| const passedEnv = firstMockCallArg(runCommand, "runCommand", 2); | |
| expect(passedEnv).toEqual({ | |
| LANG: "C", | |
| LC_TIME: "C", | |
| }); | |
| expectInvokeOk(sendInvokeResult); | |
| }); | |
| async function expectNestedEnvShellDenied(params: { | |
| depth: number; | |
| markerName: string; | |
| errorLabel: string; | |
| }) { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = createInvokeSpies({ | |
| runCommand: vi.fn(async () => { | |
| throw new Error(params.errorLabel); | |
| }), | |
| }); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [{ pattern: "/usr/bin/env" }], | |
| }, | |
| }, | |
| }), | |
| async ({ tempHome }) => { | |
| const marker = path.join(tempHome, params.markerName); | |
| await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: buildNestedEnvShellCommand({ | |
| depth: params.depth, | |
| payload: `echo PWNED > ${marker}`, | |
| }), | |
| runCommand, | |
| sendInvokeResult, | |
| sendNodeEvent, | |
| }); | |
| expect(fs.existsSync(marker)).toBe(false); | |
| }, | |
| ); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(sendNodeEvent, sendInvokeResult); | |
| } | |
| it("denies env-wrapped shell payloads at and past the dispatch depth boundary", async () => { | |
| if (process.platform === "win32") { | |
| return; | |
| } | |
| for (const testCase of [ | |
| { | |
| depth: 4, | |
| markerName: "depth4-pwned.txt", | |
| errorLabel: "runCommand should not be called for depth-boundary shell wrappers", | |
| }, | |
| { | |
| depth: 5, | |
| markerName: "pwned.txt", | |
| errorLabel: "runCommand should not be called for nested env depth overflow", | |
| }, | |
| ]) { | |
| await expectNestedEnvShellDenied(testCase); | |
| } | |
| }); | |
| it("requires explicit approval for strict inline-eval carriers", async () => { | |
| // The full carrier matrix lives in command-analysis tests; this is the | |
| // handle-level smoke for strictInlineEval denial wiring. | |
| const cases = [ | |
| { | |
| command: ["python3", "-c", "print('hi')"], | |
| expected: "python3 -c requires explicit approval in strictInlineEval mode", | |
| }, | |
| { | |
| command: ["python3.13", "-c", "print('hi')"], | |
| expected: "python3.13 -c requires explicit approval in strictInlineEval mode", | |
| }, | |
| ] as const; | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| strictInlineEval: true, | |
| }, | |
| }, | |
| }); | |
| try { | |
| for (const testCase of cases) { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = | |
| await runLocalSystemInvokeWithPolicy("full", "off", { command: [...testCase.command] }); | |
| expect(runCommand, testCase.command.join(" ")).not.toHaveBeenCalled(); | |
| expectExecDeniedEvent(sendNodeEvent); | |
| expectInvokeErrorMessage(sendInvokeResult, testCase.expected); | |
| } | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("prefers strict inline-eval denial over generic allowlist prompts", async () => { | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| strictInlineEval: true, | |
| }, | |
| }, | |
| }); | |
| try { | |
| const { runCommand, sendInvokeResult, sendNodeEvent } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "on-miss", | |
| { command: ["awk", 'BEGIN{system("id")}', "/dev/null"] }, | |
| ); | |
| expect(runCommand).not.toHaveBeenCalled(); | |
| expectExecDeniedEvent(sendNodeEvent); | |
| expectInvokeErrorMessage( | |
| sendInvokeResult, | |
| "awk inline program requires explicit approval in strictInlineEval mode", | |
| ); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("fails closed when allow-always approval persistence fails", async () => { | |
| await withTempApprovalsHome(createAllowlistOnMissApprovals(), async () => { | |
| const tempDir = createFixtureDir("openclaw-allow-always-write-failure-"); | |
| const executablePath = createTempExecutable(tempDir, "approved-tool"); | |
| const commitAuthorization = vi.fn(async () => { | |
| throw new Error("approval lock unavailable"); | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [executablePath], | |
| approvalDecision: "allow-always", | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ allowAlwaysDecision: expect.any(Object) }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(invoke.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("does not restore a revoked allowlist rule during explicit allow-always persistence", async () => { | |
| const tempDir = createFixtureDir("openclaw-allow-always-revoked-rule-"); | |
| const executablePath = createTempExecutable(tempDir, "approved-tool"); | |
| const matchedEntry = { pattern: fs.realpathSync(executablePath) }; | |
| const expectedPolicySnapshot = { | |
| security: "allowlist" as const, | |
| ask: "always" as const, | |
| askFallback: "deny" as const, | |
| autoAllowSkills: false, | |
| allowlistRules: [matchedEntry], | |
| }; | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "always", "deny", { main: { allowlist: [matchedEntry] } }), | |
| async () => { | |
| let capturedAuthorization: | |
| | Parameters<typeof commitExecAuthorizationLocked>[0]["authorization"] | |
| | undefined; | |
| const commitAuthorization = vi.fn( | |
| async (params: Parameters<typeof commitExecAuthorizationLocked>[0]) => { | |
| capturedAuthorization = params.authorization; | |
| const current = loadExecApprovals(); | |
| const main = current.agents?.main; | |
| saveExecApprovals({ | |
| ...current, | |
| agents: { | |
| ...current.agents, | |
| main: { ...main, allowlist: [] }, | |
| }, | |
| }); | |
| return await commitExecAuthorizationLocked(params); | |
| }, | |
| ); | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "always", { | |
| command: [executablePath], | |
| approvalDecision: "allow-always", | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledTimes(1); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| allowAlwaysDecision: expect.objectContaining({ kind: "patterns" }), | |
| }), | |
| ); | |
| expect(capturedAuthorization).toEqual({ | |
| source: "explicit-approval", | |
| security: "allowlist", | |
| ask: "always", | |
| allowlistSatisfied: true, | |
| policySnapshot: expectedPolicySnapshot, | |
| requireAutoAllowSkills: false, | |
| requireExactCommandApproval: false, | |
| requireDurableAllowlistApproval: false, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(invoke.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expect(loadExecApprovals().agents?.main?.allowlist ?? []).toStrictEqual([]); | |
| expectApprovalStateWriteDenied(invoke); | |
| }, | |
| ); | |
| }); | |
| it("fails closed when allowlist usage persistence fails", async () => { | |
| const tempDir = createFixtureDir("openclaw-allowlist-usage-write-failure-"); | |
| const executablePath = createTempExecutable(tempDir, "allowlisted-tool"); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [{ pattern: fs.realpathSync(executablePath) }], | |
| }, | |
| }, | |
| }), | |
| async () => { | |
| const commitAuthorization = vi.fn(async () => { | |
| throw new Error("approval lock unavailable"); | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "off", { | |
| command: [executablePath], | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(invoke.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| security: "allowlist", | |
| ask: "on-miss", | |
| }), | |
| }), | |
| ); | |
| expectApprovalStateWriteDenied(invoke); | |
| }, | |
| ); | |
| }); | |
| it("revalidates unprompted full policy before local execution", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "off", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, security: "deny" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "current-policy" }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("rejects unprompted full execution after ask policy tightens", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "off", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, ask: "on-miss" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("revalidates explicit approval against a current deny policy", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "always", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, security: "deny" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalDecision: "allow-once", | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "explicit-approval" }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("rejects explicit allow-once when persisted security tightens to allowlist", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "always", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, security: "allowlist" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalDecision: "allow-once", | |
| approved: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "explicit-approval", | |
| policySnapshot: expect.any(Object), | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("treats authenticated auto-review provenance as marker-only one-shot authority", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const autoReviewer = vi.fn<ExecAutoReviewer>(() => ({ | |
| decision: "ask", | |
| rationale: "must not be called for forwarded provenance", | |
| risk: "medium", | |
| })); | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| autoReviewer, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(autoReviewer).not.toHaveBeenCalled(); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "auto-review" }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| }); | |
| }); | |
| it("rejects forwarded auto-review when current ask policy tightens to always", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, ask: "always" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "auto-review" }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("rejects forwarded auto-review when persisted security tightens to allowlist", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, security: "allowlist" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "auto-review", | |
| policySnapshot: expect.any(Object), | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("rejects forwarded auto-review when persisted ask tightens from off to on-miss", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "off", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, ask: "on-miss" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "auto-review", | |
| policySnapshot: expect.any(Object), | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("rejects forwarded auto-review when current security policy tightens to deny", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, security: "deny" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("does not let forwarded auto-review authorize security audit suppression edits", async () => { | |
| const tmp = createFixtureDir("openclaw-forwarded-auto-review-suppression-"); | |
| const executablePath = createTempExecutable(tmp, "openclaw"); | |
| const prepared = buildCwdSessionApprovalPlan( | |
| [executablePath, "config", "set", "security.audit.suppressions", "[]"], | |
| tmp, | |
| "agent:main:main", | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: tmp, | |
| approvalSource: "auto-review", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectExecDeniedEvent(invoke.sendNodeEvent); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: explicit approval required", | |
| true, | |
| ); | |
| }); | |
| }); | |
| it("preserves exact-plan forwarded auto-review for strict inline eval", async () => { | |
| const plan = createStrictInlineEvalApprovalPlan("openclaw-forwarded-inline-"); | |
| setRuntimeConfigSnapshot({ tools: { exec: { strictInlineEval: true } } }); | |
| try { | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: plan, | |
| approvalSource: "auto-review", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ source: "auto-review" }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| }); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("does not commit allow-always state when local screen recording is unavailable", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "always", "deny"), async () => { | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalDecision: "allow-always", | |
| approved: true, | |
| needsScreenRecording: true, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).not.toHaveBeenCalled(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(loadExecApprovals().agents?.main?.allowlist ?? []).toStrictEqual([]); | |
| expect(invoke.sendNodeEvent).toHaveBeenCalledWith( | |
| expect.anything(), | |
| "exec.denied", | |
| expect.objectContaining({ reason: "permission:screenRecording" }), | |
| ); | |
| }); | |
| }); | |
| it("revalidates timeout fallback against the current askFallback policy", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "always", "full", {}), async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.defaults = { ...current.defaults, askFallback: "deny" }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? undefined, | |
| approvalSource: "ask-fallback", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(invoke.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }); | |
| }); | |
| it("requires a canonical plan for timeout fallback provenance", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "approvalSource requires matching systemRunPlan", | |
| true, | |
| ); | |
| }); | |
| it("requires a canonical plan for forwarded auto-review provenance", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| approvalSource: "auto-review", | |
| prepareDelayedApprovalPlan: false, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "approvalSource requires matching systemRunPlan", | |
| true, | |
| ); | |
| }); | |
| it("requires a canonical plan for explicit approval provenance", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalDecision: "allow-once", | |
| approved: true, | |
| prepareDelayedApprovalPlan: false, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "explicit approval requires matching systemRunPlan", | |
| true, | |
| ); | |
| }); | |
| it("requires a prepared policy snapshot for forwarded delayed approval", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| prepareDelayedApprovalPlan: false, | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "delayed approval requires a prepared policy snapshot", | |
| true, | |
| ); | |
| }); | |
| it("rejects explicit approval when policy tightens after prepare", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "always", "deny"), async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const policyBoundPlan = bindCurrentPolicyToPlan(prepared.plan); | |
| const current = loadExecApprovals(); | |
| current.defaults = { ...current.defaults, security: "allowlist" }; | |
| saveExecApprovals(current); | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| preparedPlan: policyBoundPlan, | |
| approvalDecision: "allow-once", | |
| approved: true, | |
| prepareDelayedApprovalPlan: false, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).not.toHaveBeenCalled(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "exec approval policy changed; request approval again", | |
| ); | |
| }); | |
| }); | |
| it("rejects forwarded auto-review when ask tightens after prepare", async () => { | |
| await withTempApprovalsHome(createApprovals("full", "off", "deny"), async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const policyBoundPlan = bindCurrentPolicyToPlan(prepared.plan); | |
| const current = loadExecApprovals(); | |
| current.defaults = { ...current.defaults, ask: "on-miss" }; | |
| saveExecApprovals(current); | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: policyBoundPlan, | |
| approvalSource: "auto-review", | |
| prepareDelayedApprovalPlan: false, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).not.toHaveBeenCalled(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "exec approval policy changed; request approval again", | |
| ); | |
| }); | |
| }); | |
| it("rejects explicit approval when an allowlist rule is revoked after prepare", async () => { | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "always", "deny", { | |
| main: { | |
| allowlist: [{ id: "rule-1", pattern: "/usr/bin/echo" }], | |
| }, | |
| }), | |
| async () => { | |
| const prepared = buildSystemRunApprovalPlan({ | |
| command: ["echo", "ok"], | |
| agentId: "main", | |
| sessionKey: "agent:main:main", | |
| }); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const policyBoundPlan = bindCurrentPolicyToPlan(prepared.plan); | |
| const current = loadExecApprovals(); | |
| current.agents = { ...current.agents, main: { allowlist: [] } }; | |
| saveExecApprovals(current); | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "always", { | |
| preparedPlan: policyBoundPlan, | |
| agentId: "main", | |
| approvalDecision: "allow-once", | |
| approved: true, | |
| prepareDelayedApprovalPlan: false, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).not.toHaveBeenCalled(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "exec approval policy changed; request approval again", | |
| ); | |
| }, | |
| ); | |
| }); | |
| it("rejects timeout fallback provenance mixed with explicit approval", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| approvalDecision: "allow-once", | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "approvalSource cannot be combined with explicit approval", | |
| true, | |
| ); | |
| }); | |
| it("rejects forwarded auto-review provenance mixed with explicit approval", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "on-miss", { | |
| approved: true, | |
| approvalDecision: "allow-once", | |
| approvalSource: "auto-review", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "approvalSource cannot be combined with explicit approval", | |
| true, | |
| ); | |
| }); | |
| it("applies marker-only full timeout fallback without another prompt", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "always", "full", {}), async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| preparedPlan: prepared.plan, | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).toHaveBeenCalledWith( | |
| prepared.plan.argv, | |
| prepared.plan.cwd, | |
| undefined, | |
| undefined, | |
| undefined, | |
| expect.any(Function), | |
| ); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| }); | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "permits a durable exact-command approval under allowlist timeout fallback", | |
| async () => { | |
| const tempDir = createFixtureDir("openclaw-fallback-durable-"); | |
| const prepared = buildCwdSessionApprovalPlan( | |
| ["/bin/sh", "-c", "/bin/ls"], | |
| tempDir, | |
| "agent:main:main", | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createApprovals("full", "always", "allowlist", { | |
| main: { allowlist: [{ pattern: commandPattern, source: "allow-always" }] }, | |
| }), | |
| async () => { | |
| const commitAuthorization = vi.fn(commitExecAuthorizationLocked); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| approvalSource: "ask-fallback", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "ask-fallback", | |
| requireExactCommandApproval: true, | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(invoke.sendInvokeResult); | |
| }, | |
| ); | |
| }, | |
| ); | |
| it.runIf(process.platform !== "win32")( | |
| "rejects allowlist timeout fallback when its durable source is removed before commit", | |
| async () => { | |
| const tempDir = createFixtureDir("openclaw-fallback-durable-revoked-"); | |
| const prepared = buildCwdSessionApprovalPlan( | |
| ["/bin/sh", "-c", "/bin/ls"], | |
| tempDir, | |
| "agent:main:main", | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createApprovals("full", "always", "allowlist", { | |
| main: { allowlist: [{ pattern: commandPattern, source: "allow-always" }] }, | |
| }), | |
| async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.agents = { | |
| ...current.agents, | |
| main: { allowlist: [{ pattern: commandPattern }] }, | |
| }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "always", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| approvalSource: "ask-fallback", | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "ask-fallback", | |
| requireExactCommandApproval: true, | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }, | |
| ); | |
| }, | |
| ); | |
| it("preserves source-only fallback across the authenticated Mac app bridge", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "always", "full", {}), async () => { | |
| const invoke = await runMacSystemInvokeWithPolicy("full", "always", { | |
| runViaResponse: createMacExecHostSuccess(), | |
| preparedPlan: prepared.plan, | |
| approvalSource: "ask-fallback", | |
| }); | |
| const call = requireMacExecHostCall(invoke.runViaMacAppExecHost); | |
| expect(call.request?.approvalSource).toBe("ask-fallback"); | |
| expect(call.request?.approvalDecision).toBeNull(); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeOk(invoke.sendInvokeResult, "app-ok"); | |
| }); | |
| }); | |
| it("preserves marker-only auto-review across the authenticated Mac app bridge", async () => { | |
| const prepared = buildSessionApprovalPlan(["echo", "ok"], "agent:main:main"); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "on-miss", "deny"), async () => { | |
| const invoke = await runMacSystemInvokeWithPolicy("full", "on-miss", { | |
| runViaResponse: createMacExecHostSuccess(), | |
| preparedPlan: prepared.plan, | |
| approvalSource: "auto-review", | |
| }); | |
| const call = requireMacExecHostCall(invoke.runViaMacAppExecHost); | |
| expect(call.request?.approvalSource).toBe("auto-review"); | |
| expect(call.request?.approvalDecision).toBeNull(); | |
| expect(call.request?.policySnapshot).toEqual( | |
| createExecApprovalPolicySnapshot({ file: loadExecApprovals(), agentId: undefined }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeOk(invoke.sendInvokeResult, "app-ok"); | |
| }); | |
| }); | |
| it("does not let timeout fallback satisfy strict inline review", async () => { | |
| const plan = createStrictInlineEvalApprovalPlan("openclaw-fallback-inline-"); | |
| setRuntimeConfigSnapshot({ tools: { exec: { strictInlineEval: true } } }); | |
| try { | |
| await withTempApprovalsHome(createApprovals("full", "always", "full", {}), async () => { | |
| const invoke = await runLocalSystemInvoke({ | |
| preparedPlan: plan, | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| invoke.sendInvokeResult, | |
| "requires explicit approval in strictInlineEval mode", | |
| ); | |
| }); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("does not let timeout fallback authorize security audit suppression edits", async () => { | |
| const tmp = createFixtureDir("openclaw-timeout-fallback-suppression-"); | |
| const executablePath = createTempExecutable(tmp, "openclaw"); | |
| const prepared = buildCwdSessionApprovalPlan( | |
| [executablePath, "config", "set", "security.audit.suppressions", "[]"], | |
| tmp, | |
| "agent:main:main", | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome(createApprovals("full", "always", "full", {}), async () => { | |
| const invoke = await runLocalSystemInvoke({ | |
| preparedPlan: prepared.plan, | |
| cwd: tmp, | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(invoke.sendNodeEvent, invoke.sendInvokeResult); | |
| }); | |
| }); | |
| it("keeps audit suppression edits approval-gated under allowlist fallback from full/off", async () => { | |
| const tmp = createFixtureDir("openclaw-timeout-fallback-full-off-suppression-"); | |
| const executablePath = createTempExecutable(tmp, "openclaw"); | |
| const prepared = buildCwdSessionApprovalPlan( | |
| [executablePath, "config", "set", "security.audit.suppressions", "[]"], | |
| tmp, | |
| "agent:main:main", | |
| ); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome( | |
| createApprovals("full", "off", "allowlist", { | |
| main: { allowlist: [{ pattern: fs.realpathSync(executablePath) }] }, | |
| }), | |
| async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| preparedPlan: prepared.plan, | |
| cwd: tmp, | |
| approvalSource: "ask-fallback", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(invoke.sendNodeEvent, invoke.sendInvokeResult); | |
| }, | |
| ); | |
| }); | |
| it("rejects unknown approval provenance", async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("full", "off", { | |
| approved: true, | |
| approvalDecision: "allow-once", | |
| approvalSource: "explicit", | |
| }); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage(invoke.sendInvokeResult, "approvalSource invalid", true); | |
| }); | |
| it("rejects unbindable strict inline-eval carriers before delayed approval", async () => { | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| strictInlineEval: true, | |
| }, | |
| }, | |
| }); | |
| try { | |
| await withTempApprovalsHome(createAllowlistOnMissApprovals(), async () => { | |
| const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); | |
| const executablePath = createTempExecutable(tempDir, "python3.13"); | |
| const prepared = buildSystemRunApprovalPlan({ | |
| command: [executablePath, "-c", "print('hi')"], | |
| }); | |
| expect(prepared).toEqual({ | |
| ok: false, | |
| message: | |
| "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", | |
| }); | |
| expect(loadExecApprovals().agents?.main?.allowlist ?? []).toStrictEqual([]); | |
| }); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("persists benign awk allow-always approvals in strict inline-eval mode without reopening inline carriers", async () => { | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| strictInlineEval: true, | |
| }, | |
| }, | |
| }); | |
| try { | |
| await withTempApprovalsHome(createAllowlistOnMissApprovals(), async () => { | |
| const tempDir = createFixtureDir("openclaw-inline-eval-awk-"); | |
| const executablePath = createTempExecutable(tempDir, "gawk"); | |
| fs.writeFileSync(path.join(tempDir, "script.awk"), "{ print }\n"); | |
| const benign = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [executablePath, "-F", ",", "-f", "script.awk"], | |
| cwd: tempDir, | |
| approvalDecision: "allow-always", | |
| approved: true, | |
| runCommand: vi.fn(async () => createLocalRunResult("awk-ok")), | |
| }); | |
| expect(benign.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(benign.sendInvokeResult, "awk-ok"); | |
| const allowlist = loadExecApprovals().agents?.main?.allowlist ?? []; | |
| expect(allowlist).toHaveLength(2); | |
| expect(allowlist[0]?.pattern).toBe(fs.realpathSync(executablePath)); | |
| expect(allowlist[0]?.lastUsedCommand).toBeUndefined(); | |
| expect(allowlist[1]?.pattern).toMatch(/^=node-command:[0-9a-f]{16}$/); | |
| expect(allowlist[1]?.lastUsedCommand).toBeUndefined(); | |
| const malicious = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [executablePath, 'BEGIN{system("id")}', "/dev/null"], | |
| cwd: tempDir, | |
| }); | |
| expect(malicious.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| malicious.sendInvokeResult, | |
| "awk inline program requires explicit approval in strictInlineEval mode", | |
| ); | |
| const abbreviated = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: [executablePath, '--s=BEGIN{system("id")}', "/dev/null"], | |
| cwd: tempDir, | |
| }); | |
| expect(abbreviated.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| abbreviated.sendInvokeResult, | |
| "gawk --source requires explicit approval in strictInlineEval mode", | |
| ); | |
| }); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it("does not persist allow-always approvals for strict inline-eval make carriers", async () => { | |
| setRuntimeConfigSnapshot({ | |
| tools: { | |
| exec: { | |
| strictInlineEval: true, | |
| }, | |
| }, | |
| }); | |
| try { | |
| await withTempApprovalsHome(createAllowlistOnMissApprovals(), async () => { | |
| const tempDir = createFixtureDir("openclaw-inline-eval-make-"); | |
| const executablePath = createTempExecutable(tempDir, "make"); | |
| const makefilePath = path.join(tempDir, "Makefile"); | |
| fs.writeFileSync(makefilePath, "all:\n\t@echo inline-eval-ok\n"); | |
| const prepared = buildCwdApprovalPlan([executablePath, "-f", makefilePath], tempDir); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const { runCommand, sendInvokeResult } = await runLocalSystemInvokeWithPolicy( | |
| "allowlist", | |
| "on-miss", | |
| { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| approvalDecision: "allow-always", | |
| approved: true, | |
| runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")), | |
| }, | |
| ); | |
| expect(runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(sendInvokeResult, "inline-eval-ok"); | |
| expect(loadExecApprovals().agents?.main?.allowlist ?? []).toStrictEqual([]); | |
| }); | |
| } finally { | |
| clearRuntimeConfigSnapshot(); | |
| } | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "auto-runs allowlisted inner scripts through transport shell wrappers", | |
| async () => { | |
| const tempDir = createFixtureDir("openclaw-shell-wrapper-inner-"); | |
| const scriptsDir = path.join(tempDir, "scripts"); | |
| fs.mkdirSync(scriptsDir, { recursive: true }); | |
| const scriptPath = path.join(scriptsDir, "check_mail.sh"); | |
| fs.writeFileSync(scriptPath, "#!/bin/sh\necho ok\n"); | |
| fs.chmodSync(scriptPath, 0o755); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [{ pattern: fs.realpathSync(scriptPath) }], | |
| }, | |
| }, | |
| }), | |
| async () => { | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command: ["/bin/sh", "-lc", "./scripts/check_mail.sh --limit 5"], | |
| rawCommand: '/bin/sh -lc "./scripts/check_mail.sh --limit 5"', | |
| cwd: tempDir, | |
| runCommand: vi.fn(async () => createLocalRunResult("shell-wrapper-inner-ok")), | |
| }); | |
| expect(invoke.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(invoke.sendInvokeResult, "shell-wrapper-inner-ok"); | |
| }, | |
| ); | |
| }, | |
| ); | |
| it("keeps cmd.exe transport wrappers approval-gated on Windows", async () => { | |
| const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); | |
| try { | |
| for (const testCase of [ | |
| { | |
| name: "env-assignment cmd.exe", | |
| commandPrefix: ["env", "FOO=bar", "cmd.exe", "/d", "/s", "/c"], | |
| }, | |
| ]) { | |
| const tempDir = createFixtureDir("openclaw-cmd-wrapper-allow-"); | |
| const scriptPath = path.join(tempDir, "check_mail.cmd"); | |
| fs.writeFileSync(scriptPath, "@echo off\r\necho ok\r\n"); | |
| const command = [...testCase.commandPrefix, `${scriptPath} --limit 5`]; | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [{ pattern: scriptPath }], | |
| }, | |
| }, | |
| }), | |
| async () => { | |
| const seenArgv: string[][] = []; | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| command, | |
| cwd: tempDir, | |
| isCmdExeInvocation: (argv) => { | |
| seenArgv.push([...argv]); | |
| const token = argv[0]?.trim(); | |
| if (!token) { | |
| return false; | |
| } | |
| const base = path.win32.basename(token).toLowerCase(); | |
| return base === "cmd.exe" || base === "cmd"; | |
| }, | |
| }); | |
| expect(seenArgv, testCase.name).toEqual([ | |
| ["cmd.exe", "/d", "/s", "/c", `${scriptPath} --limit 5`], | |
| ]); | |
| expect(invoke.runCommand, testCase.name).not.toHaveBeenCalled(); | |
| expectApprovalRequiredDenied(invoke.sendNodeEvent, invoke.sendInvokeResult); | |
| }, | |
| ); | |
| } | |
| } finally { | |
| platformSpy.mockRestore(); | |
| } | |
| }); | |
| it("fails closed when cmd.exe wrapper trust is downgraded before execution", async () => { | |
| const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); | |
| try { | |
| const tempDir = createFixtureDir("openclaw-cmd-wrapper-downgraded-"); | |
| const commandName = "check_mail.cmd"; | |
| const command = ["env", "FOO=bar", "cmd.exe", "/d", "/s", "/c", `${commandName} --limit 5`]; | |
| const ordinaryPattern = "*"; | |
| const prepared = buildSystemRunApprovalPlan({ command, cwd: tempDir }); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createAllowlistOnMissApprovals({ | |
| agents: { | |
| main: { | |
| allowlist: [ | |
| { pattern: ordinaryPattern }, | |
| { pattern: commandPattern, source: "allow-always" }, | |
| ], | |
| }, | |
| }, | |
| }), | |
| async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.agents = { | |
| ...current.agents, | |
| main: { | |
| allowlist: [{ pattern: ordinaryPattern }, { pattern: commandPattern }], | |
| }, | |
| }; | |
| }); | |
| const invoke = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| isCmdExeInvocation: (argv) => { | |
| const token = argv[0]?.trim(); | |
| if (!token) { | |
| return false; | |
| } | |
| const base = path.win32.basename(token).toLowerCase(); | |
| return base === "cmd.exe" || base === "cmd"; | |
| }, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "current-policy", | |
| requireExactCommandApproval: true, | |
| }), | |
| }), | |
| ); | |
| expect(invoke.runCommand).not.toHaveBeenCalled(); | |
| expect(invoke.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(invoke); | |
| }, | |
| ); | |
| } finally { | |
| platformSpy.mockRestore(); | |
| } | |
| }); | |
| it("reuses exact-command durable trust for shell-wrapper reruns", async () => { | |
| if (process.platform === "win32") { | |
| return; | |
| } | |
| const tempDir = createFixtureDir("openclaw-shell-wrapper-allow-"); | |
| const prepared = buildCwdApprovalPlan(["/bin/sh", "-c", "/bin/ls"], tempDir); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "on-miss", "full", { | |
| main: { | |
| allowlist: [ | |
| { | |
| pattern: `=command:${crypto | |
| .createHash("sha256") | |
| .update(prepared.plan.commandText) | |
| .digest("hex") | |
| .slice(0, 16)}`, | |
| source: "allow-always", | |
| }, | |
| ], | |
| }, | |
| }), | |
| async () => { | |
| const rerun = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| runCommand: vi.fn(async () => createLocalRunResult("shell-wrapper-reused")), | |
| }); | |
| expect(rerun.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(rerun.sendInvokeResult, "shell-wrapper-reused"); | |
| }, | |
| ); | |
| }); | |
| it.runIf(process.platform !== "win32")( | |
| "rejects durable trust when its approved directory is replaced before execution", | |
| async () => { | |
| const tempDir = createFixtureDir("openclaw-durable-cwd-drift-"); | |
| const movedDir = `${tempDir}-moved`; | |
| const prepared = buildCwdApprovalPlan(["/bin/sh", "-c", "/bin/ls"], tempDir); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "on-miss", "full", { | |
| main: { | |
| allowlist: [{ pattern: commandPattern, source: "allow-always" }], | |
| }, | |
| }), | |
| async () => { | |
| const commitAuthorization: HandleSystemRunInvokeOptions["commitExecAuthorization"] = | |
| async (params) => { | |
| const assertCurrent = await commitExecAuthorizationLocked(params); | |
| fs.renameSync(tempDir, movedDir); | |
| fs.mkdirSync(tempDir); | |
| return assertCurrent; | |
| }; | |
| const rerun = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(rerun.runCommand).not.toHaveBeenCalled(); | |
| expectInvokeErrorMessage( | |
| rerun.sendInvokeResult, | |
| "SYSTEM_RUN_DENIED: approval cwd changed before execution", | |
| true, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ); | |
| it("does not bind safe builtin policy to a redundant exact-command grant", async () => { | |
| if (process.platform === "win32") { | |
| return; | |
| } | |
| const tempDir = createFixtureDir("openclaw-shell-wrapper-redundant-grant-"); | |
| const prepared = buildCwdApprovalPlan(["/bin/sh", "-c", "cd ."], tempDir); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "on-miss", "full", { | |
| main: { | |
| allowlist: [{ pattern: commandPattern, source: "allow-always" }], | |
| }, | |
| }), | |
| async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.agents = { ...current.agents, main: { allowlist: [] } }; | |
| }); | |
| const rerun = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| commitExecAuthorization: commitAuthorization, | |
| runCommand: vi.fn(async () => createLocalRunResult("safe-builtin-ok")), | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "current-policy", | |
| requireExactCommandApproval: false, | |
| requireDurableAllowlistApproval: false, | |
| }), | |
| }), | |
| ); | |
| expect(rerun.runCommand).toHaveBeenCalledTimes(1); | |
| expectInvokeOk(rerun.sendInvokeResult, "safe-builtin-ok"); | |
| }, | |
| ); | |
| }); | |
| it("fails closed when an exact-command grant is revoked before execution", async () => { | |
| if (process.platform === "win32") { | |
| return; | |
| } | |
| const tempDir = createFixtureDir("openclaw-shell-wrapper-revoked-"); | |
| const prepared = buildCwdApprovalPlan(["/bin/sh", "-c", "/bin/ls"], tempDir); | |
| expect(prepared.ok).toBe(true); | |
| requireApprovalPlan(prepared, "unreachable"); | |
| const commandPattern = createExactCommandPattern(prepared.plan.commandText); | |
| await withTempApprovalsHome( | |
| createApprovals("allowlist", "on-miss", "full", { | |
| main: { | |
| allowlist: [{ pattern: commandPattern, source: "allow-always" }], | |
| }, | |
| }), | |
| async () => { | |
| const commitAuthorization = createPolicyMutationCommit((current) => { | |
| current.agents = { ...current.agents, main: { allowlist: [] } }; | |
| }); | |
| const rerun = await runLocalSystemInvokeWithPolicy("allowlist", "on-miss", { | |
| preparedPlan: prepared.plan, | |
| cwd: prepared.plan.cwd ?? tempDir, | |
| commitExecAuthorization: commitAuthorization, | |
| }); | |
| expect(commitAuthorization).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| authorization: expect.objectContaining({ | |
| source: "current-policy", | |
| requireExactCommandApproval: true, | |
| }), | |
| }), | |
| ); | |
| expect(rerun.runCommand).not.toHaveBeenCalled(); | |
| expect(rerun.sendExecFinishedEvent).not.toHaveBeenCalled(); | |
| expectApprovalStateWriteDenied(rerun); | |
| }, | |
| ); | |
| }); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |