import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { evaluateShellAllowlist, normalizeSafeBins } from "./exec-approvals-allowlist.js"; import { analyzeArgvCommand, analyzeShellCommand, buildEnforcedShellCommand, buildSafeBinsShellCommand, } from "./exec-approvals-analysis.js"; import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; describe("exec approvals shell analysis", () => { describe("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); expect(res.command).toContain("rg foo src/*.ts"); expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/); }); it("fails closed on segment metadata mismatch", () => { const analysis = analyzeShellCommand({ command: "echo ok" }); expect(analysis.ok).toBe(true); expect( buildSafeBinsShellCommand({ command: "echo ok", segments: analysis.segments, segmentSatisfiedBy: [], }), ).toEqual({ ok: false, reason: "segment metadata mismatch" }); }); it("enforces canonical planned argv for every approved segment", () => { if (process.platform === "win32") { return; } const analysis = analyzeShellCommand({ command: "env rg -n needle", cwd: "/tmp", env: { PATH: "/usr/bin:/bin" }, platform: process.platform, }); expect(analysis.ok).toBe(true); const res = buildEnforcedShellCommand({ command: "env rg -n needle", segments: analysis.segments, platform: process.platform, }); expect(res.ok).toBe(true); expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/); expect(res.command).not.toContain("'env'"); }); }); describe("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 empty argv commands", () => { expect(analyzeArgvCommand({ argv: ["", " "] })).toEqual({ ok: false, reason: "empty argv", segments: [], }); }); 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: 'echo "ok $\\\n(id -u)"', reason: "unsupported shell token: newline", }, { command: 'echo "ok $\\\r\n(id -u)"', reason: "unsupported shell token: newline", }, { 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"]); }); }); describe("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); } }); it("fails allowlist analysis for shell line continuations", () => { const result = evaluateShellAllowlist({ command: 'echo "ok $\\\n(id -u)"', allowlist: [{ pattern: "/usr/bin/echo" }], safeBins: new Set(), cwd: "/tmp", }); expect(result.analysisOk).toBe(false); expect(result.allowlistSatisfied).toBe(false); }); it("satisfies allowlist when bare * wildcard is present", () => { const dir = makeTempDir(); const binPath = path.join(dir, "mybin"); fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); const env = makePathEnv(dir); try { const result = evaluateShellAllowlist({ command: "mybin --flag", allowlist: [{ pattern: "*" }], safeBins: new Set(), cwd: dir, env, }); expect(result.analysisOk).toBe(true); expect(result.allowlistSatisfied).toBe(true); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); it("normalizes safe bin names", () => { expect([...normalizeSafeBins([" jq ", "", "JQ", " sort "])]).toEqual(["jq", "sort"]); }); }); });