diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 70f2a432c91..7ada778c3e3 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -346,7 +346,12 @@ describe("exec host env validation", () => { "sudo /approve abc123 allow-once", "sudo -E /approve abc123 allow-once", "sudo -EH /approve abc123 allow-once", + "sudo -k /approve abc123 allow-once", + "sudo --reset-timestamp /approve abc123 allow-once", + "sudo --command-timeout=1 /approve abc123 allow-once", + "sudo OPENCLAW_APPROVE=1 /approve abc123 allow-once", "sudo -uroot bash -lc '/approve abc123 allow-once'", + "sudo -u root OPENCLAW_APPROVE=1 bash -lc '/approve abc123 allow-once'", "sudo -EH bash -lc '/approve abc123 allow-once'", "doas -uroot bash -lc '/approve abc123 deny'", "env env env env env env /approve abc123 allow-once", diff --git a/src/infra/command-analysis/risks.test.ts b/src/infra/command-analysis/risks.test.ts index 46be84e1fea..6ae653329e4 100644 --- a/src/infra/command-analysis/risks.test.ts +++ b/src/infra/command-analysis/risks.test.ts @@ -20,6 +20,26 @@ describe("command-analysis risks", () => { expect(detectInlineEvalArgv(["sudo", "-EH", "python3", "-c", "print(1)"])?.flag).toBe("-c"); expect(detectInlineEvalArgv(["sudo", "-i", "python3", "-c", "print(1)"])?.flag).toBe("-c"); expect(detectInlineEvalArgv(["sudo", "-s", "python3", "-c", "print(1)"])?.flag).toBe("-c"); + expect(detectInlineEvalArgv(["sudo", "-k", "python3", "-c", "print(1)"])?.flag).toBe("-c"); + expect( + detectInlineEvalArgv(["sudo", "--reset-timestamp", "python3", "-c", "print(1)"])?.flag, + ).toBe("-c"); + expect( + detectInlineEvalArgv(["sudo", "--command-timeout=1", "python3", "-c", "print(1)"])?.flag, + ).toBe("-c"); + expect(detectInlineEvalArgv(["sudo", "--chroot=/", "python3", "-c", "print(1)"])?.flag).toBe( + "-c", + ); + expect( + detectInlineEvalArgv(["sudo", "PYTHONPATH=/tmp", "python3", "-c", "print(1)"])?.flag, + ).toBe("-c"); + expect( + detectInlineEvalArgv(["sudo", "-u", "root", "PYTHONPATH=/tmp", "python3", "-c", "print(1)"]) + ?.flag, + ).toBe("-c"); + expect( + detectInlineEvalArgv(["sudo", "--", "PYTHONPATH=/tmp", "python3", "-c", "print(1)"])?.flag, + ).toBe("-c"); expect(detectInlineEvalArgv(["sudo", "--shell", "python3", "-c", "print(1)"])?.flag).toBe("-c"); expect(detectInlineEvalArgv(["sudo", "-Eu", "root", "python3", "-c", "print(1)"])?.flag).toBe( "-c", @@ -42,6 +62,9 @@ describe("command-analysis risks", () => { it("keeps carrier inline eval detection command-boundary aware", () => { expect(detectInlineEvalArgv(["command", "echo", "python3", "-c", "print(1)"])).toBeNull(); expect(detectInlineEvalArgv(["sudo", "echo", "python3", "-c", "print(1)"])).toBeNull(); + expect( + detectInlineEvalArgv(["sudo", "FOO=bar", "echo", "python3", "-c", "print(1)"]), + ).toBeNull(); expect(detectInlineEvalArgv(["env", "-S", 'echo python3 -c "print(1)"'])).toBeNull(); expect(detectInlineEvalArgv(["command", "-v", "python3", "-c", "print(1)"])).toBeNull(); }); @@ -115,6 +138,18 @@ describe("command-analysis risks", () => { expect(buildCommandPayloadCandidates(["sudo", "-s", "/approve", "abc"])).toEqual([ "/approve abc", ]); + expect(buildCommandPayloadCandidates(["sudo", "-k", "/approve", "abc"])).toEqual([ + "/approve abc", + ]); + expect(buildCommandPayloadCandidates(["sudo", "--reset-timestamp", "/approve", "abc"])).toEqual( + ["/approve abc"], + ); + expect( + buildCommandPayloadCandidates(["sudo", "--command-timeout=1", "/approve", "abc"]), + ).toEqual(["/approve abc"]); + expect(buildCommandPayloadCandidates(["sudo", "OPENCLAW_ENV=1", "/approve", "abc"])).toEqual([ + "/approve abc", + ]); expect(buildCommandPayloadCandidates(["sudo", "--shell", "/approve", "abc"])).toEqual([ "/approve abc", ]); diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 9ed3bb48816..829ea85228a 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -74,22 +74,23 @@ function detectCarrierInlineEvalArgvInternal( argv: string[], seenArgv: Set, ): InterpreterInlineEvalHit | null { - const key = commandArgvKey(argv); + const executableArgv = stripLeadingEnvAssignments(argv); + const key = commandArgvKey(executableArgv); if (seenArgv.has(key)) { return null; } seenArgv.add(key); - const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(argv); + const dispatchUnwrap = unwrapKnownDispatchWrapperInvocation(executableArgv); if (dispatchUnwrap.kind === "unwrapped") { return detectInlineEvalArgvInternal(dispatchUnwrap.argv, seenArgv); } - const executable = normalizeExecutableToken(argv[0] ?? ""); + const executable = normalizeExecutableToken(executableArgv[0] ?? ""); if (!COMMAND_CARRIER_EXECUTABLES.has(executable)) { return null; } - const carriedArgv = resolveCarrierCommandArgv(argv); + const carriedArgv = resolveCarrierCommandArgv(executableArgv); if (!carriedArgv) { return null; } diff --git a/src/infra/command-carriers.ts b/src/infra/command-carriers.ts index 64c0bfe1a21..fa5439c9650 100644 --- a/src/infra/command-carriers.ts +++ b/src/infra/command-carriers.ts @@ -35,7 +35,9 @@ const SUDO_OPTIONS_WITH_VALUE = new Set([ "-U", "-u", "--chdir", + "--chroot", "--close-from", + "--command-timeout", "--group", "--host", "--other-user", @@ -51,6 +53,7 @@ const SUDO_STANDALONE_OPTIONS = new Set([ "-E", "-H", "-i", + "-k", "-N", "-n", "-P", @@ -65,13 +68,13 @@ const SUDO_STANDALONE_OPTIONS = new Set([ "--preserve-env", "--preserve-groups", "--reset-home", + "--reset-timestamp", "--set-home", "--shell", "--stdin", ]); const SUDO_NON_EXEC_OPTIONS = new Set([ "-K", - "-k", "-l", "-V", "-v", @@ -80,7 +83,6 @@ const SUDO_NON_EXEC_OPTIONS = new Set([ "--help", "--list", "--remove-timestamp", - "--reset-timestamp", "--validate", "--version", ]); @@ -169,6 +171,20 @@ function knownCarrierOptionConsumesNextValue( return consumesNextValue; } +function stripSudoEnvAssignmentsFromCommandArgv( + executable: string, + argv: string[], +): string[] | null { + if (executable !== "sudo") { + return argv.length > 0 ? argv : null; + } + let index = 0; + while (index < argv.length && isEnvAssignmentToken(argv[index] ?? "")) { + index += 1; + } + return index < argv.length ? argv.slice(index) : null; +} + function findParsedCarrierOption( options: readonly ParsedCarrierOption[], names: ReadonlySet, @@ -317,10 +333,10 @@ function resolveSudoLikeCarriedArgv(argv: string[]): string[] | null { for (let index = 1; index < argv.length; index += 1) { const token = argv[index] ?? ""; if (token === "--") { - return argv.slice(index + 1); + return stripSudoEnvAssignmentsFromCommandArgv(executable, argv.slice(index + 1)); } if (!token.startsWith("-")) { - return argv.slice(index); + return stripSudoEnvAssignmentsFromCommandArgv(executable, argv.slice(index)); } const option = parseCarrierOptionToken( token,