mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
refactor: share command payload extraction
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user