fix: harden Windows exec allowlist

This commit is contained in:
Peter Steinberger
2026-02-03 09:34:08 -08:00
parent 8f3bfbd1c4
commit a7f4a53ce8
5 changed files with 170 additions and 2 deletions

View File

@@ -586,6 +586,19 @@ export type ExecCommandAnalysis = {
const DISALLOWED_PIPELINE_TOKENS = new Set([">", "<", "`", "\n", "\r", "(", ")"]);
const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]);
const WINDOWS_UNSUPPORTED_TOKENS = new Set([
"&",
"|",
"<",
">",
"^",
"(",
")",
"%",
"!",
"\n",
"\r",
]);
function isDoubleQuoteEscape(next: string | undefined): next is string {
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
@@ -735,6 +748,86 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se
return { ok: true, segments: result.parts };
}
function findWindowsUnsupportedToken(command: string): string | null {
for (const ch of command) {
if (WINDOWS_UNSUPPORTED_TOKENS.has(ch)) {
if (ch === "\n" || ch === "\r") {
return "newline";
}
return ch;
}
}
return null;
}
function tokenizeWindowsSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inDouble = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (ch === '"') {
inDouble = !inDouble;
continue;
}
if (!inDouble && /\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (inDouble) {
return null;
}
pushToken();
return tokens.length > 0 ? tokens : null;
}
function analyzeWindowsShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const unsupported = findWindowsUnsupportedToken(params.command);
if (unsupported) {
return {
ok: false,
reason: `unsupported windows shell token: ${unsupported}`,
segments: [],
};
}
const argv = tokenizeWindowsSegment(params.command);
if (!argv || argv.length === 0) {
return { ok: false, reason: "unable to parse windows command", segments: [] };
}
return {
ok: true,
segments: [
{
raw: params.command,
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
},
],
};
}
function isWindowsPlatform(platform?: string | null): boolean {
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
return normalized.startsWith("win");
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
@@ -828,7 +921,11 @@ 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) {
@@ -1190,13 +1287,15 @@ export function evaluateShellAllowlist(params: {
env?: NodeJS.ProcessEnv;
skillBins?: Set<string>;
autoAllowSkills?: boolean;
platform?: string | null;
}): ExecAllowlistAnalysis {
const chainParts = splitCommandChain(params.command);
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 {
@@ -1230,6 +1329,7 @@ export function evaluateShellAllowlist(params: {
command: part,
cwd: params.cwd,
env: params.env,
platform: params.platform,
});
if (!analysis.ok) {
return {