diff --git a/CHANGELOG.md b/CHANGELOG.md index 5817753129e..f032e5a2bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Exec approvals: treat POSIX `exec` as a command carrier for inline eval, shell-wrapper, and eval/source detection, so approval explanations and command-risk checks do not miss payloads hidden behind `exec`. Thanks @vincentkoc. - Diagnostics: handle missing session-tail files in cron recovery context without tripping extension test typecheck. Thanks @vincentkoc. - QA/Slack: update the Slack dispatch preview fallback test SDK mock for structured progress draft helpers, so the rich progress draft regression suite covers the new imports instead of failing before assertions run. Thanks @vincentkoc. - Plugins/loader: keep bundled plugin package `test-api.js` aliases behind private QA mode, so source transforms do not expose test-only public surfaces during normal plugin loading. Thanks @vincentkoc. diff --git a/src/infra/command-analysis/risks.test.ts b/src/infra/command-analysis/risks.test.ts index 5855ef2dc9e..b9e4515e2eb 100644 --- a/src/infra/command-analysis/risks.test.ts +++ b/src/infra/command-analysis/risks.test.ts @@ -57,6 +57,10 @@ describe("command-analysis risks", () => { expect(detectInlineEvalArgv(["env", "-P", "/usr/bin", "python3", "-c", "print(1)"])?.flag).toBe( "-c", ); + expect(detectInlineEvalArgv(["exec", "python3", "-c", "print(1)"])?.flag).toBe("-c"); + expect(detectInlineEvalArgv(["exec", "-a", "py", "python3", "-c", "print(1)"])?.flag).toBe( + "-c", + ); expect(detectInlineEvalArgv(["command", "node", "--eval", "1"])?.flag).toBe("--eval"); expect(detectInlineEvalArgv(["env", "-S", 'python3 -c "print(1)"'])?.flag).toBe("-c"); expect(detectInlineEvalArgv(["python3", "script.py"])).toBeNull(); @@ -109,6 +113,12 @@ describe("command-analysis risks", () => { (argv, startIndex) => argv[startIndex] === "-lc", ), ).toBe("sudo"); + expect( + detectShellWrapperThroughCarrierArgv( + ["exec", "bash", "-lc", "id"], + (argv, startIndex) => argv[startIndex] === "-lc", + ), + ).toBe("exec"); expect( detectShellWrapperThroughCarrierArgv( ["sudo", "echo", "bash", "-lc", "id"], @@ -125,6 +135,13 @@ describe("command-analysis risks", () => { kind: "source", command: "source", }); + expect(detectCarriedShellBuiltinArgv(["exec", "eval", "echo hi"])).toEqual({ + kind: "eval", + }); + expect(detectCarriedShellBuiltinArgv(["exec", "source", "./env.sh"])).toEqual({ + kind: "source", + command: "source", + }); expect(detectCarriedShellBuiltinArgv(["command", "echo", "eval"])).toBeNull(); }); diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 829ea85228a..3bea90a9992 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -27,6 +27,13 @@ function commandArgvKey(argv: readonly string[]): string { return argv.join("\0"); } +function isCommandCarrierExecutable(executable: string, options?: { includeExec?: boolean }) { + return ( + COMMAND_CARRIER_EXECUTABLES.has(executable) || + Boolean(options?.includeExec && executable === "exec") + ); +} + export function buildCommandPayloadCandidates( argv: string[], seenArgv = new Set(), @@ -87,10 +94,10 @@ function detectCarrierInlineEvalArgvInternal( } const executable = normalizeExecutableToken(executableArgv[0] ?? ""); - if (!COMMAND_CARRIER_EXECUTABLES.has(executable)) { + if (!isCommandCarrierExecutable(executable, { includeExec: true })) { return null; } - const carriedArgv = resolveCarrierCommandArgv(executableArgv); + const carriedArgv = resolveCarrierCommandArgv(executableArgv, 0, { includeExec: true }); if (!carriedArgv) { return null; } @@ -179,10 +186,10 @@ export function detectShellWrapperThroughCarrierArgv( shellCommandFlag: (argv: string[], startIndex: number) => unknown, ): string | null { const executable = normalizeExecutableToken(argv[0] ?? ""); - if (!COMMAND_CARRIER_EXECUTABLES.has(executable)) { + if (!isCommandCarrierExecutable(executable, { includeExec: true })) { return null; } - const carriedArgv = resolveCarrierCommandArgv(argv); + const carriedArgv = resolveCarrierCommandArgv(argv, 0, { includeExec: true }); if (!carriedArgv) { return null; } @@ -194,10 +201,10 @@ export function detectShellWrapperThroughCarrierArgv( export function detectCarriedShellBuiltinArgv(argv: string[]): CarriedShellBuiltinHit | null { const executable = normalizeExecutableToken(argv[0] ?? ""); - if (!COMMAND_CARRIER_EXECUTABLES.has(executable)) { + if (!isCommandCarrierExecutable(executable, { includeExec: true })) { return null; } - const carriedArgv = resolveCarrierCommandArgv(argv); + const carriedArgv = resolveCarrierCommandArgv(argv, 0, { includeExec: true }); if (!carriedArgv) { return null; } diff --git a/src/infra/command-explainer/extract.test.ts b/src/infra/command-explainer/extract.test.ts index 6cfc2fa3532..2d3ba2b6442 100644 --- a/src/infra/command-explainer/extract.test.ts +++ b/src/infra/command-explainer/extract.test.ts @@ -607,6 +607,7 @@ describe("command explainer tree-sitter runtime", () => { 'env python -c "print(1)"', 'sudo python -c "print(1)"', 'command python -c "print(1)"', + 'exec python -c "print(1)"', ]) { const explanation = await explainShellCommand(command); expect(explanation.risks).toContainEqual( @@ -644,6 +645,14 @@ describe("command explainer tree-sitter runtime", () => { expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "command" }), ); + const execShell = await explainShellCommand("exec bash -lc 'id && whoami'"); + expect(execShell.risks).toContainEqual( + expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "exec" }), + ); + + const execEval = await explainShellCommand("exec eval 'echo hi'"); + expect(execEval.risks).toContainEqual(expect.objectContaining({ kind: "eval" })); + const sudoCombinedFlags = await explainShellCommand('sudo bash -euxc "id && whoami"'); expect(sudoCombinedFlags.risks).toContainEqual( expect.objectContaining({ kind: "shell-wrapper-through-carrier", command: "sudo" }),