2026-02-13 17:39:55 +00:00
|
|
|
import {
|
|
|
|
|
DEFAULT_SAFE_BINS,
|
|
|
|
|
analyzeShellCommand,
|
|
|
|
|
isWindowsPlatform,
|
|
|
|
|
matchAllowlist,
|
|
|
|
|
resolveAllowlistCandidatePath,
|
|
|
|
|
splitCommandChain,
|
|
|
|
|
type ExecCommandAnalysis,
|
|
|
|
|
type CommandResolution,
|
|
|
|
|
type ExecCommandSegment,
|
|
|
|
|
} from "./exec-approvals-analysis.js";
|
2026-02-19 14:37:56 +01:00
|
|
|
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
2026-02-19 14:23:19 +01:00
|
|
|
import {
|
|
|
|
|
SAFE_BIN_GENERIC_PROFILE,
|
|
|
|
|
SAFE_BIN_PROFILES,
|
2026-02-19 16:04:51 +01:00
|
|
|
type SafeBinProfile,
|
2026-02-19 14:23:19 +01:00
|
|
|
validateSafeBinArgv,
|
|
|
|
|
} from "./exec-safe-bin-policy.js";
|
2026-02-18 05:01:25 +01:00
|
|
|
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
|
2026-02-13 17:39:55 +00:00
|
|
|
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>;
|
2026-02-19 16:04:51 +01:00
|
|
|
platform?: string | null;
|
2026-02-18 05:01:25 +01:00
|
|
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
2026-02-19 16:04:51 +01:00
|
|
|
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
|
|
|
|
|
safeBinGenericProfile?: SafeBinProfile;
|
|
|
|
|
isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath;
|
2026-02-13 17:39:55 +00:00
|
|
|
}): boolean {
|
2026-02-14 19:59:03 +01:00
|
|
|
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
|
|
|
|
|
// Keep safeBins conservative there (require explicit allowlist entries).
|
2026-02-19 16:04:51 +01:00
|
|
|
if (isWindowsPlatform(params.platform ?? process.platform)) {
|
2026-02-14 19:59:03 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-13 17:39:55 +00:00
|
|
|
if (params.safeBins.size === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const resolution = params.resolution;
|
|
|
|
|
const execName = resolution?.executableName?.toLowerCase();
|
|
|
|
|
if (!execName) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-19 14:23:19 +01:00
|
|
|
const matchesSafeBin = params.safeBins.has(execName);
|
2026-02-13 17:39:55 +00:00
|
|
|
if (!matchesSafeBin) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!resolution?.resolvedPath) {
|
|
|
|
|
return false;
|
2026-02-18 04:54:46 +01:00
|
|
|
}
|
2026-02-19 16:04:51 +01:00
|
|
|
const isTrustedPath = params.isTrustedSafeBinPathFn ?? isTrustedSafeBinPath;
|
2026-02-18 05:01:25 +01:00
|
|
|
if (
|
2026-02-19 16:04:51 +01:00
|
|
|
!isTrustedPath({
|
2026-02-18 05:01:25 +01:00
|
|
|
resolvedPath: resolution.resolvedPath,
|
|
|
|
|
trustedDirs: params.trustedSafeBinDirs,
|
|
|
|
|
})
|
|
|
|
|
) {
|
2026-02-18 04:54:46 +01:00
|
|
|
return false;
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
const argv = params.argv.slice(1);
|
2026-02-19 16:04:51 +01:00
|
|
|
const safeBinProfiles = params.safeBinProfiles ?? SAFE_BIN_PROFILES;
|
|
|
|
|
const genericSafeBinProfile = params.safeBinGenericProfile ?? SAFE_BIN_GENERIC_PROFILE;
|
|
|
|
|
const profile = safeBinProfiles[execName] ?? genericSafeBinProfile;
|
2026-02-19 14:14:46 +01:00
|
|
|
return validateSafeBinArgv(argv, profile);
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ExecAllowlistEvaluation = {
|
|
|
|
|
allowlistSatisfied: boolean;
|
|
|
|
|
allowlistMatches: ExecAllowlistEntry[];
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
2026-02-13 17:39:55 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-14 19:59:03 +01:00
|
|
|
export type ExecSegmentSatisfiedBy = "allowlist" | "safeBins" | "skills" | null;
|
|
|
|
|
|
2026-02-13 17:39:55 +00:00
|
|
|
function evaluateSegments(
|
|
|
|
|
segments: ExecCommandSegment[],
|
|
|
|
|
params: {
|
|
|
|
|
allowlist: ExecAllowlistEntry[];
|
|
|
|
|
safeBins: Set<string>;
|
|
|
|
|
cwd?: string;
|
2026-02-19 16:04:51 +01:00
|
|
|
platform?: string | null;
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins?: Set<string>;
|
|
|
|
|
autoAllowSkills?: boolean;
|
|
|
|
|
},
|
2026-02-14 19:59:03 +01:00
|
|
|
): {
|
|
|
|
|
satisfied: boolean;
|
|
|
|
|
matches: ExecAllowlistEntry[];
|
|
|
|
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
|
|
|
|
} {
|
2026-02-13 17:39:55 +00:00
|
|
|
const matches: ExecAllowlistEntry[] = [];
|
|
|
|
|
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
|
2026-02-14 19:59:03 +01:00
|
|
|
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
|
2026-02-13 17:39:55 +00:00
|
|
|
|
|
|
|
|
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,
|
2026-02-19 16:04:51 +01:00
|
|
|
platform: params.platform,
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
2026-02-13 17:39:55 +00:00
|
|
|
});
|
|
|
|
|
const skillAllow =
|
|
|
|
|
allowSkills && segment.resolution?.executableName
|
|
|
|
|
? params.skillBins?.has(segment.resolution.executableName)
|
|
|
|
|
: false;
|
2026-02-14 19:59:03 +01:00
|
|
|
const by: ExecSegmentSatisfiedBy = match
|
|
|
|
|
? "allowlist"
|
|
|
|
|
: safe
|
|
|
|
|
? "safeBins"
|
|
|
|
|
: skillAllow
|
|
|
|
|
? "skills"
|
|
|
|
|
: null;
|
|
|
|
|
segmentSatisfiedBy.push(by);
|
|
|
|
|
return Boolean(by);
|
2026-02-13 17:39:55 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-14 19:59:03 +01:00
|
|
|
return { satisfied, matches, segmentSatisfiedBy };
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function evaluateExecAllowlist(params: {
|
|
|
|
|
analysis: ExecCommandAnalysis;
|
|
|
|
|
allowlist: ExecAllowlistEntry[];
|
|
|
|
|
safeBins: Set<string>;
|
|
|
|
|
cwd?: string;
|
2026-02-19 16:04:51 +01:00
|
|
|
platform?: string | null;
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins?: Set<string>;
|
|
|
|
|
autoAllowSkills?: boolean;
|
|
|
|
|
}): ExecAllowlistEvaluation {
|
|
|
|
|
const allowlistMatches: ExecAllowlistEntry[] = [];
|
2026-02-14 19:59:03 +01:00
|
|
|
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
|
2026-02-13 17:39:55 +00:00
|
|
|
if (!params.analysis.ok || params.analysis.segments.length === 0) {
|
2026-02-14 19:59:03 +01:00
|
|
|
return { allowlistSatisfied: false, allowlistMatches, segmentSatisfiedBy };
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
2026-02-19 16:04:51 +01:00
|
|
|
platform: params.platform,
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins: params.skillBins,
|
|
|
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
|
|
|
});
|
|
|
|
|
if (!result.satisfied) {
|
2026-02-14 19:59:03 +01:00
|
|
|
return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] };
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
allowlistMatches.push(...result.matches);
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy.push(...result.segmentSatisfiedBy);
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
2026-02-14 19:59:03 +01:00
|
|
|
return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy };
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No chains, evaluate all segments together
|
|
|
|
|
const result = evaluateSegments(params.analysis.segments, {
|
|
|
|
|
allowlist: params.allowlist,
|
|
|
|
|
safeBins: params.safeBins,
|
|
|
|
|
cwd: params.cwd,
|
2026-02-19 16:04:51 +01:00
|
|
|
platform: params.platform,
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins: params.skillBins,
|
|
|
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
|
|
|
});
|
2026-02-14 19:59:03 +01:00
|
|
|
return {
|
|
|
|
|
allowlistSatisfied: result.satisfied,
|
|
|
|
|
allowlistMatches: result.matches,
|
|
|
|
|
segmentSatisfiedBy: result.segmentSatisfiedBy,
|
|
|
|
|
};
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ExecAllowlistAnalysis = {
|
|
|
|
|
analysisOk: boolean;
|
|
|
|
|
allowlistSatisfied: boolean;
|
|
|
|
|
allowlistMatches: ExecAllowlistEntry[];
|
|
|
|
|
segments: ExecCommandSegment[];
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy: ExecSegmentSatisfiedBy[];
|
2026-02-13 17:39:55 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins?: Set<string>;
|
|
|
|
|
autoAllowSkills?: boolean;
|
|
|
|
|
platform?: string | null;
|
|
|
|
|
}): ExecAllowlistAnalysis {
|
2026-02-15 16:05:49 +00:00
|
|
|
const analysisFailure = (): ExecAllowlistAnalysis => ({
|
|
|
|
|
analysisOk: false,
|
|
|
|
|
allowlistSatisfied: false,
|
|
|
|
|
allowlistMatches: [],
|
|
|
|
|
segments: [],
|
|
|
|
|
segmentSatisfiedBy: [],
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-13 17:39:55 +00:00
|
|
|
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) {
|
2026-02-15 16:05:49 +00:00
|
|
|
return analysisFailure();
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
const evaluation = evaluateExecAllowlist({
|
|
|
|
|
analysis,
|
|
|
|
|
allowlist: params.allowlist,
|
|
|
|
|
safeBins: params.safeBins,
|
|
|
|
|
cwd: params.cwd,
|
2026-02-19 16:04:51 +01:00
|
|
|
platform: params.platform,
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins: params.skillBins,
|
|
|
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
analysisOk: true,
|
|
|
|
|
allowlistSatisfied: evaluation.allowlistSatisfied,
|
|
|
|
|
allowlistMatches: evaluation.allowlistMatches,
|
|
|
|
|
segments: analysis.segments,
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy: evaluation.segmentSatisfiedBy,
|
2026-02-13 17:39:55 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allowlistMatches: ExecAllowlistEntry[] = [];
|
|
|
|
|
const segments: ExecCommandSegment[] = [];
|
2026-02-14 19:59:03 +01:00
|
|
|
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
|
2026-02-13 17:39:55 +00:00
|
|
|
|
|
|
|
|
for (const part of chainParts) {
|
|
|
|
|
const analysis = analyzeShellCommand({
|
|
|
|
|
command: part,
|
|
|
|
|
cwd: params.cwd,
|
|
|
|
|
env: params.env,
|
|
|
|
|
platform: params.platform,
|
|
|
|
|
});
|
|
|
|
|
if (!analysis.ok) {
|
2026-02-15 16:05:49 +00:00
|
|
|
return analysisFailure();
|
2026-02-13 17:39:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
segments.push(...analysis.segments);
|
|
|
|
|
const evaluation = evaluateExecAllowlist({
|
|
|
|
|
analysis,
|
|
|
|
|
allowlist: params.allowlist,
|
|
|
|
|
safeBins: params.safeBins,
|
|
|
|
|
cwd: params.cwd,
|
2026-02-19 16:04:51 +01:00
|
|
|
platform: params.platform,
|
2026-02-19 14:21:07 +01:00
|
|
|
trustedSafeBinDirs: params.trustedSafeBinDirs,
|
2026-02-13 17:39:55 +00:00
|
|
|
skillBins: params.skillBins,
|
|
|
|
|
autoAllowSkills: params.autoAllowSkills,
|
|
|
|
|
});
|
|
|
|
|
allowlistMatches.push(...evaluation.allowlistMatches);
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy.push(...evaluation.segmentSatisfiedBy);
|
2026-02-13 17:39:55 +00:00
|
|
|
if (!evaluation.allowlistSatisfied) {
|
|
|
|
|
return {
|
|
|
|
|
analysisOk: true,
|
|
|
|
|
allowlistSatisfied: false,
|
|
|
|
|
allowlistMatches,
|
|
|
|
|
segments,
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy,
|
2026-02-13 17:39:55 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
analysisOk: true,
|
|
|
|
|
allowlistSatisfied: true,
|
|
|
|
|
allowlistMatches,
|
|
|
|
|
segments,
|
2026-02-14 19:59:03 +01:00
|
|
|
segmentSatisfiedBy,
|
2026-02-13 17:39:55 +00:00
|
|
|
};
|
|
|
|
|
}
|