mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
2628fae0da
* fix: recognize more shell wrappers * fix: cover windows shell wrapper aliases * fix: limit shell rewrite parsing set * fix: recognize yash cmdline option * fix: fail closed for opaque shell rewrites * fix: recognize nushell execute mode * fix: fail opaque shell allowlist matches closed * fix: suppress opaque shell positional previews * fix: parse nushell value options before commands * fix: block opaque shell script fallbacks * fix: keep nushell startup configs bound * fix: preserve plus-option shell script binding * fix: fail opaque shell fallback paths closed * fix: reject shell inline flags in script fallback * fix: block nushell startup option binding * fix: fail opaque system run transports closed * fix: deny hidden opaque shell approvals * fix: keep startup shell payloads opaque * fix: keep osh shell payloads opaque * fix: use analysis result in allowlist test * fix: parse nushell attached command flags * fix: keep nushell attached parsing local
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { expectDefined } from "@openclaw/normalization-core";
|
|
/** Resolves system.run allowlist matches, argv plans, and truncated command output. */
|
|
import {
|
|
analyzeArgvCommand,
|
|
evaluateExecAllowlist,
|
|
evaluateShellAllowlistWithAuthorization,
|
|
resolvePlannedSegmentArgv,
|
|
type ExecAllowlistEntry,
|
|
type ExecApprovalsResolved,
|
|
type ExecCommandSegment,
|
|
type ExecSegmentSatisfiedBy,
|
|
type ExecSecurity,
|
|
type SkillBinTrustEntry,
|
|
} from "../infra/exec-approvals.js";
|
|
import type { ExecAuthorizationPlan } from "../infra/exec-authorization-plan.js";
|
|
import { buildAuthorizedShellCommandFromPlan } from "../infra/exec-authorization-render.js";
|
|
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
|
import {
|
|
normalizeExecutableToken,
|
|
POSIX_PARSEABLE_SHELL_WRAPPERS,
|
|
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";
|
|
|
|
/**
|
|
* Allowlist analysis and argv rewriting for node-host system.run.
|
|
*
|
|
* This module keeps command approval analysis separate from process execution,
|
|
* and only rewrites shell transports when the rebuilt command still satisfies policy.
|
|
*/
|
|
const POSIX_PARSEABLE_SHELL_WRAPPER_NAMES: ReadonlySet<string> = POSIX_PARSEABLE_SHELL_WRAPPERS;
|
|
const POSIX_SHELL_WRAPPER_NAMES: ReadonlySet<string> = POSIX_SHELL_WRAPPERS;
|
|
|
|
type SystemRunAllowlistAnalysis = {
|
|
analysisOk: boolean;
|
|
allowlistMatches: ExecAllowlistEntry[];
|
|
allowlistSatisfied: boolean;
|
|
allowlistAuthorizationSatisfied: boolean;
|
|
segments: ExecCommandSegment[];
|
|
segmentAllowlistEntries: Array<ExecAllowlistEntry | null>;
|
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
|
authorizationPlan?: ExecAuthorizationPlan;
|
|
};
|
|
|
|
/** Evaluates analyzed command segments against allowlist and trusted safe-bin policy. */
|
|
export async function evaluateSystemRunAllowlist(params: {
|
|
shellCommand: string | null;
|
|
argv: string[];
|
|
approvals: ExecApprovalsResolved;
|
|
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;
|
|
}): Promise<SystemRunAllowlistAnalysis> {
|
|
if (params.shellCommand) {
|
|
const allowlistEval = await evaluateShellAllowlistWithAuthorization({
|
|
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,
|
|
allowlistAuthorizationSatisfied: allowlistEval.analysisOk && allowlistEval.allowlistSatisfied,
|
|
segments: allowlistEval.segments,
|
|
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
|
|
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
|
|
...(allowlistEval.authorizationPlan
|
|
? { authorizationPlan: allowlistEval.authorizationPlan }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
allowlistAuthorizationSatisfied: analysis.ok && allowlistEval.allowlistSatisfied,
|
|
segments: analysis.segments,
|
|
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
|
|
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
|
|
};
|
|
}
|
|
|
|
/** Resolve the single planned argv that can replace the caller argv after allowlist approval. */
|
|
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(
|
|
expectDefined(params.segments[0], "segments entry at 0"),
|
|
);
|
|
return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null;
|
|
}
|
|
|
|
/** Resolve final argv after safe-bin shell rewriting. */
|
|
export async function resolveSystemRunExecArgv(params: {
|
|
plannedAllowlistArgv: string[] | undefined;
|
|
argv: string[];
|
|
security: ExecSecurity;
|
|
approvals: ExecApprovalsResolved;
|
|
safeBins: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBins"];
|
|
safeBinProfiles: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBinProfiles"];
|
|
trustedSafeBinDirs: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["trustedSafeBinDirs"];
|
|
skillBins: SkillBinTrustEntry[];
|
|
autoAllowSkills: boolean;
|
|
isWindows: boolean;
|
|
policy: {
|
|
approvedByAsk: boolean;
|
|
analysisOk: boolean;
|
|
allowlistSatisfied: boolean;
|
|
};
|
|
shellCommand: string | null;
|
|
segments: ExecCommandSegment[];
|
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
|
authorizationPlan: ExecAuthorizationPlan | undefined;
|
|
cwd: string | undefined;
|
|
env: Record<string, string> | undefined;
|
|
}): Promise<string[] | null> {
|
|
let execArgv = params.plannedAllowlistArgv ?? params.argv;
|
|
const transportKind = params.shellCommand
|
|
? resolvePosixShellInlineCommandTransportKind(params.argv)
|
|
: "none";
|
|
if (
|
|
params.security === "allowlist" &&
|
|
!params.policy.approvedByAsk &&
|
|
params.shellCommand &&
|
|
params.policy.analysisOk &&
|
|
params.policy.allowlistSatisfied &&
|
|
transportKind === "opaque"
|
|
) {
|
|
return null;
|
|
}
|
|
if (
|
|
params.security === "allowlist" &&
|
|
params.isWindows &&
|
|
!params.policy.approvedByAsk &&
|
|
params.shellCommand &&
|
|
params.policy.analysisOk &&
|
|
params.policy.allowlistSatisfied &&
|
|
params.segments.length === 1
|
|
) {
|
|
// Exact-path matches stay bound to the resolved executable, while the bare
|
|
// wildcard contract can still authorize unresolved Windows commands.
|
|
const plannedArgv = resolvePlannedSegmentArgv(
|
|
expectDefined(params.segments[0], "segments entry at 0"),
|
|
);
|
|
if (!plannedArgv) {
|
|
return null;
|
|
}
|
|
execArgv = plannedArgv;
|
|
}
|
|
if (
|
|
params.security === "allowlist" &&
|
|
!params.isWindows &&
|
|
!params.policy.approvedByAsk &&
|
|
params.shellCommand &&
|
|
params.policy.analysisOk &&
|
|
params.policy.allowlistSatisfied
|
|
) {
|
|
if (
|
|
transportKind !== "parseable" ||
|
|
!params.segmentSatisfiedBy.some((entry) => entry === "safeBins" || entry === "inlineChain")
|
|
) {
|
|
return execArgv;
|
|
}
|
|
if (!params.authorizationPlan) {
|
|
return null;
|
|
}
|
|
const rebuilt = buildAuthorizedShellCommandFromPlan({
|
|
plan: params.authorizationPlan,
|
|
mode: "safeBins",
|
|
segmentSatisfiedBy: params.segmentSatisfiedBy,
|
|
});
|
|
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 resolvePosixShellInlineCommandTransportKind(
|
|
argv: string[],
|
|
): "none" | "opaque" | "parseable" {
|
|
const transportArgv = resolveShellWrapperTransportArgv(argv);
|
|
if (!transportArgv) {
|
|
return "none";
|
|
}
|
|
const executable = normalizeExecutableToken(transportArgv[0] ?? "");
|
|
if (!POSIX_SHELL_WRAPPER_NAMES.has(executable)) {
|
|
return "none";
|
|
}
|
|
return POSIX_PARSEABLE_SHELL_WRAPPER_NAMES.has(executable) ? "parseable" : "opaque";
|
|
}
|
|
|
|
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_PARSEABLE_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)) {
|
|
// Combined shell flags can leave the inline command in a suffix of the same argv token.
|
|
rewritten[absoluteValueIndex] =
|
|
token.slice(0, token.length - params.oldCommand.length) + params.nextCommand;
|
|
return rewritten;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Mark truncated output in stderr when possible, otherwise stdout. */
|
|
/** Truncates captured stdout/stderr in place to the node-host output cap. */
|
|
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}`;
|
|
}
|
|
}
|