mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +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,
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function expandHome(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
type CommandResolution = {
|
||||
export type CommandResolution = {
|
||||
rawExecutable: string;
|
||||
resolvedPath?: string;
|
||||
executableName: string;
|
||||
@@ -185,7 +185,7 @@ function matchesPattern(pattern: string, target: string): boolean {
|
||||
return regex.test(normalizedTarget);
|
||||
}
|
||||
|
||||
function resolveAllowlistCandidatePath(
|
||||
export function resolveAllowlistCandidatePath(
|
||||
resolution: CommandResolution | null,
|
||||
cwd?: string,
|
||||
): string | undefined {
|
||||
@@ -575,7 +575,7 @@ function analyzeWindowsShellCommand(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function isWindowsPlatform(platform?: string | null): boolean {
|
||||
export function isWindowsPlatform(platform?: string | null): boolean {
|
||||
const normalized = String(platform ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -671,258 +671,11 @@ function parseSegmentsFromParts(
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function analyzeShellCommand(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: string | null;
|
||||
}): ExecCommandAnalysis {
|
||||
if (isWindowsPlatform(params.platform)) {
|
||||
return analyzeWindowsShellCommand(params);
|
||||
}
|
||||
// First try splitting by chain operators (&&, ||, ;)
|
||||
const chainParts = splitCommandChain(params.command);
|
||||
if (chainParts) {
|
||||
const chains: ExecCommandSegment[][] = [];
|
||||
const allSegments: ExecCommandSegment[] = [];
|
||||
|
||||
for (const part of chainParts) {
|
||||
const pipelineSplit = splitShellPipeline(part);
|
||||
if (!pipelineSplit.ok) {
|
||||
return { ok: false, reason: pipelineSplit.reason, segments: [] };
|
||||
}
|
||||
const segments = parseSegmentsFromParts(pipelineSplit.segments, params.cwd, params.env);
|
||||
if (!segments) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
chains.push(segments);
|
||||
allSegments.push(...segments);
|
||||
}
|
||||
|
||||
return { ok: true, segments: allSegments, chains };
|
||||
}
|
||||
|
||||
// No chain operators, parse as simple pipeline
|
||||
const split = splitShellPipeline(params.command);
|
||||
if (!split.ok) {
|
||||
return { ok: false, reason: split.reason, segments: [] };
|
||||
}
|
||||
const segments = parseSegmentsFromParts(split.segments, params.cwd, params.env);
|
||||
if (!segments) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
export function analyzeArgvCommand(params: {
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const argv = params.argv.filter((entry) => entry.trim().length > 0);
|
||||
if (argv.length === 0) {
|
||||
return { ok: false, reason: "empty argv", segments: [] };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: argv.join(" "),
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a command string by chain operators (&&, ||, ;) while respecting quotes.
|
||||
* Returns null when no chain is present or when the chain is malformed.
|
||||
*/
|
||||
function splitCommandChain(command: string): string[] | null {
|
||||
export function splitCommandChain(command: string): string[] | null {
|
||||
const parts: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
@@ -1023,101 +776,66 @@ function splitCommandChain(command: string): string[] | null {
|
||||
return parts.length > 0 ? parts : null;
|
||||
}
|
||||
|
||||
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: {
|
||||
export function analyzeShellCommand(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: [],
|
||||
};
|
||||
}): ExecCommandAnalysis {
|
||||
if (isWindowsPlatform(params.platform)) {
|
||||
return analyzeWindowsShellCommand(params);
|
||||
}
|
||||
// First try splitting by chain operators (&&, ||, ;)
|
||||
const chainParts = splitCommandChain(params.command);
|
||||
if (chainParts) {
|
||||
const chains: ExecCommandSegment[][] = [];
|
||||
const allSegments: ExecCommandSegment[] = [];
|
||||
|
||||
for (const part of chainParts) {
|
||||
const pipelineSplit = splitShellPipeline(part);
|
||||
if (!pipelineSplit.ok) {
|
||||
return { ok: false, reason: pipelineSplit.reason, segments: [] };
|
||||
}
|
||||
const segments = parseSegmentsFromParts(pipelineSplit.segments, params.cwd, params.env);
|
||||
if (!segments) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
chains.push(segments);
|
||||
allSegments.push(...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,
|
||||
};
|
||||
|
||||
return { ok: true, segments: allSegments, chains };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
// No chain operators, parse as simple pipeline
|
||||
const split = splitShellPipeline(params.command);
|
||||
if (!split.ok) {
|
||||
return { ok: false, reason: split.reason, segments: [] };
|
||||
}
|
||||
const segments = parseSegmentsFromParts(split.segments, params.cwd, params.env);
|
||||
if (!segments) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
export function analyzeArgvCommand(params: {
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const argv = params.argv.filter((entry) => entry.trim().length > 0);
|
||||
if (argv.length === 0) {
|
||||
return { ok: false, reason: "empty argv", segments: [] };
|
||||
}
|
||||
return {
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
allowlistMatches,
|
||||
segments,
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: argv.join(" "),
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
export * from "./exec-approvals-analysis.js";
|
||||
export * from "./exec-approvals-allowlist.js";
|
||||
|
||||
export type ExecHost = "sandbox" | "gateway" | "node";
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
|
||||
Reference in New Issue
Block a user