| |
| 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(); |
| |
| 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]; |
| |
| |
| 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 { |
| |
| } |
| }); |
|
|
| 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 () => { |
| |
| |
| 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); |
| }, |
| ); |
| }); |
| }); |
| |
|
|