| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
|
|
| import { describe, expect, it, vi } from "vitest"; |
|
|
| import { |
| analyzeArgvCommand, |
| analyzeShellCommand, |
| evaluateExecAllowlist, |
| evaluateShellAllowlist, |
| isSafeBinUsage, |
| matchAllowlist, |
| maxAsk, |
| minSecurity, |
| normalizeSafeBins, |
| requiresExecApproval, |
| resolveCommandResolution, |
| resolveExecApprovals, |
| resolveExecApprovalsFromFile, |
| type ExecAllowlistEntry, |
| } from "./exec-approvals.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-")); |
| } |
|
|
| describe("exec approvals allowlist matching", () => { |
| it("ignores basename-only patterns", () => { |
| const resolution = { |
| rawExecutable: "rg", |
| resolvedPath: "/opt/homebrew/bin/rg", |
| executableName: "rg", |
| }; |
| const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).toBeNull(); |
| }); |
|
|
| it("matches by resolved path with **", () => { |
| const resolution = { |
| rawExecutable: "rg", |
| resolvedPath: "/opt/homebrew/bin/rg", |
| executableName: "rg", |
| }; |
| const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/rg" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match?.pattern).toBe("/opt/**/rg"); |
| }); |
|
|
| it("does not let * cross path separators", () => { |
| const resolution = { |
| rawExecutable: "rg", |
| resolvedPath: "/opt/homebrew/bin/rg", |
| executableName: "rg", |
| }; |
| const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/*/rg" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).toBeNull(); |
| }); |
|
|
| it("requires a resolved path", () => { |
| const resolution = { |
| rawExecutable: "bin/rg", |
| resolvedPath: undefined, |
| executableName: "rg", |
| }; |
| const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).toBeNull(); |
| }); |
| }); |
|
|
| describe("exec approvals command resolution", () => { |
| it("resolves PATH executables", () => { |
| 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 res = resolveCommandResolution("rg -n foo", undefined, makePathEnv(binDir)); |
| expect(res?.resolvedPath).toBe(exe); |
| expect(res?.executableName).toBe(exeName); |
| }); |
|
|
| it("resolves relative paths against cwd", () => { |
| 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); |
| const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined); |
| expect(res?.resolvedPath).toBe(script); |
| }); |
|
|
| it("parses quoted executables", () => { |
| 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); |
| const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined); |
| expect(res?.resolvedPath).toBe(script); |
| }); |
| }); |
|
|
| describe("exec approvals shell parsing", () => { |
| it("parses simple pipelines", () => { |
| const res = analyzeShellCommand({ command: "echo ok | jq .foo" }); |
| expect(res.ok).toBe(true); |
| expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]); |
| }); |
|
|
| it("parses chained commands", () => { |
| const res = analyzeShellCommand({ command: "ls && rm -rf /" }); |
| expect(res.ok).toBe(true); |
| expect(res.chains?.map((chain) => chain[0]?.argv[0])).toEqual(["ls", "rm"]); |
| }); |
|
|
| 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"]); |
| }); |
| }); |
|
|
| describe("exec approvals shell allowlist (chained commands)", () => { |
| it("allows chained commands when all parts are allowlisted", () => { |
| const allowlist: ExecAllowlistEntry[] = [ |
| { pattern: "/usr/bin/obsidian-cli" }, |
| { pattern: "/usr/bin/head" }, |
| ]; |
| const result = evaluateShellAllowlist({ |
| command: |
| "/usr/bin/obsidian-cli print-default && /usr/bin/obsidian-cli search foo | /usr/bin/head", |
| allowlist, |
| safeBins: new Set(), |
| cwd: "/tmp", |
| }); |
| expect(result.analysisOk).toBe(true); |
| expect(result.allowlistSatisfied).toBe(true); |
| }); |
|
|
| it("rejects chained commands when any part is not allowlisted", () => { |
| const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/obsidian-cli" }]; |
| const result = evaluateShellAllowlist({ |
| command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", |
| allowlist, |
| safeBins: new Set(), |
| cwd: "/tmp", |
| }); |
| expect(result.analysisOk).toBe(true); |
| expect(result.allowlistSatisfied).toBe(false); |
| }); |
|
|
| it("returns analysisOk=false for malformed chains", () => { |
| const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; |
| const result = evaluateShellAllowlist({ |
| command: "/usr/bin/echo ok &&", |
| allowlist, |
| safeBins: new Set(), |
| cwd: "/tmp", |
| }); |
| expect(result.analysisOk).toBe(false); |
| expect(result.allowlistSatisfied).toBe(false); |
| }); |
|
|
| it("respects quotes when splitting chains", () => { |
| const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; |
| const result = evaluateShellAllowlist({ |
| command: '/usr/bin/echo "foo && bar"', |
| allowlist, |
| safeBins: new Set(), |
| cwd: "/tmp", |
| }); |
| expect(result.analysisOk).toBe(true); |
| expect(result.allowlistSatisfied).toBe(true); |
| }); |
| }); |
|
|
| describe("exec approvals safe bins", () => { |
| it("allows safe bins with non-path args", () => { |
| const dir = makeTempDir(); |
| const binDir = path.join(dir, "bin"); |
| fs.mkdirSync(binDir, { recursive: true }); |
| const exeName = process.platform === "win32" ? "jq.exe" : "jq"; |
| const exe = path.join(binDir, exeName); |
| fs.writeFileSync(exe, ""); |
| fs.chmodSync(exe, 0o755); |
| const res = analyzeShellCommand({ |
| command: "jq .foo", |
| cwd: dir, |
| env: makePathEnv(binDir), |
| }); |
| expect(res.ok).toBe(true); |
| const segment = res.segments[0]; |
| const ok = isSafeBinUsage({ |
| argv: segment.argv, |
| resolution: segment.resolution, |
| safeBins: normalizeSafeBins(["jq"]), |
| cwd: dir, |
| }); |
| expect(ok).toBe(true); |
| }); |
|
|
| it("blocks safe bins with file args", () => { |
| const dir = makeTempDir(); |
| const binDir = path.join(dir, "bin"); |
| fs.mkdirSync(binDir, { recursive: true }); |
| const exeName = process.platform === "win32" ? "jq.exe" : "jq"; |
| const exe = path.join(binDir, exeName); |
| fs.writeFileSync(exe, ""); |
| fs.chmodSync(exe, 0o755); |
| const file = path.join(dir, "secret.json"); |
| fs.writeFileSync(file, "{}"); |
| const res = analyzeShellCommand({ |
| command: "jq .foo secret.json", |
| cwd: dir, |
| env: makePathEnv(binDir), |
| }); |
| expect(res.ok).toBe(true); |
| const segment = res.segments[0]; |
| const ok = isSafeBinUsage({ |
| argv: segment.argv, |
| resolution: segment.resolution, |
| safeBins: normalizeSafeBins(["jq"]), |
| cwd: dir, |
| }); |
| expect(ok).toBe(false); |
| }); |
| }); |
|
|
| 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", |
| }); |
| 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 homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(dir); |
|
|
| try { |
| 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 { |
| homedirSpy.mockRestore(); |
| } |
| }); |
| }); |
|
|
| describe("exec approvals node host allowlist check", () => { |
| |
| |
| |
|
|
| it("satisfies allowlist when command matches exact path pattern", () => { |
| const resolution = { |
| rawExecutable: "python3", |
| resolvedPath: "/usr/bin/python3", |
| executableName: "python3", |
| }; |
| const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).not.toBeNull(); |
| expect(match?.pattern).toBe("/usr/bin/python3"); |
| }); |
|
|
| it("satisfies allowlist when command matches ** wildcard pattern", () => { |
| |
| const resolution = { |
| rawExecutable: "python3", |
| resolvedPath: "/opt/homebrew/opt/python@3.14/bin/python3.14", |
| executableName: "python3.14", |
| }; |
| |
| const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/python*" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match?.pattern).toBe("/opt/**/python*"); |
| }); |
|
|
| it("does not satisfy allowlist when command is not in allowlist", () => { |
| const resolution = { |
| rawExecutable: "unknown-tool", |
| resolvedPath: "/usr/local/bin/unknown-tool", |
| executableName: "unknown-tool", |
| }; |
| |
| const entries: ExecAllowlistEntry[] = [ |
| { pattern: "/usr/bin/python3" }, |
| { pattern: "/opt/**/node" }, |
| ]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).toBeNull(); |
|
|
| |
| const safe = isSafeBinUsage({ |
| argv: ["unknown-tool", "--help"], |
| resolution, |
| safeBins: normalizeSafeBins(["jq", "curl"]), |
| cwd: "/tmp", |
| }); |
| expect(safe).toBe(false); |
| }); |
|
|
| it("satisfies via safeBins even when not in allowlist", () => { |
| const resolution = { |
| rawExecutable: "jq", |
| resolvedPath: "/usr/bin/jq", |
| executableName: "jq", |
| }; |
| |
| const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }]; |
| const match = matchAllowlist(entries, resolution); |
| expect(match).toBeNull(); |
|
|
| |
| const safe = isSafeBinUsage({ |
| argv: ["jq", ".foo"], |
| resolution, |
| safeBins: normalizeSafeBins(["jq"]), |
| cwd: "/tmp", |
| }); |
| expect(safe).toBe(true); |
| }); |
| }); |
|
|
| describe("exec approvals default agent migration", () => { |
| it("migrates legacy default agent entries to main", () => { |
| const file = { |
| 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 = { |
| 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(); |
| }); |
| }); |
|
|