Files
ThothII/backend/scripts/revision-state-policy.mjs
T

944 lines
41 KiB
JavaScript

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 = "<shell>") {
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 };