fix(exec): implement Windows argPattern allowlist flow

This commit is contained in:
Peter Steinberger
2026-04-02 15:29:55 +01:00
parent cc5146b9c6
commit fff6333773
10 changed files with 1247 additions and 111 deletions

View File

@@ -246,9 +246,85 @@ export function resolvePolicyAllowlistCandidatePath(
return resolvePolicyTargetCandidatePath(resolution, cwd);
}
// Strip trailing shell redirections (e.g. `2>&1`, `2>/dev/null`) so that
// allow-always argPatterns built without them still match commands that include
// them. LLMs commonly add or omit these between runs of the same cron job.
const TRAILING_SHELL_REDIRECTIONS_RE = /\s+(?:[12]>&[12]|[12]>\/dev\/null)\s*$/;
function stripTrailingRedirections(value: string): string {
let prev = value;
// eslint-disable-next-line no-constant-condition
while (true) {
const next = prev.replace(TRAILING_SHELL_REDIRECTIONS_RE, "");
if (next === prev) {
return next;
}
prev = next;
}
}
function matchArgPattern(argPattern: string, argv: string[], platform?: string | null): boolean {
// Patterns built by buildArgPatternFromArgv use \x00 as the argument separator and
// always include a trailing \x00 sentinel so that every auto-generated pattern
// (including zero-arg "^\x00\x00$" and single-arg "^hello world\x00$") contains at
// least one \x00. This lets matchArgPattern detect the join style unambiguously
// via .includes("\x00") without misidentifying anchored hand-authored patterns.
// Legacy hand-authored patterns use a plain space and contain no \x00.
// When \x00 style is active, a trailing \x00 is appended to the joined args string
// to match the sentinel embedded in the pattern.
//
// Zero args use a double sentinel "\x00\x00" to distinguish [] from [""] — both
// join to "" but must match different patterns ("^\x00\x00$" vs "^\x00$").
const sep = argPattern.includes("\x00") ? "\x00" : " ";
const argsSlice = argv.slice(1);
const argsString =
sep === "\x00"
? argsSlice.length === 0
? "\x00\x00" // zero args: double sentinel matches "^\x00\x00$" pattern
: argsSlice.join(sep) + sep // trailing sentinel to match pattern format
: argsSlice.join(sep);
try {
const regex = new RegExp(argPattern);
if (regex.test(argsString)) {
return true;
}
// On Windows, LLMs may use forward slashes (`C:/path`) or backslashes
// (`C:\path`) interchangeably. Normalize to backslashes and retry so
// that an argPattern built from one style still matches the other.
// Use the caller-supplied target platform so Linux gateways evaluating
// Windows node commands also perform the normalization.
const effectivePlatform = String(platform ?? process.platform)
.trim()
.toLowerCase();
if (effectivePlatform.startsWith("win")) {
const normalized = argsString.replace(/\//g, "\\");
if (normalized !== argsString && regex.test(normalized)) {
return true;
}
}
// Retry after stripping trailing shell redirections (2>&1, etc.) so that
// patterns saved without them still match commands that include them.
// Only applies for space-joined (legacy hand-authored) patterns. For
// \x00-joined auto-generated patterns, redirections are already blocked
// upstream by findWindowsUnsupportedToken, so any surviving 2>&1 token
// is a literal data argument and must not be stripped.
if (sep === " ") {
const stripped = stripTrailingRedirections(argsString);
if (stripped !== argsString && regex.test(stripped)) {
return true;
}
}
return false;
} catch {
return false;
}
}
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: ExecutableResolution | null,
argv?: string[],
platform?: string | null,
): ExecAllowlistEntry | null {
if (!entries.length) {
return null;
@@ -256,7 +332,7 @@ export function matchAllowlist(
// A bare "*" wildcard allows any parsed executable command.
// Check it before the resolvedPath guard so unresolved PATH lookups still
// match (for example platform-specific executables without known extensions).
const bareWild = entries.find((e) => e.pattern?.trim() === "*");
const bareWild = entries.find((e) => e.pattern?.trim() === "*" && !e.argPattern);
if (bareWild && resolution) {
return bareWild;
}
@@ -264,6 +340,14 @@ export function matchAllowlist(
return null;
}
const resolvedPath = resolution.resolvedPath;
// argPattern matching is currently Windows-only. On other platforms every
// path-matched entry is treated as a match regardless of argPattern, which
// preserves the pre-existing behaviour.
// Use the caller-supplied target platform rather than process.platform so that
// a Linux gateway evaluating a Windows node command applies argPattern correctly.
const effectivePlatform = platform ?? process.platform;
const useArgPattern = String(effectivePlatform).trim().toLowerCase().startsWith("win");
let pathOnlyMatch: ExecAllowlistEntry | null = null;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (!pattern) {
@@ -273,11 +357,25 @@ export function matchAllowlist(
if (!hasPath) {
continue;
}
if (matchesExecAllowlistPattern(pattern, resolvedPath)) {
if (!matchesExecAllowlistPattern(pattern, resolvedPath)) {
continue;
}
if (!useArgPattern) {
// Non-Windows: first path match wins (legacy behaviour).
return entry;
}
if (!entry.argPattern) {
if (!pathOnlyMatch) {
pathOnlyMatch = entry;
}
continue;
}
// Entry has argPattern — check argv match.
if (argv && matchArgPattern(entry.argPattern, argv, platform)) {
return entry;
}
}
return null;
return pathOnlyMatch;
}
export type ExecArgvToken =