mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:54:33 +00:00
refactor(exec-approvals): split allowlist evaluation module
This commit is contained in:
296
src/infra/exec-approvals-allowlist.ts
Normal file
296
src/infra/exec-approvals-allowlist.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||
import {
|
||||
DEFAULT_SAFE_BINS,
|
||||
analyzeShellCommand,
|
||||
isWindowsPlatform,
|
||||
matchAllowlist,
|
||||
resolveAllowlistCandidatePath,
|
||||
splitCommandChain,
|
||||
type ExecCommandAnalysis,
|
||||
type CommandResolution,
|
||||
type ExecCommandSegment,
|
||||
} from "./exec-approvals-analysis.js";
|
||||
|
||||
function isPathLikeToken(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed === "-") {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
return /^[A-Za-z]:[\\/]/.test(trimmed);
|
||||
}
|
||||
|
||||
function defaultFileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSafeBins(entries?: string[]): Set<string> {
|
||||
if (!Array.isArray(entries)) {
|
||||
return new Set();
|
||||
}
|
||||
const normalized = entries
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return new Set(normalized);
|
||||
}
|
||||
|
||||
export function resolveSafeBins(entries?: string[] | null): Set<string> {
|
||||
if (entries === undefined) {
|
||||
return normalizeSafeBins(DEFAULT_SAFE_BINS);
|
||||
}
|
||||
return normalizeSafeBins(entries ?? []);
|
||||
}
|
||||
|
||||
export function isSafeBinUsage(params: {
|
||||
argv: string[];
|
||||
resolution: CommandResolution | null;
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
fileExists?: (filePath: string) => boolean;
|
||||
}): boolean {
|
||||
if (params.safeBins.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const resolution = params.resolution;
|
||||
const execName = resolution?.executableName?.toLowerCase();
|
||||
if (!execName) {
|
||||
return false;
|
||||
}
|
||||
const matchesSafeBin =
|
||||
params.safeBins.has(execName) ||
|
||||
(process.platform === "win32" && params.safeBins.has(path.parse(execName).name));
|
||||
if (!matchesSafeBin) {
|
||||
return false;
|
||||
}
|
||||
if (!resolution?.resolvedPath) {
|
||||
return false;
|
||||
}
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const exists = params.fileExists ?? defaultFileExists;
|
||||
const argv = params.argv.slice(1);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token === "-") {
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-")) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
if (eqIndex > 0) {
|
||||
const value = token.slice(eqIndex + 1);
|
||||
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isPathLikeToken(token)) {
|
||||
return false;
|
||||
}
|
||||
if (exists(path.resolve(cwd, token))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ExecAllowlistEvaluation = {
|
||||
allowlistSatisfied: boolean;
|
||||
allowlistMatches: ExecAllowlistEntry[];
|
||||
};
|
||||
|
||||
function evaluateSegments(
|
||||
segments: ExecCommandSegment[],
|
||||
params: {
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
skillBins?: Set<string>;
|
||||
autoAllowSkills?: boolean;
|
||||
},
|
||||
): { satisfied: boolean; matches: ExecAllowlistEntry[] } {
|
||||
const matches: ExecAllowlistEntry[] = [];
|
||||
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
|
||||
|
||||
const satisfied = segments.every((segment) => {
|
||||
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
|
||||
const candidateResolution =
|
||||
candidatePath && segment.resolution
|
||||
? { ...segment.resolution, resolvedPath: candidatePath }
|
||||
: segment.resolution;
|
||||
const match = matchAllowlist(params.allowlist, candidateResolution);
|
||||
if (match) {
|
||||
matches.push(match);
|
||||
}
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const skillAllow =
|
||||
allowSkills && segment.resolution?.executableName
|
||||
? params.skillBins?.has(segment.resolution.executableName)
|
||||
: false;
|
||||
return Boolean(match || safe || skillAllow);
|
||||
});
|
||||
|
||||
return { satisfied, matches };
|
||||
}
|
||||
|
||||
export function evaluateExecAllowlist(params: {
|
||||
analysis: ExecCommandAnalysis;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
skillBins?: Set<string>;
|
||||
autoAllowSkills?: boolean;
|
||||
}): ExecAllowlistEvaluation {
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
if (!params.analysis.ok || params.analysis.segments.length === 0) {
|
||||
return { allowlistSatisfied: false, allowlistMatches };
|
||||
}
|
||||
|
||||
// If the analysis contains chains, evaluate each chain part separately
|
||||
if (params.analysis.chains) {
|
||||
for (const chainSegments of params.analysis.chains) {
|
||||
const result = evaluateSegments(chainSegments, {
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
skillBins: params.skillBins,
|
||||
autoAllowSkills: params.autoAllowSkills,
|
||||
});
|
||||
if (!result.satisfied) {
|
||||
return { allowlistSatisfied: false, allowlistMatches: [] };
|
||||
}
|
||||
allowlistMatches.push(...result.matches);
|
||||
}
|
||||
return { allowlistSatisfied: true, allowlistMatches };
|
||||
}
|
||||
|
||||
// No chains, evaluate all segments together
|
||||
const result = evaluateSegments(params.analysis.segments, {
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
skillBins: params.skillBins,
|
||||
autoAllowSkills: params.autoAllowSkills,
|
||||
});
|
||||
return { allowlistSatisfied: result.satisfied, allowlistMatches: result.matches };
|
||||
}
|
||||
|
||||
export type ExecAllowlistAnalysis = {
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
allowlistMatches: ExecAllowlistEntry[];
|
||||
segments: ExecCommandSegment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata.
|
||||
*/
|
||||
export function evaluateShellAllowlist(params: {
|
||||
command: string;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
skillBins?: Set<string>;
|
||||
autoAllowSkills?: boolean;
|
||||
platform?: string | null;
|
||||
}): ExecAllowlistAnalysis {
|
||||
const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command);
|
||||
if (!chainParts) {
|
||||
const analysis = analyzeShellCommand({
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
});
|
||||
if (!analysis.ok) {
|
||||
return {
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
allowlistMatches: [],
|
||||
segments: [],
|
||||
};
|
||||
}
|
||||
const evaluation = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
skillBins: params.skillBins,
|
||||
autoAllowSkills: params.autoAllowSkills,
|
||||
});
|
||||
return {
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
allowlistMatches: evaluation.allowlistMatches,
|
||||
segments: analysis.segments,
|
||||
};
|
||||
}
|
||||
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
const segments: ExecCommandSegment[] = [];
|
||||
|
||||
for (const part of chainParts) {
|
||||
const analysis = analyzeShellCommand({
|
||||
command: part,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
});
|
||||
if (!analysis.ok) {
|
||||
return {
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
allowlistMatches: [],
|
||||
segments: [],
|
||||
};
|
||||
}
|
||||
|
||||
segments.push(...analysis.segments);
|
||||
const evaluation = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: params.allowlist,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
skillBins: params.skillBins,
|
||||
autoAllowSkills: params.autoAllowSkills,
|
||||
});
|
||||
allowlistMatches.push(...evaluation.allowlistMatches);
|
||||
if (!evaluation.allowlistSatisfied) {
|
||||
return {
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
allowlistMatches,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
allowlistMatches,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user