import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { literalBashHeredocBodyRanges } from "./bash-heredoc.mjs"; import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml"; /** * Revision-state absence policy by source dialect. * JS/TS syntax uses the TypeScript parser and YAML structure uses the installed YAML parser. * Shell active consumers are executable code/expansions and jq filter arguments for bare or * path-qualified jq, optionally through command or env. Quoted heredoc bodies are literal. * PowerShell analyzes executable code and nested $() in expandable strings. Python policy is * batched through the isolated stdlib AST helper. jq filters use a bounded path lexer after * shell argv/wrapper resolution. Offset-preserving transformations keep AST spans stable. */ const revisionIdentifiers = new Set(["revision", "workspaceRevision", "selectedWorkspace"]); function unwrapExpression(node) { let current = node; while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current) || ts.isSatisfiesExpression(current)) { current = current.expression; } return current; } function isRevisionName(value, caseInsensitive) { if (typeof value !== "string") return false; if (!caseInsensitive) return revisionIdentifiers.has(value); const lower = value.toLowerCase(); return lower === "revision" || lower === "workspacerevision" || lower === "selectedworkspace"; } function isRevisionExpression(node, caseInsensitive = false) { const unwrapped = unwrapExpression(node); if (ts.isIdentifier(unwrapped)) { const normalized = unwrapped.text.startsWith("$") && !unwrapped.text.startsWith("$$") ? unwrapped.text.slice(1) : unwrapped.text; return isRevisionName(normalized, caseInsensitive); } if (ts.isPropertyAccessExpression(unwrapped)) return isRevisionName(unwrapped.name.text, caseInsensitive); if (ts.isElementAccessExpression(unwrapped) && unwrapped.argumentExpression) { return isRevisionName(staticStringValue(unwrapped.argumentExpression), caseInsensitive); } return false; } function staticStringValue(node) { const expression = unwrapExpression(node); if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text; if (ts.isTemplateExpression(expression)) { let value = expression.head.text; for (const span of expression.templateSpans) { const part = staticStringValue(span.expression); if (part === undefined) return undefined; value += part + span.literal.text; } return value; } if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) { const left = staticStringValue(expression.left); const right = staticStringValue(expression.right); return left === undefined || right === undefined ? undefined : left + right; } return undefined; } function propertyNameText(name, caseInsensitive = false) { if (!name) return undefined; let value; if (ts.isComputedPropertyName(name)) value = staticStringValue(name.expression); else if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name) || ts.isNumericLiteral(name)) value = name.text; else value = staticStringValue(name); return caseInsensitive && typeof value === "string" ? value.toLowerCase() : value; } function objectBindingHasState(pattern, caseInsensitive) { return pattern.elements.some((element) => { if (element.dotDotDotToken) return false; return propertyNameText(element.propertyName ?? element.name, caseInsensitive) === "state"; }); } function objectLiteralHasState(object, caseInsensitive) { return object.properties.some((property) => !ts.isSpreadAssignment(property) && propertyNameText(property.name, caseInsensitive) === "state"); } function scriptKindFor(path) { const lower = path.toLowerCase(); if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX; if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX; if (/\.(?:ts|mts|cts)$/u.test(lower)) return ts.ScriptKind.TS; if (/\.(?:js|mjs|cjs)$/u.test(lower)) return ts.ScriptKind.JS; return undefined; } function maskRange(output, source, start, end, keepEnds = false) { for (let cursor = start; cursor < end; cursor += 1) { if (source[cursor] === "\n" || source[cursor] === "\r") continue; if (keepEnds && (cursor === start || cursor === end - 1)) continue; output[cursor] = " "; } } function lineEnd(source, start) { const end = source.indexOf("\n", start); return end < 0 ? source.length : end; } function quotedEnd(source, start, delimiter, escapes = "\\") { for (let cursor = start + delimiter.length; cursor < source.length; cursor += 1) { if (escapes.includes(source[cursor])) { cursor += 1; continue; } if (source.startsWith(delimiter, cursor)) return cursor + delimiter.length; } return source.length; } function balancedEnd(source, openIndex, opener, closer, escapes = "\\`") { let depth = 1; for (let cursor = openIndex + 1; cursor < source.length; cursor += 1) { if (escapes.includes(source[cursor])) { cursor += 1; continue; } if (source[cursor] === "'" || source[cursor] === '"' || source[cursor] === "`") { cursor = quotedEnd(source, cursor, source[cursor], escapes) - 1; continue; } if (source[cursor] === opener) depth += 1; else if (source[cursor] === closer && --depth === 0) return cursor; } return source.length - 1; } function restoreMasked(output, offset, masked) { for (let cursor = 0; cursor < masked.length; cursor += 1) output[offset + cursor] = masked[cursor]; } function exposeDollarSubexpressions(output, source, start, end, dialect) { for (let cursor = start; cursor + 1 < end; cursor += 1) { if (!source.startsWith("$(", cursor) || source[cursor - 1] === "`") continue; const close = balancedEnd(source, cursor + 1, "(", ")"); output[cursor] = " "; output[cursor + 1] = "("; restoreMasked(output, cursor + 2, dialect === "shell" ? maskShellSource(source.slice(cursor + 2, close)) : maskPowerShellSource(source.slice(cursor + 2, close))); if (close < source.length) output[close] = ")"; cursor = close; } } function shellCommentStart(source, index) { return source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1])); } function canonicalRevisionName(name) { const lower = name.toLowerCase(); if (lower === "revision") return "revision"; if (lower === "workspacerevision") return "workspaceRevision"; return "selectedWorkspace"; } function normalizePowerShellVariables(source) { const output = source.split(""); const patterns = [ { expression: /\$\{(?:[A-Za-z_][A-Za-z0-9_]*:)?(revision|workspaceRevision|selectedWorkspace)\}/giu, dollar: false }, { expression: /\$(?:[A-Za-z_][A-Za-z0-9_]*:)(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: false }, { expression: /\$(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: true }, ]; for (const { expression, dollar } of patterns) { for (const match of source.matchAll(expression)) { const name = canonicalRevisionName(match[1]); const replacement = `${dollar ? "$" : ""}${name}`.padEnd(match[0].length, " "); for (let offset = 0; offset < match[0].length; offset += 1) output[match.index + offset] = replacement[offset]; } } let normalized = output.join(""); normalized = normalized.replace(/\.\s*state\b/giu, (match) => match.replace(/state/iu, "state")); normalized = normalized.replace(/(["'])state\1/giu, (_match, quote) => `${quote}state${quote}`); return normalized; } function maskShellSource(source) { return maskShellFamilySource(source, false); } function maskPowerShellSource(source) { return normalizePowerShellVariables(maskShellFamilySource(source, true)); } function maskShellFamilySource(source, powershell) { const output = source.split(""); let squareDepth = 0; for (let index = 0; index < source.length; index += 1) { if (powershell && source.startsWith("<#", index)) { const close = source.indexOf("#>", index + 2); const end = close < 0 ? source.length : close + 2; maskRange(output, source, index, end); index = end - 1; continue; } if (powershell ? source[index] === "#" : shellCommentStart(source, index)) { const end = lineEnd(source, index); maskRange(output, source, index, end); index = end - 1; continue; } if (powershell && source[index] === "`") { maskRange(output, source, index, Math.min(index + 2, source.length)); index += 1; continue; } if (!powershell && source[index] === "`") { const close = source.indexOf("`", index + 1); const end = close < 0 ? source.length : close + 1; maskRange(output, source, index, end); restoreMasked(output, index + 1, maskShellSource(source.slice(index + 1, close < 0 ? source.length : close))); index = end - 1; continue; } const quote = source[index]; if (quote === "'" || quote === '"') { const escapes = powershell ? "`" : quote === "'" ? "" : "\\"; const end = quotedEnd(source, index, quote, escapes); const preserveKey = powershell && squareDepth > 0; if (!preserveKey) maskRange(output, source, index, end, false); if (quote === '"') { exposeDollarSubexpressions(output, source, index + 1, end - 1, powershell ? "powershell" : "shell"); if (!powershell) { for (let cursor = index + 1; cursor < end - 1; cursor += 1) { if (source[cursor] !== "`" || source[cursor - 1] === "\\") continue; const close = source.indexOf("`", cursor + 1); if (close < 0 || close >= end) break; restoreMasked(output, cursor + 1, maskShellSource(source.slice(cursor + 1, close))); cursor = close; } } } index = end - 1; continue; } if (source.startsWith("$(", index)) output[index] = " "; if (source[index] === "[") squareDepth += 1; else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1; } return output.join(""); } function maskUnknownSource(source) { const output = source.split(""); let squareDepth = 0; for (let index = 0; index < source.length; index += 1) { if (source.startsWith("/*", index)) { const close = source.indexOf("*/", index + 2); const end = close < 0 ? source.length : close + 2; maskRange(output, source, index, end); index = end - 1; continue; } if (source[index] === "#" || source.startsWith("//", index)) { const end = lineEnd(source, index); maskRange(output, source, index, end); index = end - 1; continue; } const quote = source[index]; if (quote === "'" || quote === '"' || quote === "`") { const end = quotedEnd(source, index, quote, "\\"); let after = end; while (/[ \t]/u.test(source[after] ?? "")) after += 1; if (!(squareDepth > 0 || source[after] === ":")) maskRange(output, source, index, end, true); index = end - 1; continue; } if (source[index] === "[") squareDepth += 1; else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1; } return output.join(""); } function maskQuotedShellHeredocBodies(source, label = "") { const output = source.split(""); for (const range of literalBashHeredocBodyRanges(source, label)) maskRange(output, source, range.start, range.end); return output.join(""); } function shellAssociativeRevisionAccess(source) { let quote; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (character === "\\") { index += 1; continue; } if (quote === "'") { if (character === "'") quote = undefined; continue; } if (character === "'") { quote = "'"; continue; } if (character === '"') { quote = quote === '"' ? undefined : '"'; continue; } if (character !== "$" || source[index + 1] !== "{") continue; const close = source.indexOf("}", index + 2); if (close < 0) break; const expansion = source.slice(index, close + 1); if (/^\$\{[ \t]*(?:revision|workspaceRevision|selectedWorkspace)[ \t]*\[[ \t]*(?:["']state["']|state)[ \t]*\][^}]*\}$/u.test(expansion)) return true; index = close; } return false; } const shellCommandPrefixes = new Set(["if", "then", "elif", "else", "while", "until", "do"]); const shellCommandClosers = new Set(["fi", "done", "esac"]); const shellControlCharacters = new Set([";", "|", "&", "(", ")", "{", "}", "`"]); function shellQuotedSubstitutionEnd(source, start, depth, budget) { for (let index = start + 1; index < source.length; index += 1) { budget.characters += 1; if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded"); if (source[index] === "\\") { index += 1; continue; } if (source[index] === '"') return index + 1; if (source.startsWith("$(", index) || source.startsWith("<(", index) || source.startsWith(">(", index)) { index = shellParenthesizedEnd(source, index + 1, depth + 1, budget) - 1; } else if (source[index] === "`") { const end = quotedEnd(source, index, "`", "\\"); if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick"); index = end - 1; } } throw new Error("revision-state shell substitution has an unclosed quote"); } function shellParenthesizedEnd(source, openIndex, depth, budget) { if (depth > 64) throw new Error("revision-state shell substitution nesting limit exceeded"); for (let index = openIndex + 1; index < source.length; index += 1) { budget.characters += 1; if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded"); if (source[index] === "\\") { index += 1; continue; } if (source[index] === "'") { const end = quotedEnd(source, index, "'", ""); if (end - 1 <= index || source[end - 1] !== "'") throw new Error("revision-state shell substitution has an unclosed quote"); index = end - 1; continue; } if (source[index] === '"') { index = shellQuotedSubstitutionEnd(source, index, depth, budget) - 1; continue; } if (source[index] === "`") { const end = quotedEnd(source, index, "`", "\\"); if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick"); index = end - 1; continue; } if (source[index] === "#" && (index === openIndex + 1 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) { index = lineEnd(source, index); continue; } if (source[index] === "(") { index = shellParenthesizedEnd(source, index, depth + 1, budget) - 1; continue; } if (source[index] === ")") return index + 1; } throw new Error("revision-state shell process substitution is unbalanced"); } function shellProcessSubstitutionEnd(source, start) { if (!(source.startsWith("<(", start) || source.startsWith(">(", start))) return undefined; return shellParenthesizedEnd(source, start + 1, 1, { characters: 0 }); } function shellRedirectionAt(source, start) { const match = source.slice(start).match(/^(?:&>>|&>|(?:[0-9]+|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|>>|<>|>\||<&|>&|<|>))/u); if (!match) return undefined; let end = start + match[0].length; while (end < source.length && !/\s/u.test(source[end]) && !shellControlCharacters.has(source[end]) && source[end] !== "<" && source[end] !== ">" && source[end] !== "'" && source[end] !== '"') end += 1; return { value: source.slice(start, end), end, needsOperand: end === start + match[0].length }; } function shellLexTokens(source) { const tokens = []; const push = (value, start, end, type = "word") => { tokens.push({ value, start, end, type }); if (tokens.length > 50_000) throw new Error("revision-state shell token limit exceeded"); }; for (let index = 0; index < source.length;) { if (source[index] === "\n" || source[index] === "\r") { push(source[index], index, index + 1, "control"); index += 1; continue; } if (/\s/u.test(source[index])) { index += 1; continue; } if (source[index] === "#") { index = lineEnd(source, index); continue; } const processEnd = shellProcessSubstitutionEnd(source, index); if (processEnd !== undefined) { push(source.slice(index, processEnd), index, processEnd); index = processEnd; continue; } const redirection = shellRedirectionAt(source, index); if (redirection) { push(redirection.value, index, redirection.end, "redirection"); tokens.at(-1).needsOperand = redirection.needsOperand; index = redirection.end; continue; } if (shellControlCharacters.has(source[index]) || source[index] === "!" && (index === 0 || /\s/u.test(source[index - 1]))) { const start = index; let value = source[index++]; if ((value === ";" || value === "|" || value === "&") && source[index] === value) value += source[index++]; push(value, start, index, "control"); continue; } const start = index; let value = ""; while (index < source.length && !/\s/u.test(source[index]) && !shellControlCharacters.has(source[index]) && source[index] !== "<" && source[index] !== ">") { const quote = source[index]; if (quote === "'" || quote === '"') { const end = quotedEnd(source, index, quote, "\\"); value += source.slice(index + 1, end - 1); index = end; } else if (source[index] === "\\" && index + 1 < source.length) { value += source[index + 1]; index += 2; } else { value += source[index++]; } } push(value, start, index); } return tokens; } function shellCommandWords(source) { const commands = []; let words = []; const finish = () => { if (words.length > 0) commands.push(words); words = []; }; for (const token of shellLexTokens(source)) { if (token.type === "control") { finish(); continue; } if (token.type === "word" && words.length === 0 && shellCommandPrefixes.has(token.value)) continue; if (token.type === "word" && words.length === 0 && shellCommandClosers.has(token.value)) continue; words.push(token); } finish(); return commands; } function shellExecutable(word) { return word?.split("/").pop(); } const shellWrapperSpecs = new Map([ ["command", { kind: "options", operandOptions: new Set() }], ["env", { kind: "env", operandOptions: new Set(["-u", "--unset", "-C", "--chdir"]) }], ["sudo", { kind: "options", operandOptions: new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"]) }], ["nice", { kind: "options", operandOptions: new Set(["-n", "--adjustment"]) }], ["time", { kind: "options", operandOptions: new Set(["-o", "--output", "-f", "--format"]) }], ["xargs", { kind: "options", operandOptions: new Set(["-I", "--replace", "-n", "--max-args", "-L", "--max-lines", "-P", "--max-procs", "-s", "--max-chars", "-d", "--delimiter"]) }], ["timeout", { kind: "timeout", operandOptions: new Set(["-k", "--kill-after", "-s", "--signal"]) }], ["stdbuf", { kind: "stdbuf", operandOptions: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }], ["nohup", { kind: "options", operandOptions: new Set() }], ["exec", { kind: "options", operandOptions: new Set(["-a"]) }], ["coproc", { kind: "coproc", operandOptions: new Set() }], ]); function skipShellMetadata(words, start) { let index = start; while (index < words.length) { const token = words[index]; if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) { index += 1; continue; } if (token.type === "redirection") { index += token.needsOperand ? 2 : 1; continue; } break; } return index; } function skipWrapperOptions(words, start, spec) { let index = start; while (index < words.length) { const word = words[index].value; if (word === "--") return index + 1; if (spec.operandOptions.has(word)) { index += 2; continue; } if (spec.kind === "stdbuf" && /^-(?:i|o|e).+/u.test(word)) { index += 1; continue; } if (word.startsWith("-")) { index += 1; continue; } break; } return index; } function shellJqArguments(words) { let index = skipShellMetadata(words, 0); let wrappers = 0; while (index < words.length) { const spec = shellWrapperSpecs.get(shellExecutable(words[index]?.value)); if (!spec) break; if (wrappers >= 16) throw new Error("revision-state shell wrapper nesting exceeds policy limit"); wrappers += 1; index = skipWrapperOptions(words, index + 1, spec); if (spec.kind === "env") { while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]?.value ?? "")) index += 1; } else if (spec.kind === "timeout") { if (index >= words.length) return undefined; index += 1; } else if (spec.kind === "coproc") { index = skipShellMetadata(words, index); const current = shellExecutable(words[index]?.value); if (current !== "jq" && !shellWrapperSpecs.has(current) && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(words[index]?.value ?? "")) { const afterName = skipShellMetadata(words, index + 1); const command = shellExecutable(words[afterName]?.value); if (command === "jq" || shellWrapperSpecs.has(command)) index = afterName; } } index = skipShellMetadata(words, index); } return shellExecutable(words[index]?.value) === "jq" ? words.slice(index + 1) : undefined; } const jqOptionOperands = new Map([ ["--arg", 2], ["--argjson", 2], ["--slurpfile", 2], ["--rawfile", 2], ["--argfile", 2], ["-L", 1], ["--library-path", 1], ["--indent", 1], ["-f", 1], ["--from-file", 1], ]); const jqFileFilterOptions = new Set(["-f", "--from-file"]); function withoutShellRedirections(arguments_) { const semantic = []; for (let index = 0; index < arguments_.length; index += 1) { const token = arguments_[index]; if (token.type === "redirection") { if (token.needsOperand) index += 1; continue; } semantic.push(token); } return semantic; } function jqInvocation(arguments_) { const semantic = withoutShellRedirections(arguments_); let fromFile = false; for (let index = 0; index < semantic.length; index += 1) { const argument = semantic[index].value; if (argument === "--") return { filter: fromFile ? undefined : semantic[index + 1], arguments_ }; const operands = jqOptionOperands.get(argument); if (operands !== undefined) { if (jqFileFilterOptions.has(argument)) fromFile = true; index += operands; continue; } if (argument.startsWith("-")) continue; return { filter: fromFile ? undefined : semantic[index], arguments_ }; } return { filter: undefined, arguments_ }; } function maskShellJqLiteralArguments(source) { const output = source.split(""); for (const words of shellCommandWords(source)) { const arguments_ = shellJqArguments(words); if (!arguments_) continue; const invocation = jqInvocation(arguments_); for (const argument of invocation.arguments_) { if (argument === invocation.filter) continue; const raw = source.slice(argument.start, argument.end); if (!raw.includes("$") && !raw.includes("`")) maskRange(output, source, argument.start, argument.end); } } return output.join(""); } function jqStringEnd(source, start) { for (let index = start + 1; index < source.length; index += 1) { if (source[index] === "\\") { index += 1; continue; } if (source[index] === '"') return index; } return source.length; } function jqInterpolationEnd(source, start) { let depth = 1; for (let index = start; index < source.length; index += 1) { if (source[index] === '"') { index = jqStringEnd(source, index); continue; } if (source[index] === "(") depth += 1; else if (source[index] === ")" && --depth === 0) return index; } return source.length; } function jqTokens(source, budget = { tokens: 0, depth: 0 }) { if (budget.depth >= 64) throw new Error("jq filter exceeds policy nesting limit"); budget.depth += 1; const tokens = []; for (let index = 0; index < source.length; index += 1) { budget.tokens += 1; if (budget.tokens >= 10_000) throw new Error("jq filter exceeds policy token limit"); if (/\s/u.test(source[index])) continue; if (source[index] === "#") { index = lineEnd(source, index); continue; } if (source[index] === '"') { const end = jqStringEnd(source, index); const raw = source.slice(index, Math.min(end + 1, source.length)); let value; if (!raw.includes("\\(")) { try { value = JSON.parse(raw); } catch { value = undefined; } } tokens.push({ type: "string", value }); for (let cursor = index + 1; cursor < end; cursor += 1) { if (source[cursor] === "\\" && source[cursor + 1] === "(") { const close = jqInterpolationEnd(source, cursor + 2); tokens.push(...jqTokens(source.slice(cursor + 2, close), budget)); cursor = close; } else if (source[cursor] === "\\") cursor += 1; } index = end; continue; } const variable = source.slice(index).match(/^\$([A-Za-z_][A-Za-z0-9_]*)/u); if (variable) { tokens.push({ type: "variable", value: variable[1] }); index += variable[0].length - 1; continue; } const identifier = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/u); if (identifier) { tokens.push({ type: "identifier", value: identifier[0] }); index += identifier[0].length - 1; continue; } const punctuation = { ".": "dot", "[": "open", "]": "close" }[source[index]]; tokens.push({ type: punctuation ?? "other", value: source[index] }); } budget.depth -= 1; return tokens; } function jqStaticString(tokens, cursor, depth = 0) { if (depth >= 64) throw new Error("revision-state jq static-key nesting exceeds policy limit"); let index = cursor; let value; if (tokens[index]?.type === "string" && typeof tokens[index].value === "string") { value = tokens[index].value; index += 1; } else if (tokens[index]?.type === "other" && tokens[index].value === "(") { const nested = jqStaticString(tokens, index + 1, depth + 1); if (!nested || tokens[nested.next]?.type !== "other" || tokens[nested.next].value !== ")") return undefined; value = nested.value; index = nested.next + 1; } else return undefined; while (tokens[index]?.type === "other" && tokens[index].value === "+") { const right = jqStaticString(tokens, index + 1, depth + 1); if (!right) return undefined; value += right.value; index = right.next; } return { value, next: index }; } function jqBracketSegment(tokens, cursor) { if (tokens[cursor]?.type !== "open") return undefined; const expression = jqStaticString(tokens, cursor + 1); return expression && tokens[expression.next]?.type === "close" ? { value: expression.value, next: expression.next + 1 } : undefined; } function jqPathSegment(tokens, cursor, allowBareBracket = true) { if (tokens[cursor]?.type === "variable") return { value: tokens[cursor].value, next: cursor + 1 }; let index = cursor; if (tokens[index]?.type === "dot") { index += 1; if (tokens[index]?.type === "identifier" || tokens[index]?.type === "string") return { value: tokens[index].value, next: index + 1 }; } return allowBareBracket ? jqBracketSegment(tokens, index) : undefined; } function jqIdentityPipelineEnd(tokens, cursor) { let index = cursor; while (tokens[index]?.type === "other" && tokens[index].value === "(") index += 1; if (tokens[index]?.type !== "dot") return undefined; index += 1; while (tokens[index]?.type === "other" && tokens[index].value === ")") index += 1; return tokens[index]?.type === "other" && tokens[index].value === "|" ? index + 1 : undefined; } function jqTargetGrammarSupported(tokens) { for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; if (token.type === "identifier" && tokens[index - 1]?.type !== "dot") return false; if (token.type === "open" && !jqBracketSegment(tokens, index)) return false; if (token.type !== "other") continue; if (["?", "(", ")", "|"].includes(token.value)) continue; if (token.value === "+" && (tokens[index - 1]?.type === "string" || tokens[index - 1]?.value === ")") && (tokens[index + 1]?.type === "string" || tokens[index + 1]?.value === "(")) continue; return false; } return true; } function jqContainsActiveTarget(tokens) { for (let index = 0; index < tokens.length; index += 1) { if (tokens[index].type === "variable" && revisionIdentifiers.has(tokens[index].value)) return true; if (tokens[index].type === "dot" && (tokens[index + 1]?.type === "identifier" || tokens[index + 1]?.type === "string") && revisionIdentifiers.has(tokens[index + 1].value)) return true; if (tokens[index].type === "open" && (tokens[index - 1]?.type === "dot" || tokens[index - 1]?.type === "close" || tokens[index - 1]?.type === "identifier")) { const key = jqStaticString(tokens, index + 1); if (key && revisionIdentifiers.has(key.value)) return true; } } return false; } function jqRevisionAnalysis(filter) { const tokens = jqTokens(filter); let activeTarget = jqContainsActiveTarget(tokens); for (let index = 0; index < tokens.length; index += 1) { if (tokens[index].type !== "dot" && tokens[index].type !== "variable") continue; const segments = []; let cursor = index; let pipelineBoundary = false; while (cursor < tokens.length) { if (pipelineBoundary && (tokens[cursor]?.type === "open" || tokens[cursor]?.type === "string")) { segments.length = 0; break; } if (pipelineBoundary && tokens[cursor]?.type === "variable") segments.length = 0; const segment = jqPathSegment(tokens, cursor, !pipelineBoundary); if (!segment) break; pipelineBoundary = false; segments.push(segment.value); cursor = segment.next; while (tokens[cursor]?.type === "other" && tokens[cursor].value === "?") cursor += 1; while (tokens[cursor]?.type === "other" && tokens[cursor].value === ")") cursor += 1; if (tokens[cursor]?.type === "other" && tokens[cursor].value === "|") { cursor += 1; while (tokens[cursor]?.type === "other" && tokens[cursor].value === "(") cursor += 1; let identityEnd; while ((identityEnd = jqIdentityPipelineEnd(tokens, cursor)) !== undefined) cursor = identityEnd; pipelineBoundary = true; } } if (segments.some((segment) => revisionIdentifiers.has(segment))) activeTarget = true; for (let position = 0; position + 1 < segments.length; position += 1) { if (revisionIdentifiers.has(segments[position]) && segments[position + 1] === "state") return "violation"; } } if (!activeTarget) return "safe"; return jqTargetGrammarSupported(tokens) ? "safe" : "unsupported"; } function shellExecutableSubstitutionBodies(source, arithmeticContext = false) { const bodies = []; const addParenthesized = (start, kind) => { const end = shellParenthesizedEnd(source, start + 1, 1, { characters: 0 }); bodies.push({ kind, start: start + 2, end: end - 1, source: source.slice(start + 2, end - 1) }); return end; }; const addBacktick = (start) => { const end = quotedEnd(source, start, "`", "\\"); if (end - 1 <= start || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick"); bodies.push({ kind: "backtick", start: start + 1, end: end - 1, source: source.slice(start + 1, end - 1) }); return end; }; for (let index = 0; index < source.length; index += 1) { if (source[index] === "\\") { index += 1; continue; } if (source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) { index = lineEnd(source, index); continue; } if (source[index] === "'") { const end = quotedEnd(source, index, "'", ""); if (end - 1 <= index || source[end - 1] !== "'") throw new Error(`revision-state shell policy found an unclosed quote at offset ${index}`); index = end - 1; continue; } if (source[index] === '"') { for (let cursor = index + 1; cursor < source.length; cursor += 1) { if (source[cursor] === "\\") { cursor += 1; continue; } if (source[cursor] === '"') { index = cursor; break; } if (source.startsWith("$(", cursor)) { const end = addParenthesized(cursor, source.startsWith("$((", cursor) ? "arithmetic" : "command"); cursor = end - 1; } else if (source[cursor] === "`") { cursor = addBacktick(cursor) - 1; } if (cursor + 1 >= source.length) throw new Error(`revision-state shell policy found an unclosed double quote at offset ${index}`); } continue; } if (!arithmeticContext && (source.startsWith("<(", index) || source.startsWith(">(", index))) { index = addParenthesized(index, "process") - 1; continue; } if (source.startsWith("$(", index)) { const arithmetic = source.startsWith("$((", index); index = addParenthesized(index, arithmetic ? "arithmetic" : "command") - 1; continue; } if (source[index] === "`") index = addBacktick(index) - 1; } return bodies; } function removeBacktickBodyEscapes(source) { let result = ""; for (let index = 0; index < source.length; index += 1) { if (source[index] === "\\" && index + 1 < source.length && ["$", "`", "\\", "\n"].includes(source[index + 1])) { if (source[index + 1] !== "\n") result += source[index + 1]; index += 1; } else { result += source[index]; } } return result; } function shellJqRevisionAccess(source, budget = { characters: 0 }, depth = 0, arithmeticContext = false) { if (depth > 32) throw new Error("revision-state executable shell substitution nesting limit exceeded"); budget.characters += source.length; if (budget.characters > 500_000) throw new Error("revision-state executable shell substitution size limit exceeded"); if (!arithmeticContext) { for (const words of shellCommandWords(source)) { const arguments_ = shellJqArguments(words); const filter = arguments_ && jqInvocation(arguments_).filter; if (filter) { const analysis = jqRevisionAnalysis(filter.value); if (analysis === "violation") return true; if (analysis === "unsupported") throw new Error("revision-state jq target grammar is unsupported"); } } } for (const body of shellExecutableSubstitutionBodies(source, arithmeticContext)) { const nestedSource = body.kind === "backtick" ? removeBacktickBodyEscapes(body.source) : body.source; if (shellJqRevisionAccess(nestedSource, budget, depth + 1, body.kind === "arithmetic")) return true; } return false; } function nonJsAnalysisSource(source, label) { const lower = label.toLowerCase(); if (lower.endsWith(".sh")) return maskShellSource(maskShellJqLiteralArguments(maskQuotedShellHeredocBodies(source, label))); if (lower.endsWith(".ps1")) return maskPowerShellSource(source); return maskUnknownSource(source); } function revisionStateAstNodes(source, label) { const knownKind = scriptKindFor(label); const caseInsensitive = label.toLowerCase().endsWith(".ps1"); const analyzed = knownKind === undefined ? nonJsAnalysisSource(source, label) : source; const file = ts.createSourceFile(label, analyzed, ts.ScriptTarget.Latest, true, knownKind ?? ts.ScriptKind.TS); const matches = []; function visit(node) { if (ts.isPropertyAccessExpression(node) && node.name.text === "state" && isRevisionExpression(node.expression, caseInsensitive)) { matches.push(node); } else if (ts.isElementAccessExpression(node) && isRevisionExpression(node.expression, caseInsensitive) && node.argumentExpression && propertyNameText(node.argumentExpression, caseInsensitive) === "state") { matches.push(node); } else if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && node.initializer && isRevisionExpression(node.initializer, caseInsensitive) && ts.isObjectBindingPattern(node.name) && objectBindingHasState(node.name, caseInsensitive)) { matches.push(node); } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isRevisionExpression(node.right, caseInsensitive)) { const assignmentTarget = unwrapExpression(node.left); if (ts.isObjectLiteralExpression(assignmentTarget) && objectLiteralHasState(assignmentTarget, caseInsensitive)) matches.push(node); } else if (ts.isPropertyAssignment(node) && propertyNameText(node.name, caseInsensitive) === "revision" && ts.isObjectLiteralExpression(node.initializer) && objectLiteralHasState(node.initializer, caseInsensitive)) { matches.push(node); } ts.forEachChild(node, visit); } visit(file); return matches; } function yamlScalarRevisionAccess(value) { return /(?:^|[\s;=,(])(?:revision|workspaceRevision|selectedWorkspace)\s*(?:\.\s*state|\[\s*["']?state["']?\s*\])(?:$|[\s;,)])/u.test(value); } function validateYamlRevisionState(source, label) { const documents = parseAllDocuments(source, { uniqueKeys: true, merge: true }); for (const document of documents) { if (document.errors.length > 0) throw new Error(`${label}: revision-state policy cannot parse YAML`); const walkAst = (node) => { if (isScalar(node)) { if (node.type === "PLAIN" && typeof node.value === "string" && yamlScalarRevisionAccess(node.value)) throw new Error(`${label}: forbidden revision-state access`); return; } if (isSeq(node)) { for (const item of node.items) walkAst(item); return; } if (isMap(node)) { for (const pair of node.items) walkAst(pair.value); } }; walkAst(document.contents); let resolved; try { resolved = document.toJS({ mapAsMap: true, maxAliasCount: 50 }); } catch { throw new Error(`${label}: revision-state YAML alias resolution failed`); } const seen = new WeakSet(); const walkResolved = (value) => { if (!value || typeof value !== "object" || seen.has(value)) return; seen.add(value); if (value instanceof Map) { for (const [key, child] of value) { if (revisionIdentifiers.has(String(key)) && child instanceof Map && child.has("state")) throw new Error(`${label}: forbidden revision-state access`); walkResolved(child); } } else if (Array.isArray(value)) { for (const child of value) walkResolved(child); } }; walkResolved(resolved); } } function validateRevisionState(source, label) { const lower = label.toLowerCase(); if (/\.(?:yaml|yml)(?:\.example)?$/u.test(lower)) { validateYamlRevisionState(source, label); return; } if (lower.endsWith(".sh")) { const active = maskQuotedShellHeredocBodies(source, label); try { if (shellJqRevisionAccess(active) || shellAssociativeRevisionAccess(active)) throw new Error("forbidden revision-state access"); } catch (error) { throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`); } } if (lower.endsWith(".py") || lower.endsWith(".pyw")) throw new Error(`${label}: revision-state Python input was not batched`); const matches = revisionStateAstNodes(source, label); if (matches.length === 0) return; const historical = 'revision.state !== "operational"'; const historicalCount = source.split(historical).length - 1; const match = matches[0]; if (label === "backend/src/workspaces/registry.ts" && matches.length === 1 && match.getText() === "revision.state" && match.parent?.getText() === historical && historicalCount === 1) return; throw new Error(`${label}: forbidden revision-state access`); } const pythonHelper = fileURLToPath(new URL("./revision_state_policy.py", import.meta.url)); function validatePythonRevisionStates(records) { if (!Array.isArray(records) || records.length === 0) return; let stdout; try { stdout = execFileSync("python3", ["-I", "-B", pythonHelper], { input: JSON.stringify(records), encoding: "utf8", timeout: 5_000, maxBuffer: 4 * 1024 * 1024, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", LC_ALL: "C.UTF-8", PYTHONDONTWRITEBYTECODE: "1", }, stdio: ["pipe", "pipe", "pipe"], }); } catch (error) { const detail = error?.stderr?.toString().trim(); throw new Error(`revision-state helper failed${detail ? `: ${detail}` : ""}`); } let result; try { result = JSON.parse(stdout); } catch { throw new Error("revision-state helper failed: invalid JSON output"); } if (!result || !Array.isArray(result.violations) || result.violations.some((label) => typeof label !== "string")) throw new Error("revision-state helper failed: invalid result shape"); if (result.violations.length > 0) throw new Error(`${result.violations[0]}: forbidden revision-state access`); } export { validatePythonRevisionStates, validateRevisionState };