diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 112ca29be6d..a3403869180 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { buildCommandPayloadCandidates } from "../infra/command-analysis/risks.js"; import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js"; import { type ExecAsk, @@ -16,7 +17,6 @@ import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, } from "../infra/shell-env.js"; -import { extractShellWrapperInlineCommand } from "../infra/shell-wrapper-resolution.js"; import { logInfo } from "../logger.js"; import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -1140,207 +1140,17 @@ function parseOpenClawChannelsLoginShellCommand(raw: string): boolean { } function rejectUnsafeControlShellCommand(command: string): void { - const isEnvAssignmentToken = (token: string): boolean => - /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token); - const commandStandaloneOptions = new Set(["-p", "-v", "-V"]); - const envOptionsWithValues = new Set([ - "-C", - "-S", - "-u", - "--argv0", - "--block-signal", - "--chdir", - "--default-signal", - "--ignore-signal", - "--split-string", - "--unset", - ]); - const execOptionsWithValues = new Set(["-a"]); - const execStandaloneOptions = new Set(["-c", "-l"]); - const sudoOptionsWithValues = new Set([ - "-C", - "-D", - "-g", - "-p", - "-R", - "-T", - "-U", - "-u", - "--chdir", - "--close-from", - "--group", - "--host", - "--other-user", - "--prompt", - "--role", - "--type", - "--user", - ]); - const sudoStandaloneOptions = new Set(["-A", "-E", "--askpass", "--preserve-env"]); - const extractEnvSplitStringPayload = (argv: string[]): string[] => { - const remaining = [...argv]; - while (remaining[0] && isEnvAssignmentToken(remaining[0])) { - remaining.shift(); - } - if (remaining[0] !== "env") { - return []; - } - remaining.shift(); - const payloads: string[] = []; - while (remaining.length > 0) { - while (remaining[0] && isEnvAssignmentToken(remaining[0])) { - remaining.shift(); - } - const token: string | undefined = remaining[0]; - if (!token) { - break; - } - if (token === "--") { - remaining.shift(); - continue; - } - if (!token.startsWith("-") || token === "-") { - break; - } - const option = remaining.shift()!; - const normalized = option.split("=", 1)[0]; - if (normalized === "-S" || normalized === "--split-string") { - const value = option.includes("=") - ? option.slice(option.indexOf("=") + 1) - : remaining.shift(); - if (value?.trim()) { - payloads.push(value); - } - continue; - } - if (envOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) { - remaining.shift(); - } - } - return payloads; - }; - const stripApprovalCommandPrefixes = (argv: string[]): string[] => { - const remaining = [...argv]; - while (remaining.length > 0) { - while (remaining[0] && isEnvAssignmentToken(remaining[0])) { - remaining.shift(); - } - - const token = remaining[0]; - if (!token) { - break; - } - if (token === "--") { - remaining.shift(); - continue; - } - if (token === "env") { - remaining.shift(); - while (remaining.length > 0) { - while (remaining[0] && isEnvAssignmentToken(remaining[0])) { - remaining.shift(); - } - const envToken = remaining[0]; - if (!envToken) { - break; - } - if (envToken === "--") { - remaining.shift(); - continue; - } - if (!envToken.startsWith("-") || envToken === "-") { - break; - } - const option = remaining.shift()!; - const normalized = option.split("=", 1)[0]; - if (envOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) { - remaining.shift(); - } - } - continue; - } - if (token === "command" || token === "builtin") { - remaining.shift(); - while (remaining[0]?.startsWith("-")) { - const option = remaining.shift()!; - if (option === "--") { - break; - } - if (!commandStandaloneOptions.has(option.split("=", 1)[0])) { - continue; - } - } - continue; - } - if (token === "exec") { - remaining.shift(); - while (remaining[0]?.startsWith("-")) { - const option = remaining.shift()!; - if (option === "--") { - break; - } - const normalized = option.split("=", 1)[0]; - if (execStandaloneOptions.has(normalized)) { - continue; - } - if (execOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) { - remaining.shift(); - } - } - continue; - } - if (token === "sudo") { - remaining.shift(); - while (remaining[0]?.startsWith("-")) { - const option = remaining.shift()!; - if (option === "--") { - break; - } - const normalized = option.split("=", 1)[0]; - if (sudoStandaloneOptions.has(normalized)) { - continue; - } - if (sudoOptionsWithValues.has(normalized) && !option.includes("=") && remaining[0]) { - remaining.shift(); - } - } - continue; - } - break; - } - return remaining; - }; - const buildCandidates = (argv: string[]): string[] => { - const envSplitCandidates = extractEnvSplitStringPayload(argv).flatMap((payload) => { - const innerArgv = splitShellArgs(payload); - return innerArgv ? buildCandidates(innerArgv) : [payload]; - }); - const stripped = stripApprovalCommandPrefixes(argv); - const shellWrapperPayload = extractShellWrapperInlineCommand(stripped); - const shellWrapperCandidates = shellWrapperPayload - ? (() => { - const innerArgv = splitShellArgs(shellWrapperPayload); - return innerArgv ? buildCandidates(innerArgv) : [shellWrapperPayload]; - })() - : []; - return [ - ...(stripped.length > 0 ? [stripped.join(" ")] : []), - ...envSplitCandidates, - ...shellWrapperCandidates, - ]; - }; - const rawCommand = command.trim(); const analysis = analyzeShellCommand({ command: rawCommand }); const candidates = analysis.ok - ? analysis.segments.flatMap((segment) => buildCandidates(segment.argv)) + ? analysis.segments.flatMap((segment) => buildCommandPayloadCandidates(segment.argv)) : rawCommand .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .flatMap((line) => { const argv = splitShellArgs(line); - return argv ? buildCandidates(argv) : [line]; + return argv ? buildCommandPayloadCandidates(argv) : [line]; }); for (const candidate of candidates) { if (parseExecApprovalShellCommand(candidate)) { diff --git a/src/infra/command-analysis/risks.test.ts b/src/infra/command-analysis/risks.test.ts index eea46e967f4..ae107e992d5 100644 --- a/src/infra/command-analysis/risks.test.ts +++ b/src/infra/command-analysis/risks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildCommandPayloadCandidates, detectCarriedShellBuiltinArgv, detectCommandCarrierArgv, detectEnvSplitStringFlag, @@ -72,6 +73,22 @@ describe("command-analysis risks", () => { expect(detectCarriedShellBuiltinArgv(["command", "echo", "eval"])).toBeNull(); }); + it("builds executable payload candidates through carriers and shell wrappers", () => { + expect(buildCommandPayloadCandidates(["FOO=1", "sudo", "-E", "/approve", "abc"])).toEqual([ + "/approve abc", + ]); + expect(buildCommandPayloadCandidates(["env", "-S", "bash -lc '/approve abc deny'"])).toEqual([ + "bash -lc /approve abc deny", + "/approve abc deny", + ]); + expect(buildCommandPayloadCandidates(["exec", "-a", "openclaw", "/approve", "abc"])).toEqual([ + "/approve abc", + ]); + expect(buildCommandPayloadCandidates(["command", "-v", "/approve"])).toEqual([ + "command -v /approve", + ]); + }); + it("checks both effective and original argv for segment inline eval", () => { const hit = detectInlineEvalInSegments([ { diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 6f48c57e37e..ca5c5b91e33 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -6,7 +6,10 @@ import { type InterpreterInlineEvalHit, } from "../exec-inline-eval.js"; import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; -import { isShellWrapperExecutable } from "../shell-wrapper-resolution.js"; +import { + extractShellWrapperInlineCommand, + isShellWrapperExecutable, +} from "../shell-wrapper-resolution.js"; export const COMMAND_CARRIER_EXECUTABLES = new Set(["sudo", "doas", "env", "command", "builtin"]); @@ -89,6 +92,8 @@ const SUDO_NON_EXEC_OPTIONS = new Set([ ]); const DOAS_OPTIONS_WITH_VALUE = new Set(["-a", "-C", "-u"]); const DOAS_STANDALONE_OPTIONS = new Set(["-L", "-n", "-s"]); +const EXEC_OPTIONS_WITH_VALUE = new Set(["-a"]); +const EXEC_STANDALONE_OPTIONS = new Set(["-c", "-l"]); function isEnvAssignmentToken(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token); @@ -220,7 +225,11 @@ function resolveSudoLikeCarriedArgv(argv: string[]): string[] | null { return null; } -function resolveCarrierCommandArgv(argv: string[], depth = 0): string[] | null { +export function resolveCarrierCommandArgv( + argv: string[], + depth = 0, + options?: { includeExec?: boolean }, +): string[] | null { if (depth > MAX_INLINE_EVAL_CARRIER_DEPTH) { return null; } @@ -234,11 +243,80 @@ function resolveCarrierCommandArgv(argv: string[], depth = 0): string[] | null { case "sudo": case "doas": return resolveSudoLikeCarriedArgv(argv); + case "exec": + return options?.includeExec ? resolveExecCarriedArgv(argv) : null; default: return null; } } +function resolveExecCarriedArgv(argv: string[]): string[] | null { + if (normalizeExecutableToken(argv[0] ?? "") !== "exec") { + return null; + } + for (let index = 1; index < argv.length; index += 1) { + const token = argv[index] ?? ""; + if (token === "--") { + return argv.slice(index + 1); + } + if (!token.startsWith("-")) { + return argv.slice(index); + } + const normalized = optionName(token); + if (EXEC_STANDALONE_OPTIONS.has(normalized)) { + continue; + } + if (EXEC_OPTIONS_WITH_VALUE.has(normalized)) { + if (!token.includes("=") && !hasInlineShortOptionValue(token)) { + index += 1; + } + continue; + } + return null; + } + return null; +} + +export function buildCommandPayloadCandidates(argv: string[], depth = 0): string[] { + if (depth > MAX_INLINE_EVAL_CARRIER_DEPTH) { + return argv.length > 0 ? [argv.join(" ")] : []; + } + const assignmentStrippedArgv = stripLeadingEnvAssignments(argv); + const carriedArgv = resolveCarrierCommandArgv(assignmentStrippedArgv, depth, { + includeExec: true, + }); + const executableArgv = carriedArgv ?? assignmentStrippedArgv; + const carriedCandidates = carriedArgv + ? buildCommandPayloadCandidates(carriedArgv, depth + 1) + : []; + const shellWrapperPayload = extractShellWrapperInlineCommand(executableArgv); + const shellWrapperCandidates = shellWrapperPayload + ? (() => { + const innerArgv = splitShellArgs(shellWrapperPayload); + return innerArgv + ? buildCommandPayloadCandidates(innerArgv, depth + 1) + : [shellWrapperPayload]; + })() + : []; + return uniqueCommandPayloadCandidates([ + ...(executableArgv.length > 0 ? [executableArgv.join(" ")] : []), + ...carriedCandidates, + ...shellWrapperCandidates, + ]); +} + +function stripLeadingEnvAssignments(argv: string[]): string[] { + let index = 0; + while (index < argv.length && isEnvAssignmentToken(argv[index] ?? "")) { + index += 1; + } + return index > 0 ? argv.slice(index) : argv; +} + +function uniqueCommandPayloadCandidates(candidates: string[]): string[] { + return [...new Set(candidates.filter((candidate) => candidate.trim().length > 0))]; +} + export function detectCarrierInlineEvalArgv( argv: string[], depth = 0,