fix(exec): detect exec carrier risks

This commit is contained in:
Vincent Koc
2026-05-03 21:59:06 -07:00
parent dcb3e64e2f
commit 1bf824f586
4 changed files with 40 additions and 6 deletions

View File

@@ -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.

View File

@@ -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();
});

View File

@@ -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<string>(),
@@ -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;
}

View File

@@ -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" }),