Files
openclaw/src/node-host/invoke-system-run-allowlist.ts
Pavan Kumar Gondhi 9ac4272b35 fix: harden safe-bin argument validation [AI] (#80999)
* fix: reject shell expansion in safe-bin tokens

* fix: complete safe-bin shell payload handling

* addressing codex review

* addressing ci

* addressing ci

* addressing codex review

* docs: add changelog entry for PR merge
2026-05-12 20:37:58 +05:30

263 lines
8.0 KiB
TypeScript

import {
analyzeArgvCommand,
buildSafeBinsShellCommand,
evaluateExecAllowlist,
evaluateShellAllowlist,
resolvePlannedSegmentArgv,
resolveExecApprovals,
type ExecAllowlistEntry,
type ExecCommandSegment,
type ExecSegmentSatisfiedBy,
type ExecSecurity,
type SkillBinTrustEntry,
} from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import {
normalizeExecutableToken,
POSIX_SHELL_WRAPPERS,
resolveShellWrapperTransportArgv,
} from "../infra/exec-wrapper-resolution.js";
import {
POSIX_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
} from "../infra/shell-inline-command.js";
import type { RunResult } from "./invoke-types.js";
const POSIX_SHELL_WRAPPER_NAMES: ReadonlySet<string> = POSIX_SHELL_WRAPPERS;
type SystemRunAllowlistAnalysis = {
analysisOk: boolean;
allowlistMatches: ExecAllowlistEntry[];
allowlistSatisfied: boolean;
segments: ExecCommandSegment[];
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
};
export function evaluateSystemRunAllowlist(params: {
shellCommand: string | null;
argv: string[];
approvals: ReturnType<typeof resolveExecApprovals>;
security: ExecSecurity;
safeBins: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBins"];
safeBinProfiles: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBinProfiles"];
trustedSafeBinDirs: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["trustedSafeBinDirs"];
cwd: string | undefined;
env: Record<string, string> | undefined;
skillBins: SkillBinTrustEntry[];
autoAllowSkills: boolean;
}): SystemRunAllowlistAnalysis {
if (params.shellCommand) {
const allowlistEval = evaluateShellAllowlist({
command: params.shellCommand,
allowlist: params.approvals.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
env: params.env,
trustedSafeBinDirs: params.trustedSafeBinDirs,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
platform: process.platform,
});
return {
analysisOk: allowlistEval.analysisOk,
allowlistMatches: allowlistEval.allowlistMatches,
allowlistSatisfied:
params.security === "allowlist" && allowlistEval.analysisOk
? allowlistEval.allowlistSatisfied
: false,
segments: allowlistEval.segments,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
};
}
const analysis = analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env });
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: params.approvals.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
trustedSafeBinDirs: params.trustedSafeBinDirs,
skillBins: params.skillBins,
autoAllowSkills: params.autoAllowSkills,
});
return {
analysisOk: analysis.ok,
allowlistMatches: allowlistEval.allowlistMatches,
allowlistSatisfied:
params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false,
segments: analysis.segments,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
};
}
export function resolvePlannedAllowlistArgv(params: {
security: ExecSecurity;
shellCommand: string | null;
policy: {
approvedByAsk: boolean;
analysisOk: boolean;
allowlistSatisfied: boolean;
};
segments: ExecCommandSegment[];
}): string[] | undefined | null {
if (
params.security !== "allowlist" ||
params.policy.approvedByAsk ||
params.shellCommand ||
!params.policy.analysisOk ||
!params.policy.allowlistSatisfied ||
params.segments.length !== 1
) {
return undefined;
}
const plannedAllowlistArgv = resolvePlannedSegmentArgv(params.segments[0]);
return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null;
}
export function resolveSystemRunExecArgv(params: {
plannedAllowlistArgv: string[] | undefined;
argv: string[];
security: ExecSecurity;
isWindows: boolean;
policy: {
approvedByAsk: boolean;
analysisOk: boolean;
allowlistSatisfied: boolean;
};
shellCommand: string | null;
segments: ExecCommandSegment[];
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
cwd: string | undefined;
env: Record<string, string> | undefined;
}): string[] | null {
let execArgv = params.plannedAllowlistArgv ?? params.argv;
if (
params.security === "allowlist" &&
params.isWindows &&
!params.policy.approvedByAsk &&
params.shellCommand &&
params.policy.analysisOk &&
params.policy.allowlistSatisfied &&
params.segments.length === 1 &&
params.segments[0]?.argv.length > 0
) {
execArgv = params.segments[0].argv;
}
if (
params.security === "allowlist" &&
!params.isWindows &&
!params.policy.approvedByAsk &&
params.shellCommand &&
params.policy.analysisOk &&
params.policy.allowlistSatisfied &&
params.segmentSatisfiedBy.some((entry) => entry === "safeBins" || entry === "inlineChain") &&
isPosixShellInlineCommandTransport(params.argv)
) {
const rebuilt = buildSafeBinsShellCommand({
command: params.shellCommand,
segments: params.segments,
segmentSatisfiedBy: params.segmentSatisfiedBy,
cwd: params.cwd,
env: params.env,
platform: process.platform,
});
if (!rebuilt.ok || !rebuilt.command) {
return null;
}
const rewrittenArgv = replacePosixShellInlineCommand({
argv: params.argv,
oldCommand: params.shellCommand,
nextCommand: rebuilt.command,
});
if (!rewrittenArgv) {
return null;
}
execArgv = rewrittenArgv;
}
return execArgv;
}
function isPosixShellInlineCommandTransport(argv: string[]): boolean {
const transportArgv = resolveShellWrapperTransportArgv(argv);
return Boolean(
transportArgv &&
POSIX_SHELL_WRAPPER_NAMES.has(normalizeExecutableToken(transportArgv[0] ?? "")),
);
}
function findSubsequence(haystack: readonly string[], needle: readonly string[]): number {
if (needle.length === 0 || needle.length > haystack.length) {
return -1;
}
for (let start = 0; start <= haystack.length - needle.length; start += 1) {
let matches = true;
for (let offset = 0; offset < needle.length; offset += 1) {
if (haystack[start + offset] !== needle[offset]) {
matches = false;
break;
}
}
if (matches) {
return start;
}
}
return -1;
}
function replacePosixShellInlineCommand(params: {
argv: string[];
oldCommand: string;
nextCommand: string;
}): string[] | null {
const transportArgv = resolveShellWrapperTransportArgv(params.argv);
if (
!transportArgv ||
!POSIX_SHELL_WRAPPER_NAMES.has(normalizeExecutableToken(transportArgv[0] ?? ""))
) {
return null;
}
const transportStart = findSubsequence(params.argv, transportArgv);
if (transportStart < 0) {
return null;
}
const match = resolveInlineCommandMatch(transportArgv, POSIX_INLINE_COMMAND_FLAGS, {
allowCombinedC: true,
});
if (match.valueTokenIndex === null) {
return null;
}
const absoluteValueIndex = transportStart + match.valueTokenIndex;
const token = params.argv[absoluteValueIndex];
if (token === undefined) {
return null;
}
const rewritten = [...params.argv];
if (token === params.oldCommand) {
rewritten[absoluteValueIndex] = params.nextCommand;
return rewritten;
}
if (token.endsWith(params.oldCommand)) {
rewritten[absoluteValueIndex] =
token.slice(0, token.length - params.oldCommand.length) + params.nextCommand;
return rewritten;
}
return null;
}
export function applyOutputTruncation(result: RunResult): void {
if (!result.truncated) {
return;
}
const suffix = "... (truncated)";
if (result.stderr.trim().length > 0) {
result.stderr = `${result.stderr}\n${suffix}`;
} else {
result.stdout = `${result.stdout}\n${suffix}`;
}
}