diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index a0ba77ecb6b..114bf002c65 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -318,6 +318,32 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).not.toContain("/usr/bin/nice"); }); + it("unwraps time 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: "/usr/bin/time -p /bin/zsh -lc whoami", + argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"], + resolution: { + rawExecutable: "/usr/bin/time", + resolvedPath: "/usr/bin/time", + executableName: "time", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain("/usr/bin/time"); + }); + it("unwraps busybox/toybox shell applets and persists inner executables", () => { if (process.platform === "win32") { return; @@ -425,6 +451,23 @@ describe("resolveAllowAlwaysPatterns", () => { }); }); + it("prevents allow-always bypass for time wrapper chains", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const env = makePathEnv(dir); + expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "/usr/bin/time -p /bin/zsh -lc 'echo warmup-ok'", + secondCommand: "/usr/bin/time -p /bin/zsh -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-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 4bdff0947a9..76f5ab6c99d 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -144,6 +144,14 @@ describe("exec-command-resolution", () => { ]); expect(niceResolution?.rawExecutable).toBe("bash"); expect(niceResolution?.executableName.toLowerCase()).toContain("bash"); + + const timeResolution = resolveCommandResolutionFromArgv( + ["/usr/bin/time", "-p", "rg", "-n", "needle"], + undefined, + makePathEnv(fixture.binDir), + ); + expect(timeResolution?.resolvedPath).toBe(fixture.exePath); + expect(timeResolution?.executableName).toBe(fixture.exeName); }); it("blocks semantic env wrappers, env -S, and deep transparent-wrapper chains", () => { diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 0cb423a11b3..3b47e5f349c 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -24,6 +24,7 @@ const DISPATCH_WRAPPER_NAMES = [ "stdbuf", "sudo", "taskset", + "time", "timeout", ] as const; @@ -86,9 +87,24 @@ const ENV_INLINE_VALUE_PREFIXES = [ const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]); const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]); +const TIME_FLAG_OPTIONS = new Set([ + "-a", + "--append", + "-h", + "--help", + "-l", + "-p", + "-q", + "--quiet", + "-v", + "--verbose", + "-V", + "--version", +]); +const TIME_OPTIONS_WITH_VALUE = new Set(["-f", "--format", "-o", "--output"]); const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); -const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "timeout"]); +const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "time", "timeout"]); type ShellWrapperKind = "posix" | "cmd" | "powershell"; @@ -371,6 +387,20 @@ function unwrapStdbufInvocation(argv: string[]): string[] | null { }); } +function unwrapTimeInvocation(argv: string[]): string[] | null { + return unwrapDashOptionInvocation(argv, { + onFlag: (flag, lower) => { + if (TIME_FLAG_OPTIONS.has(flag)) { + return "continue"; + } + if (TIME_OPTIONS_WITH_VALUE.has(flag)) { + return lower.includes("=") ? "continue" : "consume-next"; + } + return "invalid"; + }, + }); +} + function unwrapTimeoutInvocation(argv: string[]): string[] | null { return unwrapDashOptionInvocation(argv, { onFlag: (flag, lower) => { @@ -430,6 +460,8 @@ export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWr return unwrapDispatchWrapper(wrapper, unwrapNohupInvocation(argv)); case "stdbuf": return unwrapDispatchWrapper(wrapper, unwrapStdbufInvocation(argv)); + case "time": + return unwrapDispatchWrapper(wrapper, unwrapTimeInvocation(argv)); case "timeout": return unwrapDispatchWrapper(wrapper, unwrapTimeoutInvocation(argv)); case "chrt":