From cd919ebd2dead38ec4fccc8bf714daa472ce86e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 23:19:56 +0100 Subject: [PATCH] refactor(exec): unify wrapper resolution and split approvals tests --- src/infra/exec-approvals-allow-always.test.ts | 218 ++++ src/infra/exec-approvals-allowlist.ts | 124 +-- src/infra/exec-approvals-config.test.ts | 283 +++++ src/infra/exec-approvals-parity.test.ts | 37 + src/infra/exec-approvals-safe-bins.test.ts | 409 ++++++++ src/infra/exec-approvals-test-helpers.ts | 59 ++ src/infra/exec-approvals.test.ts | 969 +----------------- src/infra/exec-wrapper-resolution.ts | 414 ++++---- 8 files changed, 1253 insertions(+), 1260 deletions(-) create mode 100644 src/infra/exec-approvals-allow-always.test.ts create mode 100644 src/infra/exec-approvals-config.test.ts create mode 100644 src/infra/exec-approvals-parity.test.ts create mode 100644 src/infra/exec-approvals-safe-bins.test.ts create mode 100644 src/infra/exec-approvals-test-helpers.ts diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts new file mode 100644 index 00000000000..ab43ff17ec5 --- /dev/null +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -0,0 +1,218 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + evaluateShellAllowlist, + requiresExecApproval, + resolveAllowAlwaysPatterns, + resolveSafeBins, +} from "./exec-approvals.js"; + +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]); + }); + + it("unwraps known dispatch wrappers before shell wrappers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const whoami = makeExecutable(dir, "whoami"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: "/usr/bin/nice /bin/zsh -lc whoami", + argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], + resolution: { + rawExecutable: "/usr/bin/nice", + resolvedPath: "/usr/bin/nice", + executableName: "nice", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain("/usr/bin/nice"); + }); + + it("fails closed for unresolved dispatch wrappers", () => { + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: "sudo /bin/zsh -lc whoami", + argv: ["sudo", "/bin/zsh", "-lc", "whoami"], + resolution: { + rawExecutable: "sudo", + resolvedPath: "/usr/bin/sudo", + executableName: "sudo", + }, + }, + ], + platform: process.platform, + }); + expect(patterns).toEqual([]); + }); + + it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const safeBins = resolveSafeBins(undefined); + const env = makePathEnv(dir); + + const first = evaluateShellAllowlist({ + command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'", + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([echo]); + + const second = evaluateShellAllowlist({ + command: "/usr/bin/nice /bin/zsh -lc 'id > marker'", + allowlist: [{ pattern: echo }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + expect( + requiresExecApproval({ + ask: "on-miss", + security: "allowlist", + analysisOk: second.analysisOk, + allowlistSatisfied: second.allowlistSatisfied, + }), + ).toBe(true); + }); +}); diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 64defc8f84d..3fd4c628b8c 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -18,8 +18,9 @@ import { } from "./exec-safe-bin-policy.js"; import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js"; import { - DISPATCH_WRAPPER_EXECUTABLES, - basenameLower, + extractShellWrapperInlineCommand, + isDispatchWrapperExecutable, + isShellWrapperExecutable, unwrapKnownDispatchWrapperInvocation, } from "./exec-wrapper-resolution.js"; @@ -221,98 +222,33 @@ export type ExecAllowlistAnalysis = { segmentSatisfiedBy: ExecSegmentSatisfiedBy[]; }; -const SHELL_WRAPPER_EXECUTABLES = new Set([ - "ash", - "bash", - "cmd", - "cmd.exe", - "dash", - "fish", - "ksh", - "powershell", - "powershell.exe", - "pwsh", - "pwsh.exe", - "sh", - "zsh", -]); - -function normalizeExecutableName(name: string | undefined): string { - return (name ?? "").trim().toLowerCase(); +function hasSegmentExecutableMatch( + segment: ExecCommandSegment, + predicate: (token: string) => boolean, +): boolean { + const candidates = [ + segment.resolution?.executableName, + segment.resolution?.rawExecutable, + segment.argv[0], + ]; + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (!trimmed) { + continue; + } + if (predicate(trimmed)) { + return true; + } + } + return false; } function isShellWrapperSegment(segment: ExecCommandSegment): boolean { - const candidates = [ - normalizeExecutableName(segment.resolution?.executableName), - normalizeExecutableName(segment.resolution?.rawExecutable), - ]; - for (const candidate of candidates) { - if (!candidate) { - continue; - } - if (SHELL_WRAPPER_EXECUTABLES.has(candidate)) { - return true; - } - const base = candidate.split(/[\\/]/).pop(); - if (base && SHELL_WRAPPER_EXECUTABLES.has(base)) { - return true; - } - } - return false; + return hasSegmentExecutableMatch(segment, isShellWrapperExecutable); } function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean { - const candidates = [ - normalizeExecutableName(segment.resolution?.executableName), - normalizeExecutableName(segment.resolution?.rawExecutable), - normalizeExecutableName(segment.argv[0]), - ]; - for (const candidate of candidates) { - if (!candidate) { - continue; - } - if (DISPATCH_WRAPPER_EXECUTABLES.has(candidate)) { - return true; - } - const base = basenameLower(candidate); - if (DISPATCH_WRAPPER_EXECUTABLES.has(base)) { - return true; - } - } - return false; -} - -function extractShellInlineCommand(argv: string[]): string | null { - for (let i = 1; i < argv.length; i += 1) { - const token = argv[i]; - if (!token) { - continue; - } - const lower = token.toLowerCase(); - if (lower === "--") { - break; - } - if ( - lower === "-c" || - lower === "--command" || - lower === "-command" || - lower === "/c" || - lower === "/k" - ) { - const next = argv[i + 1]?.trim(); - return next ? next : null; - } - if (/^-[^-]*c[^-]*$/i.test(token)) { - const commandIndex = lower.indexOf("c"); - const inline = token.slice(commandIndex + 1).trim(); - if (inline) { - return inline; - } - const next = argv[i + 1]?.trim(); - return next ? next : null; - } - } - return null; + return hasSegmentExecutableMatch(segment, isDispatchWrapperExecutable); } function collectAllowAlwaysPatterns(params: { @@ -328,15 +264,15 @@ function collectAllowAlwaysPatterns(params: { } if (isDispatchWrapperSegment(params.segment)) { - const unwrappedArgv = unwrapKnownDispatchWrapperInvocation(params.segment.argv); - if (!unwrappedArgv || unwrappedArgv.length === 0) { + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(params.segment.argv); + if (dispatchUnwrap.kind !== "unwrapped" || dispatchUnwrap.argv.length === 0) { return; } collectAllowAlwaysPatterns({ segment: { - raw: unwrappedArgv.join(" "), - argv: unwrappedArgv, - resolution: resolveCommandResolutionFromArgv(unwrappedArgv, params.cwd, params.env), + raw: dispatchUnwrap.argv.join(" "), + argv: dispatchUnwrap.argv, + resolution: resolveCommandResolutionFromArgv(dispatchUnwrap.argv, params.cwd, params.env), }, cwd: params.cwd, env: params.env, @@ -355,7 +291,7 @@ function collectAllowAlwaysPatterns(params: { params.out.add(candidatePath); return; } - const inlineCommand = extractShellInlineCommand(params.segment.argv); + const inlineCommand = extractShellWrapperInlineCommand(params.segment.argv); if (!inlineCommand) { return; } diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts new file mode 100644 index 00000000000..ba575bd3366 --- /dev/null +++ b/src/infra/exec-approvals-config.test.ts @@ -0,0 +1,283 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + isSafeBinUsage, + matchAllowlist, + normalizeExecApprovals, + normalizeSafeBins, + resolveExecApprovals, + resolveExecApprovalsFromFile, + type ExecApprovalsAgent, + type ExecAllowlistEntry, + type ExecApprovalsFile, +} from "./exec-approvals.js"; + +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 ?? []); + } + } + }); +}); diff --git a/src/infra/exec-approvals-parity.test.ts b/src/infra/exec-approvals-parity.test.ts new file mode 100644 index 00000000000..177e64c8c99 --- /dev/null +++ b/src/infra/exec-approvals-parity.test.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + loadShellParserParityFixtureCases, + loadWrapperResolutionParityFixtureCases, +} from "./exec-approvals-test-helpers.js"; +import { analyzeShellCommand, resolveCommandResolutionFromArgv } from "./exec-approvals.js"; + +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); + }); + } +}); diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts new file mode 100644 index 00000000000..64e44c91ab4 --- /dev/null +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -0,0 +1,409 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import { + evaluateExecAllowlist, + evaluateShellAllowlist, + isSafeBinUsage, + normalizeSafeBins, + resolveSafeBins, +} from "./exec-approvals.js"; +import { + SAFE_BIN_PROFILE_FIXTURES, + SAFE_BIN_PROFILES, + resolveSafeBinProfiles, +} from "./exec-safe-bin-policy.js"; + +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("does not auto-allow unprofiled safe-bin entries", () => { + if (process.platform === "win32") { + return; + } + const result = evaluateShellAllowlist({ + command: "python3 -c \"print('owned')\"", + allowlist: [], + safeBins: normalizeSafeBins(["python3"]), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(false); + }); + + it("allows caller-defined custom safe-bin profiles", () => { + if (process.platform === "win32") { + return; + } + const safeBinProfiles = resolveSafeBinProfiles({ + echo: { + maxPositional: 1, + }, + }); + const allow = isSafeBinUsage({ + argv: ["echo", "hello"], + resolution: { + rawExecutable: "echo", + resolvedPath: "/bin/echo", + executableName: "echo", + }, + safeBins: normalizeSafeBins(["echo"]), + safeBinProfiles, + }); + const deny = isSafeBinUsage({ + argv: ["echo", "hello", "world"], + resolution: { + rawExecutable: "echo", + resolvedPath: "/bin/echo", + executableName: "echo", + }, + safeBins: normalizeSafeBins(["echo"]), + safeBinProfiles, + }); + expect(allow).toBe(true); + expect(deny).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); + }); + + it("does not auto-trust PATH-shadowed safe bins without explicit trusted dirs", () => { + if (process.platform === "win32") { + return; + } + const tmp = makeTempDir(); + const fakeDir = path.join(tmp, "fake-bin"); + fs.mkdirSync(fakeDir, { recursive: true }); + const fakeHead = path.join(fakeDir, "head"); + fs.writeFileSync(fakeHead, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(fakeHead, 0o755); + + const result = evaluateShellAllowlist({ + command: "head -n 1", + allowlist: [], + safeBins: normalizeSafeBins(["head"]), + env: makePathEnv(fakeDir), + cwd: tmp, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(false); + expect(result.segmentSatisfiedBy).toEqual([null]); + expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead); + }); +}); diff --git a/src/infra/exec-approvals-test-helpers.ts b/src/infra/exec-approvals-test-helpers.ts new file mode 100644 index 00000000000..fb616410b35 --- /dev/null +++ b/src/infra/exec-approvals-test-helpers.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function makePathEnv(binDir: string): NodeJS.ProcessEnv { + if (process.platform !== "win32") { + return { PATH: binDir }; + } + return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" }; +} + +export function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-")); +} + +export type ShellParserParityFixtureCase = { + id: string; + command: string; + ok: boolean; + executables: string[]; +}; + +type ShellParserParityFixture = { + cases: ShellParserParityFixtureCase[]; +}; + +export type WrapperResolutionParityFixtureCase = { + id: string; + argv: string[]; + expectedRawExecutable: string | null; +}; + +type WrapperResolutionParityFixture = { + cases: WrapperResolutionParityFixtureCase[]; +}; + +export 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; +} + +export 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; +} diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 322749a10cc..aafe0a4774b 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -1,14 +1,13 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; import { analyzeArgvCommand, analyzeShellCommand, buildSafeBinsShellCommand, evaluateExecAllowlist, evaluateShellAllowlist, - isSafeBinUsage, matchAllowlist, maxAsk, mergeExecApprovalsSocketDefaults, @@ -19,77 +18,10 @@ import { 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, - resolveSafeBinProfiles, -} 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 = { @@ -474,36 +406,6 @@ describe("exec approvals shell parsing", () => { }); }); -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<{ @@ -580,399 +482,6 @@ describe("exec approvals shell allowlist (chained commands)", () => { }); }); -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("does not auto-allow unprofiled safe-bin entries", () => { - if (process.platform === "win32") { - return; - } - const result = evaluateShellAllowlist({ - command: "python3 -c \"print('owned')\"", - allowlist: [], - safeBins: normalizeSafeBins(["python3"]), - cwd: "/tmp", - }); - expect(result.analysisOk).toBe(true); - expect(result.allowlistSatisfied).toBe(false); - }); - - it("allows caller-defined custom safe-bin profiles", () => { - if (process.platform === "win32") { - return; - } - const safeBinProfiles = resolveSafeBinProfiles({ - echo: { - maxPositional: 1, - }, - }); - const allow = isSafeBinUsage({ - argv: ["echo", "hello"], - resolution: { - rawExecutable: "echo", - resolvedPath: "/bin/echo", - executableName: "echo", - }, - safeBins: normalizeSafeBins(["echo"]), - safeBinProfiles, - }); - const deny = isSafeBinUsage({ - argv: ["echo", "hello", "world"], - resolution: { - rawExecutable: "echo", - resolvedPath: "/bin/echo", - executableName: "echo", - }, - safeBins: normalizeSafeBins(["echo"]), - safeBinProfiles, - }); - expect(allow).toBe(true); - expect(deny).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); - }); - - it("does not auto-trust PATH-shadowed safe bins without explicit trusted dirs", () => { - if (process.platform === "win32") { - return; - } - const tmp = makeTempDir(); - const fakeDir = path.join(tmp, "fake-bin"); - fs.mkdirSync(fakeDir, { recursive: true }); - const fakeHead = path.join(fakeDir, "head"); - fs.writeFileSync(fakeHead, "#!/bin/sh\nexit 0\n"); - fs.chmodSync(fakeHead, 0o755); - - const result = evaluateShellAllowlist({ - command: "head -n 1", - allowlist: [], - safeBins: normalizeSafeBins(["head"]), - env: makePathEnv(fakeDir), - cwd: tmp, - }); - expect(result.analysisOk).toBe(true); - expect(result.allowlistSatisfied).toBe(false); - expect(result.segmentSatisfiedBy).toEqual([null]); - expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead); - }); -}); - describe("exec approvals allowlist evaluation", () => { it("satisfies allowlist on exact match", () => { const analysis = { @@ -1111,479 +620,3 @@ describe("exec approvals policy helpers", () => { ).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]); - }); - - it("unwraps known dispatch wrappers before shell wrappers", () => { - if (process.platform === "win32") { - return; - } - const dir = makeTempDir(); - const whoami = makeExecutable(dir, "whoami"); - const patterns = resolveAllowAlwaysPatterns({ - segments: [ - { - raw: "/usr/bin/nice /bin/zsh -lc whoami", - argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "/usr/bin/nice", - resolvedPath: "/usr/bin/nice", - executableName: "nice", - }, - }, - ], - cwd: dir, - env: makePathEnv(dir), - platform: process.platform, - }); - expect(patterns).toEqual([whoami]); - expect(patterns).not.toContain("/usr/bin/nice"); - }); - - it("fails closed for unresolved dispatch wrappers", () => { - const patterns = resolveAllowAlwaysPatterns({ - segments: [ - { - raw: "sudo /bin/zsh -lc whoami", - argv: ["sudo", "/bin/zsh", "-lc", "whoami"], - resolution: { - rawExecutable: "sudo", - resolvedPath: "/usr/bin/sudo", - executableName: "sudo", - }, - }, - ], - platform: process.platform, - }); - expect(patterns).toEqual([]); - }); - - it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { - if (process.platform === "win32") { - return; - } - const dir = makeTempDir(); - const echo = makeExecutable(dir, "echo"); - makeExecutable(dir, "id"); - const safeBins = resolveSafeBins(undefined); - const env = makePathEnv(dir); - - const first = evaluateShellAllowlist({ - command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'", - allowlist: [], - safeBins, - cwd: dir, - env, - platform: process.platform, - }); - const persisted = resolveAllowAlwaysPatterns({ - segments: first.segments, - cwd: dir, - env, - platform: process.platform, - }); - expect(persisted).toEqual([echo]); - - const second = evaluateShellAllowlist({ - command: "/usr/bin/nice /bin/zsh -lc 'id > marker'", - allowlist: [{ pattern: echo }], - safeBins, - cwd: dir, - env, - platform: process.platform, - }); - expect(second.allowlistSatisfied).toBe(false); - expect( - requiresExecApproval({ - ask: "on-miss", - security: "allowlist", - analysisOk: second.analysisOk, - allowlistSatisfied: second.allowlistSatisfied, - }), - ).toBe(true); - }); -}); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index c7ac3c7bfa9..c5ae325e973 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -2,32 +2,51 @@ import path from "node:path"; export const MAX_DISPATCH_WRAPPER_DEPTH = 4; -export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]); -export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]); -export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]); -export const DISPATCH_WRAPPER_EXECUTABLES = new Set([ +const WINDOWS_EXE_SUFFIX = ".exe"; + +const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; +const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; +const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; +const DISPATCH_WRAPPER_NAMES = [ "chrt", - "chrt.exe", "doas", - "doas.exe", "env", - "env.exe", "ionice", - "ionice.exe", "nice", - "nice.exe", "nohup", - "nohup.exe", "setsid", - "setsid.exe", "stdbuf", - "stdbuf.exe", "sudo", - "sudo.exe", "taskset", - "taskset.exe", "timeout", - "timeout.exe", +] as const; + +function withWindowsExeAliases(names: readonly string[]): string[] { + const expanded = new Set(); + for (const name of names) { + expanded.add(name); + expanded.add(`${name}${WINDOWS_EXE_SUFFIX}`); + } + return Array.from(expanded); +} + +function stripWindowsExeSuffix(value: string): string { + return value.endsWith(WINDOWS_EXE_SUFFIX) ? value.slice(0, -WINDOWS_EXE_SUFFIX.length) : value; +} + +export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES); +export const WINDOWS_CMD_WRAPPERS = new Set(withWindowsExeAliases(WINDOWS_CMD_WRAPPER_NAMES)); +export const POWERSHELL_WRAPPERS = new Set(withWindowsExeAliases(POWERSHELL_WRAPPER_NAMES)); +export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPATCH_WRAPPER_NAMES)); + +const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); +const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); +const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); +const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); +const SHELL_WRAPPER_CANONICAL = new Set([ + ...POSIX_SHELL_WRAPPER_NAMES, + ...WINDOWS_CMD_WRAPPER_NAMES, + ...POWERSHELL_WRAPPER_NAMES, ]); const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); @@ -58,9 +77,9 @@ type ShellWrapperSpec = { }; const SHELL_WRAPPER_SPECS: ReadonlyArray = [ - { kind: "posix", names: POSIX_SHELL_WRAPPERS }, - { kind: "cmd", names: WINDOWS_CMD_WRAPPERS }, - { kind: "powershell", names: POWERSHELL_WRAPPERS }, + { kind: "posix", names: POSIX_SHELL_WRAPPER_CANONICAL }, + { kind: "cmd", names: WINDOWS_CMD_WRAPPER_CANONICAL }, + { kind: "powershell", names: POWERSHELL_WRAPPER_CANONICAL }, ]; export type ShellWrapperCommand = { @@ -75,14 +94,27 @@ export function basenameLower(token: string): string { return base.trim().toLowerCase(); } +export function normalizeExecutableToken(token: string): string { + return stripWindowsExeSuffix(basenameLower(token)); +} + +export function isDispatchWrapperExecutable(token: string): boolean { + return DISPATCH_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); +} + +export function isShellWrapperExecutable(token: string): boolean { + return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); +} + function normalizeRawCommand(rawCommand?: string | null): string | null { const trimmed = rawCommand?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { + const canonicalBase = stripWindowsExeSuffix(baseExecutable); for (const spec of SHELL_WRAPPER_SPECS) { - if (spec.names.has(baseExecutable)) { + if (spec.names.has(canonicalBase)) { return spec; } } @@ -93,7 +125,16 @@ export function isEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); } -export function unwrapEnvInvocation(argv: string[]): string[] | null { +type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid"; + +function scanWrapperInvocation( + argv: string[], + params: { + separators?: ReadonlySet; + onToken: (token: string, lowerToken: string) => WrapperScanDirective; + adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null; + }, +): string[] | null { let idx = 1; let expectsOptionValue = false; while (idx < argv.length) { @@ -107,27 +148,48 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null { idx += 1; continue; } - if (token === "--" || token === "-") { + if (params.separators?.has(token)) { idx += 1; break; } - if (isEnvAssignment(token)) { - idx += 1; - continue; + const directive = params.onToken(token, token.toLowerCase()); + if (directive === "stop") { + break; } - if (token.startsWith("-") && token !== "-") { - const lower = token.toLowerCase(); + if (directive === "invalid") { + return null; + } + if (directive === "consume-next") { + expectsOptionValue = true; + } + idx += 1; + } + if (expectsOptionValue) { + return null; + } + const commandIndex = params.adjustCommandIndex ? params.adjustCommandIndex(idx, argv) : idx; + if (commandIndex === null || commandIndex >= argv.length) { + return null; + } + return argv.slice(commandIndex); +} + +export function unwrapEnvInvocation(argv: string[]): string[] | null { + return scanWrapperInvocation(argv, { + separators: new Set(["--", "-"]), + onToken: (token, lower) => { + if (isEnvAssignment(token)) { + return "continue"; + } + if (!token.startsWith("-") || token === "-") { + return "stop"; + } const [flag] = lower.split("=", 2); if (ENV_FLAG_OPTIONS.has(flag)) { - idx += 1; - continue; + return "continue"; } if (ENV_OPTIONS_WITH_VALUE.has(flag)) { - if (!lower.includes("=")) { - expectsOptionValue = true; - } - idx += 1; - continue; + return lower.includes("=") ? "continue" : "consume-next"; } if ( lower.startsWith("-u") || @@ -140,195 +202,131 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null { lower.startsWith("--ignore-signal=") || lower.startsWith("--block-signal=") ) { - idx += 1; - continue; + return "continue"; } - return null; - } - break; - } - return idx < argv.length ? argv.slice(idx) : null; + return "invalid"; + }, + }); } function unwrapNiceInvocation(argv: string[]): string[] | null { - let idx = 1; - let expectsOptionValue = false; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (expectsOptionValue) { - expectsOptionValue = false; - idx += 1; - continue; - } - if (token === "--") { - idx += 1; - break; - } - if (token.startsWith("-") && token !== "-") { - const lower = token.toLowerCase(); + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; + } const [flag] = lower.split("=", 2); if (/^-\d+$/.test(lower)) { - idx += 1; - continue; + return "continue"; } if (NICE_OPTIONS_WITH_VALUE.has(flag)) { - if (!lower.includes("=") && lower === flag) { - expectsOptionValue = true; - } - idx += 1; - continue; + return lower.includes("=") || lower !== flag ? "continue" : "consume-next"; } if (lower.startsWith("-n") && lower.length > 2) { - idx += 1; - continue; + return "continue"; } - return null; - } - break; - } - if (expectsOptionValue) { - return null; - } - return idx < argv.length ? argv.slice(idx) : null; + return "invalid"; + }, + }); } function unwrapNohupInvocation(argv: string[]): string[] | null { - let idx = 1; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (token === "--") { - idx += 1; - break; - } - if (token.startsWith("-") && token !== "-") { - const lower = token.toLowerCase(); - if (lower === "--help" || lower === "--version") { - idx += 1; - continue; + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; } - return null; - } - break; - } - return idx < argv.length ? argv.slice(idx) : null; + return lower === "--help" || lower === "--version" ? "continue" : "invalid"; + }, + }); } function unwrapStdbufInvocation(argv: string[]): string[] | null { - let idx = 1; - let expectsOptionValue = false; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (expectsOptionValue) { - expectsOptionValue = false; - idx += 1; - continue; - } - if (token === "--") { - idx += 1; - break; - } - if (token.startsWith("-") && token !== "-") { - const lower = token.toLowerCase(); + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; + } const [flag] = lower.split("=", 2); if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) { - if (!lower.includes("=")) { - expectsOptionValue = true; - } - idx += 1; - continue; + return lower.includes("=") ? "continue" : "consume-next"; } - return null; - } - break; - } - if (expectsOptionValue) { - return null; - } - return idx < argv.length ? argv.slice(idx) : null; + return "invalid"; + }, + }); } function unwrapTimeoutInvocation(argv: string[]): string[] | null { - let idx = 1; - let expectsOptionValue = false; - while (idx < argv.length) { - const token = argv[idx]?.trim() ?? ""; - if (!token) { - idx += 1; - continue; - } - if (expectsOptionValue) { - expectsOptionValue = false; - idx += 1; - continue; - } - if (token === "--") { - idx += 1; - break; - } - if (token.startsWith("-") && token !== "-") { - const lower = token.toLowerCase(); + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!token.startsWith("-") || token === "-") { + return "stop"; + } const [flag] = lower.split("=", 2); if (TIMEOUT_FLAG_OPTIONS.has(flag)) { - idx += 1; - continue; + return "continue"; } if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) { - if (!lower.includes("=")) { - expectsOptionValue = true; - } - idx += 1; - continue; + return lower.includes("=") ? "continue" : "consume-next"; } - return null; - } - break; - } - if (expectsOptionValue || idx >= argv.length) { - return null; - } - idx += 1; // duration - return idx < argv.length ? argv.slice(idx) : null; + return "invalid"; + }, + adjustCommandIndex: (commandIndex, currentArgv) => { + // timeout consumes a required duration token before the wrapped command. + const wrappedCommandIndex = commandIndex + 1; + return wrappedCommandIndex < currentArgv.length ? wrappedCommandIndex : null; + }, + }); } -export function unwrapKnownDispatchWrapperInvocation(argv: string[]): string[] | null | undefined { +export type DispatchWrapperUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult { + return { kind: "blocked", wrapper }; +} + +function unwrapDispatchWrapper( + wrapper: string, + unwrapped: string[] | null, +): DispatchWrapperUnwrapResult { + return unwrapped + ? { kind: "unwrapped", wrapper, argv: unwrapped } + : blockDispatchWrapper(wrapper); +} + +export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult { const token0 = argv[0]?.trim(); if (!token0) { - return undefined; + return { kind: "not-wrapper" }; } - const base = basenameLower(token0); - const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base; - switch (normalizedBase) { + const wrapper = normalizeExecutableToken(token0); + switch (wrapper) { case "env": - return unwrapEnvInvocation(argv); + return unwrapDispatchWrapper(wrapper, unwrapEnvInvocation(argv)); case "nice": - return unwrapNiceInvocation(argv); + return unwrapDispatchWrapper(wrapper, unwrapNiceInvocation(argv)); case "nohup": - return unwrapNohupInvocation(argv); + return unwrapDispatchWrapper(wrapper, unwrapNohupInvocation(argv)); case "stdbuf": - return unwrapStdbufInvocation(argv); + return unwrapDispatchWrapper(wrapper, unwrapStdbufInvocation(argv)); case "timeout": - return unwrapTimeoutInvocation(argv); + return unwrapDispatchWrapper(wrapper, unwrapTimeoutInvocation(argv)); case "chrt": case "doas": case "ionice": case "setsid": case "sudo": case "taskset": - return null; + return blockDispatchWrapper(wrapper); default: - return undefined; + return { kind: "not-wrapper" }; } } @@ -338,32 +336,47 @@ export function unwrapDispatchWrappersForResolution( ): string[] { let current = argv; for (let depth = 0; depth < maxDepth; depth += 1) { - const unwrapped = unwrapKnownDispatchWrapperInvocation(current); - if (unwrapped === undefined) { + const unwrap = unwrapKnownDispatchWrapperInvocation(current); + if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { break; } - if (!unwrapped || unwrapped.length === 0) { - break; - } - current = unwrapped; + current = unwrap.argv; } return current; } function extractPosixShellInlineCommand(argv: string[]): string | null { - const flag = argv[1]?.trim(); - if (!flag) { - return null; + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + const lower = token.toLowerCase(); + if (lower === "--") { + break; + } + if (POSIX_INLINE_COMMAND_FLAGS.has(lower)) { + const cmd = argv[i + 1]?.trim(); + return cmd ? cmd : null; + } + if (/^-[^-]*c[^-]*$/i.test(token)) { + const commandIndex = lower.indexOf("c"); + const inline = token.slice(commandIndex + 1).trim(); + if (inline) { + return inline; + } + const cmd = argv[i + 1]?.trim(); + return cmd ? cmd : null; + } } - if (!POSIX_INLINE_COMMAND_FLAGS.has(flag.toLowerCase())) { - return null; - } - const cmd = argv[2]?.trim(); - return cmd ? cmd : null; + return null; } function extractCmdInlineCommand(argv: string[]): string | null { - const idx = argv.findIndex((item) => item.trim().toLowerCase() === "/c"); + const idx = argv.findIndex((item) => { + const token = item.trim().toLowerCase(); + return token === "/c" || token === "/k"; + }); if (idx === -1) { return null; } @@ -418,15 +431,15 @@ function extractShellWrapperCommandInternal( return { isWrapper: false, command: null }; } - const base0 = basenameLower(token0); - if (DISPATCH_WRAPPER_EXECUTABLES.has(base0)) { - const unwrapped = unwrapKnownDispatchWrapperInvocation(argv); - if (!unwrapped) { - return { isWrapper: false, command: null }; - } - return extractShellWrapperCommandInternal(unwrapped, rawCommand, depth + 1); + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); + if (dispatchUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (dispatchUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); } + const base0 = normalizeExecutableToken(token0); const wrapper = findShellWrapperSpec(base0); if (!wrapper) { return { isWrapper: false, command: null }; @@ -440,6 +453,11 @@ function extractShellWrapperCommandInternal( return { isWrapper: true, command: rawCommand ?? payload }; } +export function extractShellWrapperInlineCommand(argv: string[]): string | null { + const extracted = extractShellWrapperCommandInternal(argv, null, 0); + return extracted.isWrapper ? extracted.command : null; +} + export function extractShellWrapperCommand( argv: string[], rawCommand?: string | null,