diff --git a/src/infra/dispatch-wrapper-resolution.ts b/src/infra/dispatch-wrapper-resolution.ts index fe7eaa11c28..46a18230495 100644 --- a/src/infra/dispatch-wrapper-resolution.ts +++ b/src/infra/dispatch-wrapper-resolution.ts @@ -42,6 +42,8 @@ const TIME_FLAG_OPTIONS = new Set([ "--version", ]); const TIME_OPTIONS_WITH_VALUE = new Set(["-f", "--format", "-o", "--output"]); +const BSD_SCRIPT_FLAG_OPTIONS = new Set(["-a", "-d", "-k", "-p", "-q", "-r"]); +const BSD_SCRIPT_OPTIONS_WITH_VALUE = new Set(["-F", "-t"]); const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); @@ -259,6 +261,47 @@ function unwrapTimeInvocation(argv: string[]): string[] | null { }); } +function supportsScriptPositionalCommand(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin" || platform === "freebsd"; +} + +function unwrapScriptInvocation(argv: string[]): string[] | null { + if (!supportsScriptPositionalCommand()) { + return null; + } + return scanWrapperInvocation(argv, { + separators: new Set(["--"]), + onToken: (token, lower) => { + if (!lower.startsWith("-") || lower === "-") { + return "stop"; + } + const [flag] = token.split("=", 2); + if (BSD_SCRIPT_OPTIONS_WITH_VALUE.has(flag)) { + return token.includes("=") ? "continue" : "consume-next"; + } + if (BSD_SCRIPT_FLAG_OPTIONS.has(flag)) { + return "continue"; + } + return "invalid"; + }, + adjustCommandIndex: (commandIndex, currentArgv) => { + let sawTranscript = false; + for (let idx = commandIndex; idx < currentArgv.length; idx += 1) { + const token = currentArgv[idx]?.trim() ?? ""; + if (!token) { + continue; + } + if (!sawTranscript) { + sawTranscript = true; + continue; + } + return idx; + } + return null; + }, + }); +} + function unwrapTimeoutInvocation(argv: string[]): string[] | null { return unwrapDashOptionInvocation(argv, { onFlag: (flag, lower) => { @@ -294,6 +337,7 @@ const DISPATCH_WRAPPER_SPECS: readonly DispatchWrapperSpec[] = [ { name: "ionice" }, { name: "nice", unwrap: unwrapNiceInvocation, transparentUsage: true }, { name: "nohup", unwrap: unwrapNohupInvocation, transparentUsage: true }, + { name: "script", unwrap: unwrapScriptInvocation, transparentUsage: true }, { name: "setsid" }, { name: "stdbuf", unwrap: unwrapStdbufInvocation, transparentUsage: true }, { name: "sudo" }, diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index 1359916d715..d2c7897f399 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -619,6 +619,23 @@ $0 \\"$1\\"" touch {marker}`, }); }); + it("prevents allow-always bypass for script wrapper chains", () => { + if (process.platform !== "darwin" && process.platform !== "freebsd") { + return; + } + const dir = makeTempDir(); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const env = makePathEnv(dir); + expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'echo warmup-ok'", + secondCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'id > marker'", + env, + persistedPattern: echo, + }); + }); + it("does not persist comment-tailed payload paths that never execute", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index f71ea551032..6323fc152d9 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -13,6 +13,10 @@ import { unwrapKnownShellMultiplexerInvocation, } from "./exec-wrapper-resolution.js"; +function supportsScriptPositionalCommandForTests(): boolean { + return process.platform === "darwin" || process.platform === "freebsd"; +} + describe("basenameLower", () => { test.each([ { token: " Bun.CMD ", expected: "bun.cmd" }, @@ -40,6 +44,7 @@ describe("normalizeExecutableToken", () => { describe("wrapper classification", () => { test.each([ { token: "sudo", dispatch: true, shell: false }, + { token: "script", dispatch: true, shell: false }, { token: "time", dispatch: true, shell: false }, { token: "timeout.exe", dispatch: true, shell: false }, { token: "bash", dispatch: false, shell: true }, @@ -119,6 +124,16 @@ describe("unwrapKnownDispatchWrapperInvocation", () => { argv: ["nohup", "--", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "nohup", argv: ["bash", "-lc", "echo hi"] }, }, + { + argv: ["script", "-q", "/dev/null", "bash", "-lc", "echo hi"], + expected: supportsScriptPositionalCommandForTests() + ? { kind: "unwrapped", wrapper: "script", argv: ["bash", "-lc", "echo hi"] } + : { kind: "blocked", wrapper: "script" }, + }, + { + argv: ["script", "-E", "always", "/dev/null", "bash", "-lc", "echo hi"], + expected: { kind: "blocked", wrapper: "script" }, + }, { argv: ["stdbuf", "-o", "L", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "stdbuf", argv: ["bash", "-lc", "echo hi"] }, @@ -131,6 +146,10 @@ describe("unwrapKnownDispatchWrapperInvocation", () => { argv: ["timeout", "--signal=TERM", "5s", "bash", "-lc", "echo hi"], expected: { kind: "unwrapped", wrapper: "timeout", argv: ["bash", "-lc", "echo hi"] }, }, + { + argv: ["script", "-q", "/dev/null"], + expected: { kind: "blocked", wrapper: "script" }, + }, { argv: ["sudo", "bash", "-lc", "echo hi"], expected: { kind: "blocked", wrapper: "sudo" }, diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts index 8cea75de5c9..d3d410178d8 100644 --- a/src/infra/exec-wrapper-trust-plan.test.ts +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -18,6 +18,22 @@ describe("resolveExecWrapperTrustPlan", () => { }); }); + test("unwraps script wrappers before evaluating nested shell payloads", () => { + if (process.platform !== "darwin" && process.platform !== "freebsd") { + return; + } + expect( + resolveExecWrapperTrustPlan(["/usr/bin/script", "-q", "/dev/null", "sh", "-lc", "echo hi"]), + ).toEqual({ + argv: ["sh", "-lc", "echo hi"], + policyArgv: ["sh", "-lc", "echo hi"], + wrapperChain: ["script"], + policyBlocked: false, + shellWrapperExecutable: true, + shellInlineCommand: "echo hi", + }); + }); + test("fails closed for unsupported shell multiplexer applets", () => { expect(resolveExecWrapperTrustPlan(["busybox", "sed", "-n", "1p"])).toEqual({ argv: ["busybox", "sed", "-n", "1p"],