import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { analyzeArgvCommand, analyzeShellCommand, buildSafeBinsShellCommand, evaluateExecAllowlist, evaluateShellAllowlist, isSafeBinUsage, matchAllowlist, maxAsk, mergeExecApprovalsSocketDefaults, minSecurity, normalizeExecApprovals, parseExecArgvToken, normalizeSafeBins, requiresExecApproval, resolveCommandResolution, resolveCommandResolutionFromArgv, resolveAllowAlwaysPatterns, resolveExecApprovals, resolveExecApprovalsFromFile, resolveExecApprovalsPath, resolveExecApprovalsSocketPath, resolveSafeBins, type ExecApprovalsAgent, type ExecAllowlistEntry, type ExecApprovalsFile, } from "./exec-approvals.js"; import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES } from "./exec-safe-bin-policy.js"; function makePathEnv(binDir: string): NodeJS.ProcessEnv { if (process.platform !== "win32") { return { PATH: binDir }; } return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" }; } function makeTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-")); } type ShellParserParityFixtureCase = { id: string; command: string; ok: boolean; executables: string[]; }; type ShellParserParityFixture = { cases: ShellParserParityFixtureCase[]; }; type WrapperResolutionParityFixtureCase = { id: string; argv: string[]; expectedRawExecutable: string | null; }; type WrapperResolutionParityFixture = { cases: WrapperResolutionParityFixtureCase[]; }; function loadShellParserParityFixtureCases(): ShellParserParityFixtureCase[] { const fixturePath = path.join( process.cwd(), "test", "fixtures", "exec-allowlist-shell-parser-parity.json", ); const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ShellParserParityFixture; return fixture.cases; } function loadWrapperResolutionParityFixtureCases(): WrapperResolutionParityFixtureCase[] { const fixturePath = path.join( process.cwd(), "test", "fixtures", "exec-wrapper-resolution-parity.json", ); const fixture = JSON.parse( fs.readFileSync(fixturePath, "utf8"), ) as WrapperResolutionParityFixture; return fixture.cases; } describe("exec approvals allowlist matching", () => { const baseResolution = { rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", executableName: "rg", }; it("handles wildcard/path matching semantics", () => { const cases: Array<{ entries: ExecAllowlistEntry[]; expectedPattern: string | null }> = [ { entries: [{ pattern: "RG" }], expectedPattern: null }, { entries: [{ pattern: "/opt/**/rg" }], expectedPattern: "/opt/**/rg" }, { entries: [{ pattern: "/opt/*/rg" }], expectedPattern: null }, ]; for (const testCase of cases) { const match = matchAllowlist(testCase.entries, baseResolution); expect(match?.pattern ?? null).toBe(testCase.expectedPattern); } }); it("requires a resolved path", () => { const match = matchAllowlist([{ pattern: "bin/rg" }], { rawExecutable: "bin/rg", resolvedPath: undefined, executableName: "rg", }); expect(match).toBeNull(); }); }); describe("mergeExecApprovalsSocketDefaults", () => { it("prefers normalized socket, then current, then default path", () => { const normalized = normalizeExecApprovals({ version: 1, agents: {}, socket: { path: "/tmp/a.sock", token: "a" }, }); const current = normalizeExecApprovals({ version: 1, agents: {}, socket: { path: "/tmp/b.sock", token: "b" }, }); const merged = mergeExecApprovalsSocketDefaults({ normalized, current }); expect(merged.socket?.path).toBe("/tmp/a.sock"); expect(merged.socket?.token).toBe("a"); }); it("falls back to current token when missing in normalized", () => { const normalized = normalizeExecApprovals({ version: 1, agents: {} }); const current = normalizeExecApprovals({ version: 1, agents: {}, socket: { path: "/tmp/b.sock", token: "b" }, }); const merged = mergeExecApprovalsSocketDefaults({ normalized, current }); expect(merged.socket?.path).toBeTruthy(); expect(merged.socket?.token).toBe("b"); }); }); describe("resolve exec approvals defaults", () => { it("expands home-prefixed default file and socket paths", () => { const dir = makeTempDir(); const prevOpenClawHome = process.env.OPENCLAW_HOME; try { process.env.OPENCLAW_HOME = dir; expect(path.normalize(resolveExecApprovalsPath())).toBe( path.normalize(path.join(dir, ".openclaw", "exec-approvals.json")), ); expect(path.normalize(resolveExecApprovalsSocketPath())).toBe( path.normalize(path.join(dir, ".openclaw", "exec-approvals.sock")), ); } finally { if (prevOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { process.env.OPENCLAW_HOME = prevOpenClawHome; } } }); }); describe("exec approvals safe shell command builder", () => { it("quotes only safeBins segments (leaves other segments untouched)", () => { if (process.platform === "win32") { return; } const analysis = analyzeShellCommand({ command: "rg foo src/*.ts | head -n 5 && echo ok", cwd: "/tmp", env: { PATH: "/usr/bin:/bin" }, platform: process.platform, }); expect(analysis.ok).toBe(true); const res = buildSafeBinsShellCommand({ command: "rg foo src/*.ts | head -n 5 && echo ok", segments: analysis.segments, segmentSatisfiedBy: [null, "safeBins", null], platform: process.platform, }); expect(res.ok).toBe(true); // Preserve non-safeBins segment raw (glob stays unquoted) expect(res.command).toContain("rg foo src/*.ts"); // SafeBins segment is fully quoted expect(res.command).toContain("'head' '-n' '5'"); }); }); describe("exec approvals command resolution", () => { it("resolves PATH, relative, and quoted executables", () => { const cases = [ { name: "PATH executable", setup: () => { const dir = makeTempDir(); const binDir = path.join(dir, "bin"); fs.mkdirSync(binDir, { recursive: true }); const exeName = process.platform === "win32" ? "rg.exe" : "rg"; const exe = path.join(binDir, exeName); fs.writeFileSync(exe, ""); fs.chmodSync(exe, 0o755); return { command: "rg -n foo", cwd: undefined as string | undefined, envPath: makePathEnv(binDir), expectedPath: exe, expectedExecutableName: exeName, }; }, }, { name: "relative executable", setup: () => { const dir = makeTempDir(); const cwd = path.join(dir, "project"); const script = path.join(cwd, "scripts", "run.sh"); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { command: "./scripts/run.sh --flag", cwd, envPath: undefined as NodeJS.ProcessEnv | undefined, expectedPath: script, expectedExecutableName: undefined, }; }, }, { name: "quoted executable", setup: () => { const dir = makeTempDir(); const cwd = path.join(dir, "project"); const script = path.join(cwd, "bin", "tool"); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.writeFileSync(script, ""); fs.chmodSync(script, 0o755); return { command: '"./bin/tool" --version', cwd, envPath: undefined as NodeJS.ProcessEnv | undefined, expectedPath: script, expectedExecutableName: undefined, }; }, }, ] as const; for (const testCase of cases) { const setup = testCase.setup(); const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath); expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath); if (setup.expectedExecutableName) { expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName); } } }); it("unwraps env wrapper argv to resolve the effective executable", () => { const dir = makeTempDir(); const binDir = path.join(dir, "bin"); fs.mkdirSync(binDir, { recursive: true }); const exeName = process.platform === "win32" ? "rg.exe" : "rg"; const exe = path.join(binDir, exeName); fs.writeFileSync(exe, ""); fs.chmodSync(exe, 0o755); const resolution = resolveCommandResolutionFromArgv( ["/usr/bin/env", "FOO=bar", "rg", "-n", "needle"], undefined, makePathEnv(binDir), ); expect(resolution?.resolvedPath).toBe(exe); expect(resolution?.executableName).toBe(exeName); }); it("unwraps env wrapper with shell inner executable", () => { const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]); expect(resolution?.rawExecutable).toBe("bash"); expect(resolution?.executableName.toLowerCase()).toContain("bash"); }); }); describe("exec approvals shell parsing", () => { it("parses pipelines and chained commands", () => { const cases = [ { name: "pipeline", command: "echo ok | jq .foo", expectedSegments: ["echo", "jq"], }, { name: "chain", command: "ls && rm -rf /", expectedChainHeads: ["ls", "rm"], }, ] as const; for (const testCase of cases) { const res = analyzeShellCommand({ command: testCase.command }); expect(res.ok, testCase.name).toBe(true); if ("expectedSegments" in testCase) { expect( res.segments.map((seg) => seg.argv[0]), testCase.name, ).toEqual(testCase.expectedSegments); } else { expect( res.chains?.map((chain) => chain[0]?.argv[0]), testCase.name, ).toEqual(testCase.expectedChainHeads); } } }); it("parses argv commands", () => { const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] }); expect(res.ok).toBe(true); expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]); }); it("rejects unsupported shell constructs", () => { const cases: Array<{ command: string; reason: string; platform?: NodeJS.Platform }> = [ { command: 'echo "output: $(whoami)"', reason: "unsupported shell token: $()" }, { command: 'echo "output: `id`"', reason: "unsupported shell token: `" }, { command: "echo $(whoami)", reason: "unsupported shell token: $()" }, { command: "cat < input.txt", reason: "unsupported shell token: <" }, { command: "echo ok > output.txt", reason: "unsupported shell token: >" }, { command: "/usr/bin/echo first line\n/usr/bin/echo second line", reason: "unsupported shell token: \n", }, { command: "ping 127.0.0.1 -n 1 & whoami", reason: "unsupported windows shell token: &", platform: "win32", }, ]; for (const testCase of cases) { const res = analyzeShellCommand({ command: testCase.command, platform: testCase.platform }); expect(res.ok).toBe(false); expect(res.reason).toBe(testCase.reason); } }); it("accepts inert substitution-like syntax", () => { const cases = ['echo "output: \\$(whoami)"', "echo 'output: $(whoami)'"]; for (const command of cases) { const res = analyzeShellCommand({ command }); expect(res.ok).toBe(true); expect(res.segments[0]?.argv[0]).toBe("echo"); } }); it("accepts safe heredoc forms", () => { const cases: Array<{ command: string; expectedArgv: string[] }> = [ { command: "/usr/bin/tee /tmp/file << 'EOF'\nEOF", expectedArgv: ["/usr/bin/tee"] }, { command: "/usr/bin/tee /tmp/file < segment.argv[0])).toEqual(testCase.expectedArgv); } }); it("rejects unsafe or malformed heredoc forms", () => { const cases: Array<{ command: string; reason: string }> = [ { command: "/usr/bin/cat < { const res = analyzeShellCommand({ command: '"C:\\Program Files\\Tool\\tool.exe" --version', platform: "win32", }); expect(res.ok).toBe(true); expect(res.segments[0]?.argv).toEqual(["C:\\Program Files\\Tool\\tool.exe", "--version"]); }); it("normalizes short option clusters with attached payloads", () => { const parsed = parseExecArgvToken("-oblocked.txt"); expect(parsed.kind).toBe("option"); if (parsed.kind !== "option" || parsed.style !== "short-cluster") { throw new Error("expected short-cluster option"); } expect(parsed.flags[0]).toBe("-o"); expect(parsed.cluster).toBe("oblocked.txt"); }); it("normalizes long options with inline payloads", () => { const parsed = parseExecArgvToken("--output=blocked.txt"); expect(parsed.kind).toBe("option"); if (parsed.kind !== "option" || parsed.style !== "long") { throw new Error("expected long option"); } expect(parsed.flag).toBe("--output"); expect(parsed.inlineValue).toBe("blocked.txt"); }); }); describe("exec approvals shell parser parity fixture", () => { const fixtures = loadShellParserParityFixtureCases(); for (const fixture of fixtures) { it(`matches fixture: ${fixture.id}`, () => { const res = analyzeShellCommand({ command: fixture.command }); expect(res.ok).toBe(fixture.ok); if (fixture.ok) { const executables = res.segments.map((segment) => path.basename(segment.argv[0] ?? "").toLowerCase(), ); expect(executables).toEqual(fixture.executables.map((entry) => entry.toLowerCase())); } else { expect(res.segments).toHaveLength(0); } }); } }); describe("exec approvals wrapper resolution parity fixture", () => { const fixtures = loadWrapperResolutionParityFixtureCases(); for (const fixture of fixtures) { it(`matches wrapper fixture: ${fixture.id}`, () => { const resolution = resolveCommandResolutionFromArgv(fixture.argv); expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable); }); } }); describe("exec approvals shell allowlist (chained commands)", () => { it("evaluates chained command allowlist scenarios", () => { const cases: Array<{ allowlist: ExecAllowlistEntry[]; command: string; expectedAnalysisOk: boolean; expectedAllowlistSatisfied: boolean; platform?: NodeJS.Platform; }> = [ { allowlist: [{ pattern: "/usr/bin/obsidian-cli" }, { pattern: "/usr/bin/head" }], command: "/usr/bin/obsidian-cli print-default && /usr/bin/obsidian-cli search foo | /usr/bin/head", expectedAnalysisOk: true, expectedAllowlistSatisfied: true, }, { allowlist: [{ pattern: "/usr/bin/obsidian-cli" }], command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", expectedAnalysisOk: true, expectedAllowlistSatisfied: false, }, { allowlist: [{ pattern: "/usr/bin/echo" }], command: "/usr/bin/echo ok &&", expectedAnalysisOk: false, expectedAllowlistSatisfied: false, }, { allowlist: [{ pattern: "/usr/bin/ping" }], command: "ping 127.0.0.1 -n 1 & whoami", expectedAnalysisOk: false, expectedAllowlistSatisfied: false, platform: "win32", }, ]; for (const testCase of cases) { const result = evaluateShellAllowlist({ command: testCase.command, allowlist: testCase.allowlist, safeBins: new Set(), cwd: "/tmp", platform: testCase.platform, }); expect(result.analysisOk).toBe(testCase.expectedAnalysisOk); expect(result.allowlistSatisfied).toBe(testCase.expectedAllowlistSatisfied); } }); it("respects quoted chain separators", () => { const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; const commands = ['/usr/bin/echo "foo && bar"', '/usr/bin/echo "foo\\" && bar"']; for (const command of commands) { const result = evaluateShellAllowlist({ command, allowlist, safeBins: new Set(), cwd: "/tmp", }); expect(result.analysisOk).toBe(true); expect(result.allowlistSatisfied).toBe(true); } }); }); describe("exec approvals safe bins", () => { type SafeBinCase = { name: string; argv: string[]; resolvedPath: string; expected: boolean; safeBins?: string[]; executableName?: string; rawExecutable?: string; cwd?: string; setup?: (cwd: string) => void; }; function buildDeniedFlagVariantCases(params: { executableName: string; resolvedPath: string; safeBins?: string[]; flag: string; takesValue: boolean; label: string; }): SafeBinCase[] { const value = "blocked"; const argvVariants: string[][] = []; if (!params.takesValue) { argvVariants.push([params.executableName, params.flag]); } else if (params.flag.startsWith("--")) { argvVariants.push([params.executableName, `${params.flag}=${value}`]); argvVariants.push([params.executableName, params.flag, value]); } else if (params.flag.startsWith("-")) { argvVariants.push([params.executableName, `${params.flag}${value}`]); argvVariants.push([params.executableName, params.flag, value]); } else { argvVariants.push([params.executableName, params.flag, value]); } return argvVariants.map((argv) => ({ name: `${params.label} (${argv.slice(1).join(" ")})`, argv, resolvedPath: params.resolvedPath, expected: false, safeBins: params.safeBins ?? [params.executableName], executableName: params.executableName, })); } const deniedFlagCases: SafeBinCase[] = [ ...buildDeniedFlagVariantCases({ executableName: "sort", resolvedPath: "/usr/bin/sort", flag: "-o", takesValue: true, label: "blocks sort output flag", }), ...buildDeniedFlagVariantCases({ executableName: "sort", resolvedPath: "/usr/bin/sort", flag: "--output", takesValue: true, label: "blocks sort output flag", }), ...buildDeniedFlagVariantCases({ executableName: "sort", resolvedPath: "/usr/bin/sort", flag: "--compress-program", takesValue: true, label: "blocks sort external program flag", }), ...buildDeniedFlagVariantCases({ executableName: "grep", resolvedPath: "/usr/bin/grep", flag: "-R", takesValue: false, label: "blocks grep recursive flag", }), ...buildDeniedFlagVariantCases({ executableName: "grep", resolvedPath: "/usr/bin/grep", flag: "--recursive", takesValue: false, label: "blocks grep recursive flag", }), ...buildDeniedFlagVariantCases({ executableName: "grep", resolvedPath: "/usr/bin/grep", flag: "--file", takesValue: true, label: "blocks grep file-pattern flag", }), ...buildDeniedFlagVariantCases({ executableName: "jq", resolvedPath: "/usr/bin/jq", flag: "-f", takesValue: true, label: "blocks jq file-program flag", }), ...buildDeniedFlagVariantCases({ executableName: "jq", resolvedPath: "/usr/bin/jq", flag: "--from-file", takesValue: true, label: "blocks jq file-program flag", }), ...buildDeniedFlagVariantCases({ executableName: "wc", resolvedPath: "/usr/bin/wc", flag: "--files0-from", takesValue: true, label: "blocks wc file-list flag", }), ]; const cases: SafeBinCase[] = [ { name: "allows safe bins with non-path args", argv: ["jq", ".foo"], resolvedPath: "/usr/bin/jq", expected: true, }, { name: "blocks safe bins with file args", argv: ["jq", ".foo", "secret.json"], resolvedPath: "/usr/bin/jq", expected: false, setup: (cwd) => fs.writeFileSync(path.join(cwd, "secret.json"), "{}"), }, { name: "blocks safe bins resolved from untrusted directories", argv: ["jq", ".foo"], resolvedPath: "/tmp/evil-bin/jq", expected: false, cwd: "/tmp", }, ...deniedFlagCases, { name: "blocks grep file positional when pattern uses -e", argv: ["grep", "-e", "needle", ".env"], resolvedPath: "/usr/bin/grep", expected: false, safeBins: ["grep"], executableName: "grep", }, { name: "blocks grep file positional after -- terminator", argv: ["grep", "-e", "needle", "--", ".env"], resolvedPath: "/usr/bin/grep", expected: false, safeBins: ["grep"], executableName: "grep", }, ]; for (const testCase of cases) { it(testCase.name, () => { if (process.platform === "win32") { return; } const cwd = testCase.cwd ?? makeTempDir(); testCase.setup?.(cwd); const executableName = testCase.executableName ?? "jq"; const rawExecutable = testCase.rawExecutable ?? executableName; const ok = isSafeBinUsage({ argv: testCase.argv, resolution: { rawExecutable, resolvedPath: testCase.resolvedPath, executableName, }, safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]), }); expect(ok).toBe(testCase.expected); }); } it("supports injected trusted safe-bin dirs for tests/callers", () => { if (process.platform === "win32") { return; } const ok = isSafeBinUsage({ argv: ["jq", ".foo"], resolution: { rawExecutable: "jq", resolvedPath: "/custom/bin/jq", executableName: "jq", }, safeBins: normalizeSafeBins(["jq"]), trustedSafeBinDirs: new Set(["/custom/bin"]), }); expect(ok).toBe(true); }); it("supports injected platform for deterministic safe-bin checks", () => { const ok = isSafeBinUsage({ argv: ["jq", ".foo"], resolution: { rawExecutable: "jq", resolvedPath: "/usr/bin/jq", executableName: "jq", }, safeBins: normalizeSafeBins(["jq"]), platform: "win32", }); expect(ok).toBe(false); }); it("supports injected trusted path checker for deterministic callers", () => { if (process.platform === "win32") { return; } const baseParams = { argv: ["jq", ".foo"], resolution: { rawExecutable: "jq", resolvedPath: "/tmp/custom/jq", executableName: "jq", }, safeBins: normalizeSafeBins(["jq"]), }; expect( isSafeBinUsage({ ...baseParams, isTrustedSafeBinPathFn: () => true, }), ).toBe(true); expect( isSafeBinUsage({ ...baseParams, isTrustedSafeBinPathFn: () => false, }), ).toBe(false); }); it("keeps safe-bin profile fixtures aligned with compiled profiles", () => { for (const [name, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { const profile = SAFE_BIN_PROFILES[name]; expect(profile).toBeDefined(); const fixtureDeniedFlags = fixture.deniedFlags ?? []; const compiledDeniedFlags = profile?.deniedFlags ?? new Set(); for (const deniedFlag of fixtureDeniedFlags) { expect(compiledDeniedFlags.has(deniedFlag)).toBe(true); } expect(Array.from(compiledDeniedFlags).toSorted()).toEqual( [...fixtureDeniedFlags].toSorted(), ); } }); it("does not include sort/grep in default safeBins", () => { const defaults = resolveSafeBins(undefined); expect(defaults.has("jq")).toBe(true); expect(defaults.has("sort")).toBe(false); expect(defaults.has("grep")).toBe(false); }); it("blocks sort output flags independent of file existence", () => { if (process.platform === "win32") { return; } const cwd = makeTempDir(); fs.writeFileSync(path.join(cwd, "existing.txt"), "x"); const resolution = { rawExecutable: "sort", resolvedPath: "/usr/bin/sort", executableName: "sort", }; const safeBins = normalizeSafeBins(["sort"]); const existing = isSafeBinUsage({ argv: ["sort", "-o", "existing.txt"], resolution, safeBins, }); const missing = isSafeBinUsage({ argv: ["sort", "-o", "missing.txt"], resolution, safeBins, }); const longFlag = isSafeBinUsage({ argv: ["sort", "--output=missing.txt"], resolution, safeBins, }); expect(existing).toBe(false); expect(missing).toBe(false); expect(longFlag).toBe(false); }); it("threads trusted safe-bin dirs through allowlist evaluation", () => { if (process.platform === "win32") { return; } const analysis = { ok: true as const, segments: [ { raw: "jq .foo", argv: ["jq", ".foo"], resolution: { rawExecutable: "jq", resolvedPath: "/custom/bin/jq", executableName: "jq", }, }, ], }; const denied = evaluateExecAllowlist({ analysis, allowlist: [], safeBins: normalizeSafeBins(["jq"]), trustedSafeBinDirs: new Set(["/usr/bin"]), cwd: "/tmp", }); expect(denied.allowlistSatisfied).toBe(false); const allowed = evaluateExecAllowlist({ analysis, allowlist: [], safeBins: normalizeSafeBins(["jq"]), trustedSafeBinDirs: new Set(["/custom/bin"]), cwd: "/tmp", }); expect(allowed.allowlistSatisfied).toBe(true); }); }); describe("exec approvals allowlist evaluation", () => { it("satisfies allowlist on exact match", () => { const analysis = { ok: true, segments: [ { raw: "tool", argv: ["tool"], resolution: { rawExecutable: "tool", resolvedPath: "/usr/bin/tool", executableName: "tool", }, }, ], }; const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/tool" }]; const result = evaluateExecAllowlist({ analysis, allowlist, safeBins: new Set(), cwd: "/tmp", }); expect(result.allowlistSatisfied).toBe(true); expect(result.allowlistMatches.map((entry) => entry.pattern)).toEqual(["/usr/bin/tool"]); }); it("satisfies allowlist via safe bins", () => { const analysis = { ok: true, segments: [ { raw: "jq .foo", argv: ["jq", ".foo"], resolution: { rawExecutable: "jq", resolvedPath: "/usr/bin/jq", executableName: "jq", }, }, ], }; const result = evaluateExecAllowlist({ analysis, allowlist: [], safeBins: normalizeSafeBins(["jq"]), cwd: "/tmp", }); // Safe bins are disabled on Windows (PowerShell parsing/expansion differences). if (process.platform === "win32") { expect(result.allowlistSatisfied).toBe(false); return; } expect(result.allowlistSatisfied).toBe(true); expect(result.allowlistMatches).toEqual([]); }); it("satisfies allowlist via auto-allow skills", () => { const analysis = { ok: true, segments: [ { raw: "skill-bin", argv: ["skill-bin", "--help"], resolution: { rawExecutable: "skill-bin", resolvedPath: "/opt/skills/skill-bin", executableName: "skill-bin", }, }, ], }; const result = evaluateExecAllowlist({ analysis, allowlist: [], safeBins: new Set(), skillBins: new Set(["skill-bin"]), autoAllowSkills: true, cwd: "/tmp", }); expect(result.allowlistSatisfied).toBe(true); }); }); describe("exec approvals policy helpers", () => { it("minSecurity returns the more restrictive value", () => { expect(minSecurity("deny", "full")).toBe("deny"); expect(minSecurity("allowlist", "full")).toBe("allowlist"); }); it("maxAsk returns the more aggressive ask mode", () => { expect(maxAsk("off", "always")).toBe("always"); expect(maxAsk("on-miss", "off")).toBe("on-miss"); }); it("requiresExecApproval respects ask mode and allowlist satisfaction", () => { expect( requiresExecApproval({ ask: "always", security: "allowlist", analysisOk: true, allowlistSatisfied: true, }), ).toBe(true); expect( requiresExecApproval({ ask: "off", security: "allowlist", analysisOk: true, allowlistSatisfied: false, }), ).toBe(false); expect( requiresExecApproval({ ask: "on-miss", security: "allowlist", analysisOk: true, allowlistSatisfied: true, }), ).toBe(false); expect( requiresExecApproval({ ask: "on-miss", security: "allowlist", analysisOk: false, allowlistSatisfied: false, }), ).toBe(true); expect( requiresExecApproval({ ask: "on-miss", security: "full", analysisOk: false, allowlistSatisfied: false, }), ).toBe(false); }); }); describe("exec approvals wildcard agent", () => { it("merges wildcard allowlist entries with agent entries", () => { const dir = makeTempDir(); const prevOpenClawHome = process.env.OPENCLAW_HOME; try { process.env.OPENCLAW_HOME = dir; const approvalsPath = path.join(dir, ".openclaw", "exec-approvals.json"); fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); fs.writeFileSync( approvalsPath, JSON.stringify( { version: 1, agents: { "*": { allowlist: [{ pattern: "/bin/hostname" }] }, main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, }, }, null, 2, ), ); const resolved = resolveExecApprovals("main"); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([ "/bin/hostname", "/usr/bin/uname", ]); } finally { if (prevOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { process.env.OPENCLAW_HOME = prevOpenClawHome; } } }); }); describe("exec approvals node host allowlist check", () => { // These tests verify the allowlist satisfaction logic used by the node host path // The node host checks: matchAllowlist() || isSafeBinUsage() for each command segment // Using hardcoded resolution objects for cross-platform compatibility it("matches exact and wildcard allowlist patterns", () => { const cases: Array<{ resolution: { rawExecutable: string; resolvedPath: string; executableName: string }; entries: ExecAllowlistEntry[]; expectedPattern: string | null; }> = [ { resolution: { rawExecutable: "python3", resolvedPath: "/usr/bin/python3", executableName: "python3", }, entries: [{ pattern: "/usr/bin/python3" }], expectedPattern: "/usr/bin/python3", }, { // Simulates symlink resolution: // /opt/homebrew/bin/python3 -> /opt/homebrew/opt/python@3.14/bin/python3.14 resolution: { rawExecutable: "python3", resolvedPath: "/opt/homebrew/opt/python@3.14/bin/python3.14", executableName: "python3.14", }, entries: [{ pattern: "/opt/**/python*" }], expectedPattern: "/opt/**/python*", }, { resolution: { rawExecutable: "unknown-tool", resolvedPath: "/usr/local/bin/unknown-tool", executableName: "unknown-tool", }, entries: [{ pattern: "/usr/bin/python3" }, { pattern: "/opt/**/node" }], expectedPattern: null, }, ]; for (const testCase of cases) { const match = matchAllowlist(testCase.entries, testCase.resolution); expect(match?.pattern ?? null).toBe(testCase.expectedPattern); } }); it("does not treat unknown tools as safe bins", () => { const resolution = { rawExecutable: "unknown-tool", resolvedPath: "/usr/local/bin/unknown-tool", executableName: "unknown-tool", }; const safe = isSafeBinUsage({ argv: ["unknown-tool", "--help"], resolution, safeBins: normalizeSafeBins(["jq", "curl"]), }); expect(safe).toBe(false); }); it("satisfies via safeBins even when not in allowlist", () => { const resolution = { rawExecutable: "jq", resolvedPath: "/usr/bin/jq", executableName: "jq", }; // Not in allowlist const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }]; const match = matchAllowlist(entries, resolution); expect(match).toBeNull(); // But is a safe bin with non-file args const safe = isSafeBinUsage({ argv: ["jq", ".foo"], resolution, safeBins: normalizeSafeBins(["jq"]), }); // Safe bins are disabled on Windows (PowerShell parsing/expansion differences). if (process.platform === "win32") { expect(safe).toBe(false); return; } expect(safe).toBe(true); }); }); describe("exec approvals default agent migration", () => { it("migrates legacy default agent entries to main", () => { const file: ExecApprovalsFile = { version: 1, agents: { default: { allowlist: [{ pattern: "/bin/legacy" }] }, }, }; const resolved = resolveExecApprovalsFromFile({ file }); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]); expect(resolved.file.agents?.default).toBeUndefined(); expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy"); }); it("prefers main agent settings when both main and default exist", () => { const file: ExecApprovalsFile = { version: 1, agents: { main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] }, default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] }, }, }; const resolved = resolveExecApprovalsFromFile({ file }); expect(resolved.agent.ask).toBe("always"); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]); expect(resolved.file.agents?.default).toBeUndefined(); }); }); describe("normalizeExecApprovals handles string allowlist entries (#9790)", () => { function getMainAllowlistPatterns(file: ExecApprovalsFile): string[] | undefined { const normalized = normalizeExecApprovals(file); return normalized.agents?.main?.allowlist?.map((entry) => entry.pattern); } function expectNoSpreadStringArtifacts(entries: ExecAllowlistEntry[]) { for (const entry of entries) { expect(entry).toHaveProperty("pattern"); expect(typeof entry.pattern).toBe("string"); expect(entry.pattern.length).toBeGreaterThan(0); expect(entry).not.toHaveProperty("0"); } } it("converts bare string entries to proper ExecAllowlistEntry objects", () => { // Simulates a corrupted or legacy config where allowlist contains plain // strings (e.g. ["ls", "cat"]) instead of { pattern: "..." } objects. const file = { version: 1, agents: { main: { mode: "allowlist", allowlist: ["things", "remindctl", "memo", "which", "ls", "cat", "echo"], }, }, } as unknown as ExecApprovalsFile; const normalized = normalizeExecApprovals(file); const entries = normalized.agents?.main?.allowlist ?? []; // Spread-string corruption would create numeric keys — ensure none exist. expectNoSpreadStringArtifacts(entries); expect(entries.map((e) => e.pattern)).toEqual([ "things", "remindctl", "memo", "which", "ls", "cat", "echo", ]); }); it("preserves proper ExecAllowlistEntry objects unchanged", () => { const file: ExecApprovalsFile = { version: 1, agents: { main: { allowlist: [{ pattern: "/usr/bin/ls" }, { pattern: "/usr/bin/cat", id: "existing-id" }], }, }, }; const normalized = normalizeExecApprovals(file); const entries = normalized.agents?.main?.allowlist ?? []; expect(entries).toHaveLength(2); expect(entries[0]?.pattern).toBe("/usr/bin/ls"); expect(entries[1]?.pattern).toBe("/usr/bin/cat"); expect(entries[1]?.id).toBe("existing-id"); }); it("sanitizes mixed and malformed allowlist shapes", () => { const cases: Array<{ name: string; allowlist: unknown; expectedPatterns: string[] | undefined; }> = [ { name: "mixed entries", allowlist: ["ls", { pattern: "/usr/bin/cat" }, "echo"], expectedPatterns: ["ls", "/usr/bin/cat", "echo"], }, { name: "empty strings dropped", allowlist: ["", " ", "ls"], expectedPatterns: ["ls"], }, { name: "malformed objects dropped", allowlist: [{ pattern: "/usr/bin/ls" }, {}, { pattern: 123 }, { pattern: " " }, "echo"], expectedPatterns: ["/usr/bin/ls", "echo"], }, { name: "non-array dropped", allowlist: "ls", expectedPatterns: undefined, }, ]; for (const testCase of cases) { const patterns = getMainAllowlistPatterns({ version: 1, agents: { main: { allowlist: testCase.allowlist } as ExecApprovalsAgent, }, }); expect(patterns, testCase.name).toEqual(testCase.expectedPatterns); if (patterns) { const entries = normalizeExecApprovals({ version: 1, agents: { main: { allowlist: testCase.allowlist } as ExecApprovalsAgent, }, }).agents?.main?.allowlist; expectNoSpreadStringArtifacts(entries ?? []); } } }); }); describe("resolveAllowAlwaysPatterns", () => { function makeExecutable(dir: string, name: string): string { const fileName = process.platform === "win32" ? `${name}.exe` : name; const exe = path.join(dir, fileName); fs.writeFileSync(exe, ""); fs.chmodSync(exe, 0o755); return exe; } it("returns direct executable paths for non-shell segments", () => { const exe = path.join("/tmp", "openclaw-tool"); const patterns = resolveAllowAlwaysPatterns({ segments: [ { raw: exe, argv: [exe], resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" }, }, ], }); expect(patterns).toEqual([exe]); }); it("unwraps shell wrappers and persists the inner executable instead", () => { if (process.platform === "win32") { return; } const dir = makeTempDir(); const whoami = makeExecutable(dir, "whoami"); const patterns = resolveAllowAlwaysPatterns({ segments: [ { raw: "/bin/zsh -lc 'whoami'", argv: ["/bin/zsh", "-lc", "whoami"], resolution: { rawExecutable: "/bin/zsh", resolvedPath: "/bin/zsh", executableName: "zsh", }, }, ], cwd: dir, env: makePathEnv(dir), platform: process.platform, }); expect(patterns).toEqual([whoami]); expect(patterns).not.toContain("/bin/zsh"); }); it("extracts all inner binaries from shell chains and deduplicates", () => { if (process.platform === "win32") { return; } const dir = makeTempDir(); const whoami = makeExecutable(dir, "whoami"); const ls = makeExecutable(dir, "ls"); const patterns = resolveAllowAlwaysPatterns({ segments: [ { raw: "/bin/zsh -lc 'whoami && ls && whoami'", argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"], resolution: { rawExecutable: "/bin/zsh", resolvedPath: "/bin/zsh", executableName: "zsh", }, }, ], cwd: dir, env: makePathEnv(dir), platform: process.platform, }); expect(new Set(patterns)).toEqual(new Set([whoami, ls])); }); it("does not persist broad shell binaries when no inner command can be derived", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ { raw: "/bin/zsh -s", argv: ["/bin/zsh", "-s"], resolution: { rawExecutable: "/bin/zsh", resolvedPath: "/bin/zsh", executableName: "zsh", }, }, ], platform: process.platform, }); expect(patterns).toEqual([]); }); it("detects shell wrappers even when unresolved executableName is a full path", () => { if (process.platform === "win32") { return; } const dir = makeTempDir(); const whoami = makeExecutable(dir, "whoami"); const patterns = resolveAllowAlwaysPatterns({ segments: [ { raw: "/usr/local/bin/zsh -lc whoami", argv: ["/usr/local/bin/zsh", "-lc", "whoami"], resolution: { rawExecutable: "/usr/local/bin/zsh", resolvedPath: undefined, executableName: "/usr/local/bin/zsh", }, }, ], cwd: dir, env: makePathEnv(dir), platform: process.platform, }); expect(patterns).toEqual([whoami]); }); });