test: add schema v3 only absence gate

This commit is contained in:
2026-08-11 08:09:01 +02:00
parent 5310c6555b
commit 66f44b054f
17 changed files with 4209 additions and 56 deletions
+9 -3
View File
@@ -24,6 +24,8 @@ jobs:
name: LF, Compose, docs, and TypeScript name: LF, Compose, docs, and TypeScript
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 25 timeout-minutes: 25
env:
PYTHONDONTWRITEBYTECODE: "1"
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -47,9 +49,13 @@ jobs:
bash scripts/test-preprocess-compose-config.sh bash scripts/test-preprocess-compose-config.sh
bash scripts/test-verify-workspace-install-docs.sh bash scripts/test-verify-workspace-install-docs.sh
git diff --check git diff --check
- name: Install backend dependencies - name: Assert clean checkout before release trust bootstrap
working-directory: backend run: |
run: npm ci git diff --exit-code
git diff --cached --exit-code
test -z "$(git ls-files --others --exclude-standard)"
- name: Verify schema-v3-only release gate
run: bash scripts/verify-schema-v3-only-release.sh
- name: Verify Task 13 clean-install and runtime fixtures - name: Verify Task 13 clean-install and runtime fixtures
run: | run: |
bash scripts/test-server-pi-state-topology.sh bash scripts/test-server-pi-state-topology.sh
+2 -1
View File
@@ -7,7 +7,8 @@
"prebuild": "node scripts/clean-dist.mjs", "prebuild": "node scripts/clean-dist.mjs",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"test": "vitest run", "test": "vitest run",
"start": "node dist/server.js" "start": "node dist/server.js",
"test:schema-v3-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+157
View File
@@ -0,0 +1,157 @@
/** Shared Bash heredoc word parser for descriptor extraction and policy masking. */
function physicalLines(source) {
const rawLines = source.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
if (rawLines.length === 0) rawLines.push("");
let offset = 0;
return rawLines.map((raw) => {
const record = { raw, text: raw.replace(/\n$/u, "").replace(/\r$/u, ""), start: offset };
offset += raw.length;
return record;
});
}
function heredocOperator(line) {
let quote = null;
let arithmeticDepth = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const character = line[index];
if (quote !== null) {
if (character === quote) quote = null;
else if (quote === '"' && character === "\\") index += 1;
continue;
}
if (character === "'" || character === '"') { quote = character; continue; }
if (character === "\\") { index += 1; continue; }
if (character === "#" && (index === 0 || /[ \t;|&()]/u.test(line[index - 1]))) break;
if (character === "(" && line[index + 1] === "(") { arithmeticDepth += 1; index += 1; continue; }
if (character === ")" && line[index + 1] === ")" && arithmeticDepth > 0) { arithmeticDepth -= 1; index += 1; continue; }
if (arithmeticDepth > 0 || character !== "<" || line[index + 1] !== "<") continue;
if (line[index - 1] === "<" || line[index + 2] === "<") { index += 1; continue; }
return index;
}
return -1;
}
function endsWithBashContinuation(line) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (quote === null && character === "`") { index += 1; continue; }
if (quote === "'") { if (character === "'") quote = null; continue; }
if (character === '"') { if (quote === '"') quote = null; else if (quote === null) quote = '"'; continue; }
if (character !== "\\") continue;
if (index === line.length - 1) return true;
if (quote === null || (quote === '"' && '$`"\\'.includes(line[index + 1]))) index += 1;
}
return false;
}
function bashLogicalLine(lines, start) {
let line = lines[start];
let end = start;
while (endsWithBashContinuation(line)) {
if (end + 1 >= lines.length) break;
line = `${line.slice(0, -1)}${lines[end + 1]}`;
end += 1;
}
return { line, end };
}
function bashHeredocOpener(line, operator, label, lineNumber) {
let cursor = operator + 2;
let stripTabs = false;
if (line[cursor] === "-") { stripTabs = true; cursor += 1; }
while (line[cursor] === " " || line[cursor] === "\t") cursor += 1;
const unsupported = () => { throw new Error(`${label}:${lineNumber}: unsupported Bash heredoc opener`); };
if (cursor >= line.length || line[cursor] === "#") unsupported();
let delimiter = "";
let quotedDelimiter = false;
while (cursor < line.length) {
const character = line[cursor];
if (character === " " || character === "\t" || ";|&<>".includes(character)) break;
if (character === "'" || character === '"') {
quotedDelimiter = true;
const quote = character;
cursor += 1;
let closed = false;
while (cursor < line.length) {
const quoted = line[cursor];
if (quoted === quote) { closed = true; cursor += 1; break; }
if (quote === '"' && quoted === "\\") {
cursor += 1;
if (cursor >= line.length) unsupported();
const escaped = line[cursor];
delimiter += '$`"\\'.includes(escaped) ? escaped : `\\${escaped}`;
cursor += 1;
continue;
}
delimiter += quoted;
cursor += 1;
}
if (!closed) unsupported();
continue;
}
if (character === "\\") {
quotedDelimiter = true;
cursor += 1;
if (cursor >= line.length) unsupported();
delimiter += line[cursor];
cursor += 1;
continue;
}
if (character === "$" || character === "`" || "(){}[]*?".includes(character)) unsupported();
delimiter += character;
cursor += 1;
}
if (delimiter.length === 0) unsupported();
if (heredocOperator(line.slice(cursor)) >= 0) unsupported();
return { delimiter, stripTabs, expandable: !quotedDelimiter };
}
function parsedBashHeredocs(source, label) {
const records = physicalLines(source);
const lines = records.map((record) => record.text);
const extracted = [];
for (let index = 0; index < lines.length; index += 1) {
const logical = bashLogicalLine(lines, index);
const operator = heredocOperator(logical.line);
if (operator < 0) { index = logical.end; continue; }
const opener = index;
const { delimiter, stripTabs, expandable } = bashHeredocOpener(logical.line, operator, label, index + 1);
index = logical.end;
const body = [];
const startLine = index + 2;
const bodyStart = records[index + 1]?.start ?? source.length;
let closed = false;
for (index += 1; index < lines.length; index += 1) {
const candidate = stripTabs ? lines[index].replace(/^\t+/u, "") : lines[index];
if (candidate === delimiter) { closed = true; break; }
body.push(candidate);
}
const bodyEnd = closed ? records[index].start : source.length;
extracted.push({
source: `${body.join("\n")}\n`, label: `${label}:${startLine} Bash heredoc${closed ? "" : " (unclosed)"}`,
expandable, closed, bodyStart, bodyEnd, path: label,
rawBlock: records.slice(opener, Math.min(index + 1, records.length)).map((record) => record.raw).join(""),
});
}
return extracted;
}
function extractBashDocuments(source, label) {
return parsedBashHeredocs(source, label).map(({ bodyStart: _start, bodyEnd: _end, closed: _closed, ...document }) => document);
}
function literalBashHeredocBodyRanges(source, label) {
const ranges = [];
for (const heredoc of parsedBashHeredocs(source, label)) {
if (!heredoc.expandable) {
if (!heredoc.closed) throw new Error(`${label}: revision-state policy found an unclosed literal Bash heredoc`);
ranges.push({ start: heredoc.bodyStart, end: heredoc.bodyEnd });
}
}
return ranges;
}
export { extractBashDocuments, literalBashHeredocBodyRanges };
@@ -441,7 +441,7 @@ test("generated render command binds snapshot bytes to the commit manifest and G
await writeFile(readPath,JSON.stringify({revision})); await writeFile(pullPath,JSON.stringify({head:commit})); await writeFile(readPath,JSON.stringify({revision})); await writeFile(pullPath,JSON.stringify({head:commit}));
await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest.*(missing|unbounded)/i); await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest.*(missing|unbounded)/i);
await assert.rejects(lstat(output)); await assert.rejects(lstat(output));
const legacyRevision={...revision}; legacyRevision.state=["oper","ational"].join(""); const legacyRevision={...revision}; legacyRevision[["st","ate"].join("")]=["oper","ational"].join("");
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest(legacyRevision))); await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest(legacyRevision)));
await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest revision is invalid/); await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest revision is invalid/);
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest({...revision,unexpected:"field"}))); await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest({...revision,unexpected:"field"})));
+943
View File
@@ -0,0 +1,943 @@
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 };
@@ -0,0 +1,273 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { validatePythonRevisionStates, validateRevisionState } from "./revision-state-policy.mjs";
function rejects(source, path) {
assert.throws(() => validateRevisionState(source, path), /revision-state/, source);
}
function passes(source, path) {
assert.doesNotThrow(() => validateRevisionState(source, path));
}
test("PowerShell scoped and braced revision variables remain executable", () => {
rejects('${revision}.state', "scripts/direct.ps1");
rejects('${workspaceRevision}["state"]', "scripts/bracket.ps1");
rejects('Write-Output "$(${selectedWorkspace}.state)"', "scripts/subexpression.ps1");
rejects('${script:revision}.state', "scripts/scoped.ps1");
rejects('${global:workspaceRevision}["state"]', "scripts/global.ps1");
for (const source of [
'$REVISION.STATE',
'${Revision}.state',
'$WORKSPACEREVISION["STATE"]',
'${GLOBAL:SELECTEDWORKSPACE}.State',
'$REVISION["ST" + "ATE"]',
'${Revision}[("sT" + "AtE")]',
'$record.REVISION.STATE',
'$record.WORKSPACEREVISION["STATE"]',
'$record["REVISION"].STATE',
]) rejects(source, "scripts/case.ps1");
passes('REVISION.STATE; revision.STATE; revision["ST" + "ATE"]; record.REVISION.STATE; record["REVISION"].state', "backend/src/case-sensitive.ts");
});
test("Bash jq command forms and associative revision parameters are active", () => {
for (const source of [
"value=$(jq -r '.revision.state' snapshot.json)",
"value=$(command jq -r '.workspaceRevision.state' snapshot.json)",
"/usr/bin/jq --arg x y '.selectedWorkspace.state' snapshot.json",
"env -i MODE=x jq -- '.revision.state' snapshot.json",
"env -u MODE /opt/tools/jq -r '.workspaceRevision.state' snapshot.json",
"echo safe\nvalue=`jq -r '.selectedWorkspace.state' snapshot.json`",
"sudo -u nobody /usr/bin/jq -r '.revision.state' snapshot.json",
"nice -n 5 jq -r '.workspaceRevision.state' snapshot.json",
"time jq -r '.selectedWorkspace.state' snapshot.json",
"printf x | xargs -n 1 jq -r '.revision.state'",
"timeout -k 2 5 jq -r '.revision.state' snapshot.json",
`stdbuf -o L jq -r '.["workspaceRevision"].state' snapshot.json`,
`stdbuf -oL jq -r '.["selectedWorkspace"]["state"]' snapshot.json`,
`nohup jq -r '.revision["state"]' snapshot.json`,
"< snapshot.json jq -r '.workspaceRevision.state'",
"sudo MODE=x jq -r '.selectedWorkspace.state' snapshot.json",
String.raw`jq -r '"x \(.revision.state)"' snapshot.json`,
"jq < snapshot.json -r '.revision.state'",
"jq -r < snapshot.json '.workspaceRevision.state'",
"jq --arg note safe < snapshot.json '.selectedWorkspace.state'",
"jq -r '.revision?.state' snapshot.json",
`jq -r '.["workspaceRevision"]?["state"]' snapshot.json`,
`jq -r '.["revision"]?.["state"]' snapshot.json`,
`jq -r '."revision".state' snapshot.json`,
`jq -r '."workspaceRevision"."state"' snapshot.json`,
"jq -r '$revision.state' snapshot.json",
"jq -r '($selectedWorkspace).state' snapshot.json",
`${"env ".repeat(17)}jq -r '.revision.state' snapshot.json`,
"jq<input.json -r '.revision.state'",
"jq 2>/dev/null -r '.workspaceRevision.state' snapshot.json",
"{ jq -r '.selectedWorkspace.state' snapshot.json; }",
"! jq -r '.revision.state' snapshot.json",
"if jq -r '.workspaceRevision.state' snapshot.json; then :; fi",
"if false; then :; elif jq -r '.selectedWorkspace.state' snapshot.json; then :; fi",
"while false; do jq -r '.revision.state' snapshot.json; done",
"until false; do jq -r '.workspaceRevision.state' snapshot.json; done",
"jq -r '.revision | .state' snapshot.json",
"jq -r '(.workspaceRevision | .state)' snapshot.json",
`jq -r '.["revi" + "sion"].state' snapshot.json`,
`jq -r '.["workspace" + "Revision"]["st" + "ate"]' snapshot.json`,
"jq 2>&1 -r '.revision.state' snapshot.json",
"jq 2>&- -r '.workspaceRevision.state' snapshot.json",
"jq 0<&3 -r '.selectedWorkspace.state' snapshot.json",
"jq &>/dev/null -r '.revision.state' snapshot.json",
"jq &>>log -r '.workspaceRevision.state' snapshot.json",
"jq >|output -r '.selectedWorkspace.state' snapshot.json",
"jq {fd}>output -r '.revision.state' snapshot.json",
"exec jq -r '.workspaceRevision.state' snapshot.json",
"coproc jq -r '.selectedWorkspace.state' snapshot.json",
"coproc worker jq -r '.revision.state' snapshot.json",
"coproc worker >out jq -r '.workspaceRevision.state' snapshot.json",
"coproc worker 2>/dev/null jq -r '.selectedWorkspace.state' snapshot.json",
"coproc worker VAR=x jq -r '.revision.state' snapshot.json",
`jq -r '.["revi" + ("sion")].state' snapshot.json`,
"jq -r '.revision | . | .state' snapshot.json",
"jq -r '(.workspaceRevision | (.) | .state)' snapshot.json",
"jq -r '.revision | select(.) | .state' snapshot.json",
"jq -r '.workspaceRevision | {value:.state}' snapshot.json",
"jq -r '.selectedWorkspace | [.state]' snapshot.json",
"jq -r '.revision + .state' snapshot.json",
"jq < <(cat snapshot.json) -r '.revision.state'",
"jq < <(cat <(printf snapshot.json)) -r '.workspaceRevision.state'",
"jq > >(cat >/dev/null) -r '.selectedWorkspace.state' snapshot.json",
`jq < <(printf '%s\n' "$((1 + (2)))") -r '.revision.state'`,
"jq < <(cat snapshot.json -r '.revision.state'",
`${"<(".repeat(65)}echo snapshot${")".repeat(65)} jq -r '.workspaceRevision.state'`,
"cat <(jq -r '.revision.state' snapshot.json)",
"cat snapshot.json > >(jq -r '.workspaceRevision.state')",
`echo "$(jq -r '.selectedWorkspace.state' snapshot.json)"`,
"value=$(jq -r '.revision.state' snapshot.json)",
`echo "\`jq -r '.workspaceRevision.state' snapshot.json\`"`,
`echo "$(cat <(jq -r '.selectedWorkspace.state' snapshot.json))"`,
`${"$(".repeat(33)}jq -r '.revision.state' snapshot.json${")".repeat(33)}`,
`echo "$(( $(jq -r '.revision.state' snapshot.json) + 0 ))"`,
"echo \"$(( `jq -r '.workspaceRevision.state' snapshot.json` + 0 ))\"",
"echo `echo \\`jq -r '.selectedWorkspace.state' snapshot.json\\``",
"echo \"`echo \\`jq -r '.revision.state' snapshot.json\\``\"",
`${"$(( ".repeat(33)}$(jq -r '.workspaceRevision.state' snapshot.json)${" + 0 ))".repeat(33)}`,
'old=${revision["state"]}',
"old=${workspaceRevision[state]}",
"old=${revision[state]:-missing}",
"old=${workspaceRevision['state']:=missing}",
"old=${selectedWorkspace[state]:1:2}",
]) rejects(source, "scripts/policy.sh");
const jqFilters = [
".revision?.state", '.["revision"]?.["state"]', '."revision".state',
'."workspaceRevision"."state"', "(.revision).state", "$revision.state",
".revision | .state", "(.workspaceRevision | .state)",
'.["revi" + "sion"].state', '.["revi" + ("sion")].state',
".revision | . | .state", "(.workspaceRevision | (.) | .state)",
".revision | select(.) | .state", ".workspaceRevision | {value:.state}",
'.revision | ["state"]', '(.workspaceRevision | (["state"]))', ".selectedWorkspace | $state",
];
for (const filter of jqFilters) {
const compiled = spawnSync("jq", ["-n", "--argjson", "revision", "{}", "--arg", "state", "x", filter], { encoding: "utf8" });
if (compiled.error?.code !== "ENOENT") assert.equal(compiled.status, 0, `${filter}: ${compiled.stderr}`);
}
passes("cat <<'EOF'\nrevision.state\nEOF\n", "scripts/literal.sh");
passes("echo '${revision[state]}'\n", "scripts/single-quoted-parameter.sh");
passes(`echo "<(jq '.revision.state')"\n`, "scripts/literal-process-text.sh");
passes(`echo "ordinary jq '.workspaceRevision.state' text"\n`, "scripts/literal-jq-text.sh");
passes(`echo '$(jq -r ".selectedWorkspace.state")'\n`, "scripts/single-quoted-command-text.sh");
passes(`# profile's harmless note
printf 'ok\n'
`, "scripts/comment-apostrophe.sh");
passes(`cat <( # profile's harmless note
printf 'snapshot\n'
)
`, "scripts/substitution-comment-apostrophe.sh");
passes(`echo "$(( 1 + (2 * 3) ))"\n`, "scripts/literal-arithmetic.sh");
passes(`echo $(( jq + revision + state ))\n`, "scripts/arithmetic-identifiers.sh");
passes("echo \\`jq -r '.revision.state' snapshot.json\\`\n", "scripts/escaped-literal-backticks.sh");
passes("echo \"\\`jq -r '.workspaceRevision.state' snapshot.json\\`\"\n", "scripts/double-quoted-literal-backticks.sh");
passes("echo `printf '%s' '\\`jq -r \".selectedWorkspace.state\" snapshot.json\\`'`\n", "scripts/quoted-nonexecuting-nested-backticks.sh");
passes("jq --arg note 'revision.state' '.' file\n", "scripts/jq-arg.sh");
passes(`jq --argjson note '"revision.state"' '.' file
`, "scripts/jq-argjson.sh");
passes("jq -r '.' revision.state.json\n", "scripts/jq-file.sh");
passes("jq -r '.revision.id' snapshot.json\n", "scripts/jq-simple-non-state.sh");
passes("jq -f revision.state.jq snapshot.json\n", "scripts/jq-from-file.sh");
passes("jq --from-file workspaceRevision.state.jq snapshot.json\n", "scripts/jq-long-from-file.sh");
passes(`jq -r '"revision.state"' snapshot.json
`, "scripts/jq-string.sh");
passes(`jq -r '{note:"selectedWorkspace.state"}' snapshot.json
`, "scripts/jq-object.sh");
passes(`jq -r '.revision | "state"' snapshot.json
`, "scripts/jq-pipe-literal-right.sh");
passes(`jq -r '"revision" | .state' snapshot.json
`, "scripts/jq-pipe-literal-left.sh");
passes(`jq -r '.revision | ["state"]' snapshot.json
`, "scripts/jq-pipe-array.sh");
passes(`jq -r '(.workspaceRevision | (["state"]))' snapshot.json
`, "scripts/jq-pipe-parenthesized-array.sh");
passes(`jq --arg state x '.selectedWorkspace | $state' snapshot.json
`, "scripts/jq-pipe-variable.sh");
for (const opener of ["'E'OF", "E'OF'", "E\\OF"]) {
passes(`cat <<${opener}
revision.state
EOF
`, "scripts/partial-quoted-heredoc.sh");
}
rejects("cat <<'E'OF\nrevision.state\nEOF\nworkspaceRevision.state\n", "scripts/after-heredoc.sh");
rejects("cat <<'EOF'\nrevision.state\n", "scripts/unclosed-heredoc.sh");
rejects(`echo "<<'EOF'"
jq -r '.revision.state' snapshot.json
`, "scripts/quoted-opener.sh");
});
test("Python helper resolves active AST expressions and static format bindings", () => {
const rejectsPython = (source) => assert.throws(
() => validatePythonRevisionStates([{ source, label: "backend/scripts/policy.py" }]),
/revision-state/,
);
for (const source of [
"old = revision.state",
'old = workspaceRevision["state"]',
'old = record["selectedWorkspace"].state',
'old = f"{revision.state}"',
'"{revision.state}".format(value)',
'"{0.state}".format(revision)',
'"{0[state]}".format(workspaceRevision)',
'"{item.state}".format(item=selectedWorkspace)',
'"{item[state]}".format_map({"item": revision})',
'("{0.state}").format(revision)',
'"{0:{1.state}}".format(value, revision)',
'old = revision["st" + "ate"]',
'old = record["revi" + "sion"].state',
'old = revision[f"state"]',
'old = record[f"revision"].state',
`old = revision[f"st{'a'}te"]`,
`old = revision[f"{'state'}"]`,
`old = record[f"revi{'sion'}"].state`,
`old = revision[f"{'st' + 'ate'}"]`,
`old = record[f"{'revi' + 'sion'}"].state`,
`old = revision[f"{'state':s}"]`,
'"{0.state}".format(*[revision])',
'"{0[state]}".format(*(revision,))',
'"{1[state]}".format(*[other, workspaceRevision])',
'"{item.state}".format(**{"item": selectedWorkspace})',
'"{item[state]}".format_map({**{"item": revision}})',
'"{.state}".format(revision)',
'"{[state]}".format(revision)',
'"{:{.state}}".format(value, revision)',
'"{.name} {[state]}".format(other, revision)',
'"{item.state}".format(item=revision, **values)',
]) rejectsPython(source);
validatePythonRevisionStates([
{ source: 'text = "{revision.state}"', label: "backend/scripts/literal.py" },
{ source: 'text = "{{revision.state}}".format(value)', label: "backend/scripts/escaped.py" },
{ source: 'text = "{0.state}".format(other)', label: "backend/scripts/unrelated.py" },
{ source: 'old = revision[f"st{suffix}"]', label: "backend/scripts/dynamic-key.py" },
{ source: 'text = "{.name} {[state]}".format(other, other)', label: "backend/scripts/multi-auto.py" },
{ source: 'text = "{item.state}".format(**values)', label: "backend/scripts/dynamic-map.py" },
]);
const hostile = mkdtempSync(join(tmpdir(), "revision-policy-hostile-"));
writeFileSync(join(hostile, "json.py"), "raise RuntimeError('shadowed')\n");
const previousPythonPath = process.env.PYTHONPATH;
try {
process.env.PYTHONPATH = hostile;
validatePythonRevisionStates([{ source: "value = 1", label: "backend/scripts/isolated.py" }]);
} finally {
if (previousPythonPath === undefined) delete process.env.PYTHONPATH;
else process.env.PYTHONPATH = previousPythonPath;
rmSync(hostile, { recursive: true, force: true });
}
assert.throws(
() => validatePythonRevisionStates([{ source: 'revision[f"{1:.1000000000f}"]', label: "backend/scripts/oversized.py" }]),
/revision-state helper failed/,
);
validatePythonRevisionStates([{ source: 'revision[f"{1:04d}"]', label: "backend/scripts/small-format.py" }]);
assert.throws(
() => validatePythonRevisionStates([{ source: "def broken(", label: "backend/scripts/invalid.py" }]),
/revision-state helper failed/,
);
});
test("YAML mappings and only active plain scalar expressions are rejected", () => {
for (const source of [
"value: { revision: { state: old } }\n",
"value:\n workspaceRevision:\n state: old\n",
'items:\n - "selectedWorkspace":\n "state": old\n',
"old: selectedWorkspace.state\n",
"url: https://host/x; old: selectedWorkspace.state\n",
"saved: &saved { state: old }\nvalue: { revision: *saved }\n",
"defaults: &defaults { workspaceRevision: { state: old } }\nvalue: { <<: *defaults }\n",
]) rejects(source, "scripts/policy.yaml");
rejects("a: &a [x,x,x,x,x,x,x,x,x]\nb: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\nc: [*b,*b,*b,*b,*b,*b,*b,*b,*b]\n", "scripts/alias-bomb.yaml");
rejects("value: [\n", "scripts/invalid.yaml");
for (const source of [
"value: |\n revision.state\n",
"value: >\n workspaceRevision.state\n",
'value: "selectedWorkspace.state"\n',
"url: https://host/revision.state\n",
]) passes(source, "scripts/literal.yaml");
});
+318
View File
@@ -0,0 +1,318 @@
"""Semantic Python revision-state policy helper.
Reads one JSON array of ``{"label": str, "source": str}`` records from stdin and
writes ``{"violations": [label, ...]}``. Invalid input or Python source is fatal.
"""
from __future__ import annotations
import ast
import json
import re
import string
import sys
from itertools import pairwise
from typing import Any
TARGETS = frozenset({"revision", "workspaceRevision", "selectedWorkspace"})
_FORMATTER = string.Formatter()
MAX_STATIC_TEXT = 4_096
MAX_FORMAT_SPEC = 256
MAX_STATIC_DEPTH = 64
_UNRESOLVED = object()
def _bounded_text(value: str) -> str:
if len(value) > MAX_STATIC_TEXT:
raise ValueError("static text exceeds revision policy limit")
return value
def _static_scalar(node: ast.expr, depth: int) -> object:
if depth > MAX_STATIC_DEPTH:
raise ValueError("static expression nesting exceeds revision policy limit")
if isinstance(node, ast.Constant) and type(node.value) in {
str,
int,
float,
complex,
bool,
type(None),
}:
if isinstance(node.value, str):
_bounded_text(node.value)
if isinstance(node.value, int) and node.value.bit_length() > MAX_STATIC_TEXT * 4:
raise ValueError("static integer exceeds revision policy limit")
return node.value
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _static_scalar(node.left, depth + 1)
right = _static_scalar(node.right, depth + 1)
if left is _UNRESOLVED or right is _UNRESOLVED:
return _UNRESOLVED
try:
result = left + right
except TypeError:
return _UNRESOLVED
if type(result) not in {str, int, float, complex, bool}:
return _UNRESOLVED
if isinstance(result, str):
_bounded_text(result)
if isinstance(result, int) and result.bit_length() > MAX_STATIC_TEXT * 4:
raise ValueError("static integer exceeds revision policy limit")
return result
if isinstance(node, ast.JoinedStr):
result = _static_key(node, depth + 1)
return _UNRESOLVED if result is None else result
return _UNRESOLVED
def _validate_format_spec(format_spec: str) -> None:
if len(format_spec) > MAX_FORMAT_SPEC:
raise ValueError("static format specification exceeds revision policy limit")
for digits in re.findall(r"[0-9]+", format_spec):
if len(digits) > 6 or int(digits) > MAX_STATIC_TEXT:
raise ValueError("static format width or precision exceeds revision policy limit")
def _static_key(node: ast.expr, depth: int = 0) -> str | None:
if depth > MAX_STATIC_DEPTH:
raise ValueError("static key nesting exceeds revision policy limit")
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return _bounded_text(node.value)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _static_key(node.left, depth + 1)
right = _static_key(node.right, depth + 1)
return None if left is None or right is None else _bounded_text(left + right)
if isinstance(node, ast.JoinedStr):
pieces = []
length = 0
for value in node.values:
if isinstance(value, ast.Constant) and isinstance(value.value, str):
piece = value.value
elif isinstance(value, ast.FormattedValue):
scalar = _static_scalar(value.value, depth + 1)
if scalar is _UNRESOLVED:
return None
format_spec = "" if value.format_spec is None else _static_key(value.format_spec, depth + 1)
if format_spec is None:
return None
_validate_format_spec(format_spec)
try:
if value.conversion == ord("s"):
scalar = str(scalar)
elif value.conversion == ord("r"):
scalar = repr(scalar)
elif value.conversion == ord("a"):
scalar = ascii(scalar)
elif value.conversion != -1:
return None
piece = format(scalar, format_spec)
except (TypeError, ValueError):
return None
else:
return None
length += len(piece)
if length > MAX_STATIC_TEXT:
raise ValueError("static formatted key exceeds revision policy limit")
pieces.append(piece)
return "".join(pieces)
return None
def _is_revision_expr(node: ast.expr) -> bool:
if isinstance(node, ast.Name):
return node.id in TARGETS
if isinstance(node, ast.Attribute):
return node.attr in TARGETS
if isinstance(node, ast.Subscript):
return _static_key(node.slice) in TARGETS
return False
def _is_state_access(node: ast.AST) -> bool:
if isinstance(node, ast.Attribute):
return node.attr == "state" and _is_revision_expr(node.value)
if isinstance(node, ast.Subscript):
return _static_key(node.slice) == "state" and _is_revision_expr(node.value)
return False
def _static_sequence(node: ast.expr) -> list[ast.expr] | None:
if not isinstance(node, (ast.List, ast.Tuple)):
return None
result: list[ast.expr] = []
for element in node.elts:
if isinstance(element, ast.Starred):
nested = _static_sequence(element.value)
if nested is None:
return None
result.extend(nested)
else:
result.append(element)
return result
def _static_mapping(node: ast.expr) -> dict[str, ast.expr] | None:
if not isinstance(node, ast.Dict):
return None
result: dict[str, ast.expr] = {}
for key, value in zip(node.keys, node.values, strict=True):
if key is None:
nested = _static_mapping(value)
if nested is None:
return None
result.update(nested)
elif (name := _static_key(key)) is not None:
result[name] = value
else:
return None
return result
def _format_bindings(call: ast.Call, method: str) -> dict[str | int, ast.expr]:
if method == "format":
bindings: dict[str | int, ast.expr] = {}
position = 0
positional_known = True
for argument in call.args:
if isinstance(argument, ast.Starred):
expanded = _static_sequence(argument.value)
if expanded is None:
positional_known = False
continue
if positional_known:
for value in expanded:
bindings[position] = value
position += 1
elif positional_known:
bindings[position] = argument
position += 1
for keyword in call.keywords:
if keyword.arg is not None:
# An explicit keyword remains bound even beside **dynamic; a duplicate is TypeError.
bindings[keyword.arg] = keyword.value
else:
expanded = _static_mapping(keyword.value)
if expanded is not None:
bindings.update(expanded)
return bindings
if len(call.args) != 1 or call.keywords:
return {}
return _static_mapping(call.args[0]) or {}
def _field_accesses_state(
field_name: str, bindings: dict[str | int, ast.expr], automatic_index: int | None = None
) -> bool:
root_match = re.match(r"(?:[0-9]+|[A-Za-z_][A-Za-z0-9_]*)", field_name)
if root_match is None:
if automatic_index is None or not field_name.startswith((".", "[")):
return False
root: str | int = automatic_index
cursor = 0
else:
root_text = root_match.group(0)
root = int(root_text) if root_text.isdigit() else root_text
cursor = root_match.end()
steps: list[tuple[bool, str]] = []
while cursor < len(field_name):
if field_name[cursor] == ".":
match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", field_name[cursor + 1 :])
if match is None:
return False
steps.append((True, match.group(0)))
cursor += len(match.group(0)) + 1
elif field_name[cursor] == "[":
close = field_name.find("]", cursor + 1)
if close < 0:
return False
steps.append((False, field_name[cursor + 1 : close]))
cursor = close + 1
else:
return False
if steps:
first_step = str(steps[0][1])
if str(root) in TARGETS and first_step == "state":
return True
bound = bindings.get(root)
if bound is not None and _is_revision_expr(bound) and first_step == "state":
return True
names = [str(root), *(str(key) for _is_attr, key in steps)]
return any(left in TARGETS and right == "state" for left, right in pairwise(names))
def _format_call_violation(node: ast.Call) -> bool:
function = node.func
if not isinstance(function, ast.Attribute) or function.attr not in {"format", "format_map"}:
return False
if not isinstance(function.value, ast.Constant) or not isinstance(function.value.value, str):
return False
bindings = _format_bindings(node, function.attr)
numbering: dict[str, int | str | None] = {"next": 0, "mode": None}
visited = 0
def analyze_template(template: str) -> bool:
nonlocal visited
visited += 1
if visited > 1_000:
raise ValueError("format specification nesting exceeds policy limit")
for _literal, field_name, format_spec, _conversion in _FORMATTER.parse(template):
automatic_index = None
if field_name is not None:
root_match = re.match(r"(?:[0-9]+|[A-Za-z_][A-Za-z0-9_]*)", field_name)
automatic = field_name == "" or root_match is None and field_name.startswith((".", "["))
manual = root_match is not None and root_match.group(0).isdigit()
if automatic:
if numbering["mode"] == "manual":
raise ValueError("cannot switch from manual to automatic field numbering")
numbering["mode"] = "automatic"
automatic_index = int(numbering["next"])
numbering["next"] = automatic_index + 1
elif manual:
if numbering["mode"] == "automatic":
raise ValueError("cannot switch from automatic to manual field numbering")
numbering["mode"] = "manual"
if _field_accesses_state(field_name, bindings, automatic_index):
return True
if format_spec and analyze_template(format_spec):
return True
return False
return analyze_template(function.value.value)
def has_revision_state(source: str, label: str = "<unknown>") -> bool:
tree = ast.parse(source, filename=label, mode="exec")
return any(_is_state_access(node) or (isinstance(node, ast.Call) and _format_call_violation(node)) for node in ast.walk(tree))
def analyze_batch(records: Any) -> list[str]:
if not isinstance(records, list):
raise TypeError("input must be a JSON array")
violations = []
for record in records:
if not isinstance(record, dict) or set(record) != {"label", "source"}:
raise TypeError("each record must contain exactly label and source")
label, source = record["label"], record["source"]
if not isinstance(label, str) or not isinstance(source, str):
raise TypeError("label and source must be strings")
if has_revision_state(source, label):
violations.append(label)
return violations
def main() -> int:
try:
records = json.load(sys.stdin)
json.dump({"violations": analyze_batch(records)}, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
return 0
except Exception as error: # noqa: BLE001 - protocol boundary must fail closed
print(f"python revision-state helper failed: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,90 @@
import importlib.util
import tracemalloc
import unittest
from pathlib import Path
from unittest.mock import patch
_HELPER = Path(__file__).with_name("revision_state_policy.py")
_SPEC = importlib.util.spec_from_file_location("revision_state_policy", _HELPER)
assert _SPEC is not None and _SPEC.loader is not None
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
analyze_batch = _MODULE.analyze_batch
has_revision_state = _MODULE.has_revision_state
class RevisionStatePolicyTests(unittest.TestCase):
def test_ast_access_and_f_strings(self):
for source in (
"old = revision.state",
'old = workspaceRevision["state"]',
'old = record["selectedWorkspace"].state',
'old = f"{revision.state}"',
'old = revision["st" + "ate"]',
'old = record["revi" + "sion"].state',
'old = revision[f"state"]',
'old = record[f"revision"].state',
"old = revision[f\"st{'a'}te\"]",
"old = revision[f\"{'state'}\"]",
"old = record[f\"revi{'sion'}\"].state",
"old = revision[f\"{'st' + 'ate'}\"]",
"old = record[f\"{'revi' + 'sion'}\"].state",
"old = revision[f\"{'state':s}\"]",
):
with self.subTest(source=source):
self.assertTrue(has_revision_state(source))
def test_static_format_bindings(self):
for source in (
'"{0.state}".format(revision)',
'"{0[state]}".format(workspaceRevision)',
'"{item.state}".format(item=selectedWorkspace)',
'"{item[state]}".format_map({"item": revision})',
'("{0.state}").format(revision)',
'"{0:{1.state}}".format(value, revision)',
'"{0.state}".format(*[revision])',
'"{0[state]}".format(*(revision,))',
'"{1[state]}".format(*[other, workspaceRevision])',
'"{item.state}".format(**{"item": selectedWorkspace})',
'"{item[state]}".format(**{"outer": other, **{"item": revision}})',
'"{item.state}".format_map({**{"item": workspaceRevision}})',
'"{.state}".format(revision)',
'"{[state]}".format(revision)',
'"{:{.state}}".format(value, revision)',
'"{.name} {[state]}".format(other, revision)',
'"{item.state}".format(item=revision, **values)',
):
with self.subTest(source=source):
self.assertTrue(has_revision_state(source))
self.assertFalse(has_revision_state('"{0.state}".format(other)'))
# Dynamic unpacking is intentionally unresolved rather than guessed.
self.assertFalse(has_revision_state('"{0.state}".format(*values)'))
self.assertFalse(has_revision_state('"{.name} {[state]}".format(other, other)'))
self.assertFalse(has_revision_state('"{item.state}".format(**values)'))
# FormattedValue keys are dynamic and are not treated as static strings.
self.assertFalse(has_revision_state('revision[f"st{suffix}"]'))
def test_literals_are_not_active(self):
self.assertFalse(has_revision_state('text = "{revision.state}"'))
self.assertFalse(has_revision_state('text = "{{revision.state}}".format(value)'))
def test_oversized_static_format_fails_before_formatting(self):
tracemalloc.start()
with patch("builtins.format") as format_mock:
with self.assertRaisesRegex(ValueError, "width or precision"):
has_revision_state('revision[f"{1:.1000000000f}"]')
format_mock.assert_not_called()
_current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
self.assertLess(peak, 1_000_000)
self.assertFalse(has_revision_state('revision[f"{1:04d}"]'))
def test_batch_contract(self):
self.assertEqual(
analyze_batch([{"label": "one.py", "source": "revision.state"}]),
["one.py"],
)
if __name__ == "__main__":
unittest.main()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { lstat, readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isMap, isScalar, parseAllDocuments } from "yaml";
import { extractBashDocuments } from "./bash-heredoc.mjs";
import { validatePythonRevisionStates, validateRevisionState } from "./revision-state-policy.mjs";
import { parseWorkspaceYaml } from "../dist/workspaces/schema.js";
const scriptPath = fileURLToPath(import.meta.url);
const allowedKinds = new Set(["policy_text", "workspace_descriptor", "deployment_script"]);
// Exact-content trust exceptions. Each digest covers the raw UTF-8 bytes from the
// opener line through the closer line (including physical line endings). These
// blocks are reviewed non-workspace runtime/config generation, not semantic proof.
const reviewedExpandableBlocks = new Map([
["scripts/preprocess-smoke.sh", [
{ sha256: "fc530dc721c946644ab6552bbd46b7918d6c5f11f06f3495b6ea1fcda819b38d", rationale: "Generates the reviewed preprocess Compose override." },
]],
["scripts/test-server-pi-state-topology.sh", [
{ sha256: "6f746f7e8442b0a6ea0e216607a6a923d94b24cd8fa17fa2d1dac56e6f14f7ef", rationale: "Generates the isolated server topology test environment." },
]],
["scripts/test-vector-backup-restore-safety.sh", [
{ sha256: "40b8a10a3c06aaa98e324fbf688b7d1f5cead330d7ba7eef98e06256d412a85a", rationale: "Generates the reviewed restore safety manifest." },
]],
["scripts/test-windows-clone-contract.ps1", [
{ sha256: "80f4880576a0679cb58e7b92600e7a90550c93c254553a2d4b299539f9ff0bcf", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "6166294bdc8a8bf6436ad402bcbf7cae0f3b67dc6051cecfcca79267a62b082c", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
{ sha256: "3216201d59400ed7d1ec23e536634b8235a2e78b336e45b4dc598624920f0057", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "a4044bb38b27e8120e90d65a0695fe0afd7757c067ae8dd67f170edf569a1de0", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
{ sha256: "5d0d1a3fc45e99b3aacaf4ee5dd09a6bee1937784375dfe4bcfaa4ae32cfb9de", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "b903e5dae953ae1372f1a5276f12a92ed3dd632b897f3afe5e00c646d90a1b42", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
]],
["scripts/unified-deployment-smoke.sh", [
{ sha256: "ca0c17d9ff8dc0fbe018fc1c5510eb33bc667a936fbe44a9be2d311390576825", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "31ec00cc315b52da4a3bb6e3fba2d40aef29cdcd090bbc5d14c31f1aebbcfd04", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "d92822815357ce3424e1a6eb43923df2b37b4fd93a3b5465ee9dfc69559ab0ed", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "d6b8b7b951936c0452a485e9ee3b18a61251556581d6f7a2ce66f994b5700695", rationale: "Generates reviewed Task 13 runtime configuration." },
]],
["scripts/vector-backup.sh", [
{ sha256: "571899db49dfdcec8107fbe1e0a86a61e7581979d3c4c248c20546843e275bcf", rationale: "Generates the reviewed backup manifest inside the helper command." },
]],
["scripts/vector-restore.sh", [
{ sha256: "f04d872e556a7323583c6e620b25814fb6a8e2568a9a555623978185b473a49d", rationale: "Feeds reviewed parsed manifest values to read loops." },
{ sha256: "c6053ed44abae71ae4821b68f9a513f8070947350e30d89ae0f65bf4a48f66fd", rationale: "Feeds reviewed parsed manifest values to read loops." },
]],
]);
function blockDigest(rawBlock) {
return createHash("sha256").update(rawBlock, "utf8").digest("hex");
}
function reviewedExpandableBlock(path, rawBlock) {
const digest = blockDigest(rawBlock);
return (reviewedExpandableBlocks.get(path) ?? []).some((review) => review.sha256 === digest);
}
function hasAmbiguousExpansion(source, path) {
const powershell = path.endsWith(".ps1");
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (powershell && character === "`") {
index += 1;
continue;
}
if (!powershell && character === "\\") {
index += 1;
continue;
}
if (character === "$" || (!powershell && character === "`")) return true;
}
return false;
}
function physicalLines(source) {
const rawLines = source.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
if (rawLines.length === 0) rawLines.push("");
return rawLines.map((raw) => ({ raw, text: raw.replace(/\n$/u, "").replace(/\r$/u, "") }));
}
const prescribedSymbols = [
"WorkspaceV1", "WorkspaceV2", "DeprecatedV2Descriptor", "LegacyMigrationResult",
"LegacyMigrationOptions", "WorkspaceV2MigrationInput", "migrateLegacyWorkspace",
"writeMigratedWorkspace", "migrateWorkspaceV1ToV2", "migrateWorkspaceV2ToV3",
];
const migrationMarkers = ["migration_required", "deprecated-v2-descriptor", "migrate-legacy", "migrate-v2-qdrant"];
function isPolicyImplementationException(label, category) {
const implementations = new Set([
"scripts/verify-schema-v3-only.sh",
"scripts/test-verify-schema-v3-only.sh",
"backend/scripts/verify-workspace-descriptor-files.mjs",
"backend/scripts/verify-workspace-descriptor-files.test.mjs",
"backend/scripts/revision-state-policy.mjs",
"backend/scripts/revision-state-policy.test.mjs",
"backend/scripts/bash-heredoc.mjs",
"backend/scripts/revision_state_policy.py",
"backend/scripts/test_revision_state_policy.py",
]);
if (implementations.has(label)) return true;
if (category === "migration-marker" && new Set([
"scripts/workspace_descriptor_doc_contract.py",
"scripts/test_workspace_descriptor_doc_contract.py",
"backend/scripts/clean-dist.test.mjs",
]).has(label)) return true;
return false;
}
function validatePolicySource(source, label) {
if (!isPolicyImplementationException(label, "prescribed-symbol")) {
for (const symbol of prescribedSymbols) {
if (source.toLowerCase().includes(symbol.toLowerCase())) throw new Error(`${label}: forbidden prescribed-symbol substring: ${symbol}`);
}
}
if (!isPolicyImplementationException(label, "migration-marker")) {
for (const marker of migrationMarkers) {
if (source.toLowerCase().includes(marker.toLowerCase())) throw new Error(`${label}: forbidden migration-marker substring: ${marker}`);
}
}
if (!isPolicyImplementationException(label, "legacy-workspace")) {
for (const match of source.matchAll(/legacyworkspace/giu)) {
if (match[0] !== "legacyWorkspace") throw new Error(`${label}: forbidden legacy-workspace spelling: ${match[0]}`);
}
}
if (!/\.pyw?$/iu.test(label) && !isPolicyImplementationException(label, "revision-state")) validateRevisionState(source, label);
}
function documentShape(document) {
const shape = { workspacePresent: false, workspaceMapping: false };
if (!isMap(document.contents)) return shape;
for (const pair of document.contents.items) {
if (!isScalar(pair.key)) continue;
if (pair.key.value === "workspace") {
shape.workspacePresent = true;
if (isMap(pair.value)) shape.workspaceMapping = true;
}
}
return shape;
}
function documents(source) {
try {
return parseAllDocuments(source, { uniqueKeys: true });
} catch (error) {
throw new Error(`YAML parser failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
function validateWorkspaceSource(source, label, { requireWorkspace, expandable = false, path, rawBlock }) {
const parsed = documents(source);
const shapes = parsed.map(documentShape);
if (requireWorkspace) {
if (!shapes.some((shape) => shape.workspacePresent)) {
throw new Error(`${label}: expected a top-level workspace mapping`);
}
if (!shapes.some((shape) => shape.workspaceMapping)) {
throw new Error(`${label}: top-level workspace must be a mapping`);
}
} else {
if (expandable && hasAmbiguousExpansion(source, path) && !reviewedExpandableBlock(path, rawBlock)) {
throw new Error(`${label}: expandable block interpolation is not in the exact-content reviewed allowlist`);
}
if (shapes.some((shape) => shape.workspaceMapping)) {
throw new Error(`${label}: embedded workspace descriptor is forbidden; use a tracked workspace fixture`);
}
return false;
}
try {
parseWorkspaceYaml(source);
} catch (error) {
throw new Error(`${label}: workspace descriptor is not valid schema v3: ${error instanceof Error ? error.message : String(error)}`);
}
return true;
}
function deploymentScriptDialect(path) {
if (path.endsWith(".sh")) return "bash";
if (path.endsWith(".ps1")) return "powershell";
throw new Error(`${path}: unknown deployment script dialect`);
}
function powerShellHereStringOpener(line, state) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
if (state.blockComment) {
const close = line.indexOf("#>", index);
if (close < 0) return null;
state.blockComment = false;
index = close + 1;
continue;
}
const character = line[index];
if (quote === null && character === "`") {
index += 1;
continue;
}
if (quote === "'") {
if (character === "'" && line[index + 1] === "'") index += 1;
else if (character === "'") quote = null;
continue;
}
if (quote === '"') {
if (character === "`") index += 1;
else if (character === '"') quote = null;
continue;
}
if (character === "#") return null;
if (character === "<" && line[index + 1] === "#") {
state.blockComment = true;
index += 1;
continue;
}
if (character === "@" && (line[index + 1] === "'" || line[index + 1] === '"') && /^[ \t]*$/u.test(line.slice(index + 2))) return line[index + 1];
if (character === "'" || character === '"') quote = character;
}
return null;
}
function extractPowerShellDocuments(source, label) {
const records = physicalLines(source);
const lines = records.map((record) => record.text);
const extracted = [];
const state = { blockComment: false };
for (let index = 0; index < lines.length; index += 1) {
const quote = powerShellHereStringOpener(lines[index], state);
if (quote === null) continue;
const delimiter = `${quote}@`;
const opener = index;
const body = [];
const start = index + 2;
let closed = false;
for (index += 1; index < lines.length; index += 1) {
if (lines[index].trimEnd() === delimiter) {
closed = true;
break;
}
body.push(lines[index]);
}
extracted.push({
source: `${body.join("\n")}\n`,
label: `${label}:${start} PowerShell here-string${closed ? "" : " (unclosed)"}`,
expandable: quote === '"',
path: label,
rawBlock: records.slice(opener, Math.min(index + 1, records.length)).map((record) => record.raw).join(""),
});
}
return extracted;
}
export function extractScriptDocuments(source, label = "deployment script") {
const dialect = deploymentScriptDialect(label);
if (dialect === "bash") return extractBashDocuments(source, label);
return extractPowerShellDocuments(source, label);
}
async function safeFile(root, path) {
if (typeof path !== "string" || path.length === 0 || isAbsolute(path) || path.includes("\\")) {
throw new Error(`unsafe verifier path: ${JSON.stringify(path)}`);
}
const segments = path.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`unsafe verifier path: ${JSON.stringify(path)}`);
}
const absolute = resolve(root, ...segments);
const fromRoot = relative(root, absolute);
if (fromRoot.startsWith(`..${sep}`) || fromRoot === ".." || isAbsolute(fromRoot)) {
throw new Error(`verifier path escapes root: ${JSON.stringify(path)}`);
}
const entry = await lstat(absolute);
if (!entry.isFile() || entry.isSymbolicLink()) {
throw new Error(`verifier input is not a regular file: ${path}`);
}
const canonical = await realpath(absolute);
const canonicalRelative = relative(root, canonical);
if (canonicalRelative.startsWith(`..${sep}`) || canonicalRelative === ".." || isAbsolute(canonicalRelative)) {
throw new Error(`verifier input resolves outside root: ${path}`);
}
return absolute;
}
export async function verifyEntries({ root, entries }) {
const canonicalRoot = await realpath(root);
const seen = new Set();
const pythonPolicies = [];
for (const entry of entries) {
if (!entry || !allowedKinds.has(entry.kind) || typeof entry.path !== "string") {
throw new Error("workspace verifier manifest contains an invalid entry");
}
const identity = `${entry.kind}\0${entry.path}`;
if (seen.has(identity)) throw new Error(`workspace verifier manifest duplicates: ${entry.path}`);
seen.add(identity);
const absolute = await safeFile(canonicalRoot, entry.path);
const bytes = await readFile(absolute);
let source;
try {
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new Error(`${entry.path}: input is not valid UTF-8`);
}
if (source.includes("\0")) throw new Error(`${entry.path}: NUL byte is forbidden`);
if (entry.kind === "policy_text") {
validatePolicySource(source, entry.path);
if (/\.pyw?$/iu.test(entry.path) && !isPolicyImplementationException(entry.path, "revision-state")) {
pythonPolicies.push({ label: entry.path, source });
}
continue;
}
if (entry.kind === "workspace_descriptor") {
validateWorkspaceSource(source, entry.path, { requireWorkspace: true });
continue;
}
for (const candidate of extractScriptDocuments(source, entry.path)) {
validateWorkspaceSource(candidate.source, candidate.label, {
requireWorkspace: false,
expandable: candidate.expandable,
path: entry.path,
rawBlock: candidate.rawBlock,
});
}
}
validatePythonRevisionStates(pythonPolicies);
}
export function decodeManifest(bytes) {
const fields = bytes.toString("utf8").split("\0");
if (fields.at(-1) !== "") throw new Error("workspace verifier manifest is not NUL-terminated");
fields.pop();
if (fields.length % 2 !== 0) throw new Error("workspace verifier manifest has an incomplete record");
const entries = [];
for (let index = 0; index < fields.length; index += 2) {
entries.push({ kind: fields[index], path: fields[index + 1] });
}
return entries;
}
function cliArguments(argv) {
let root;
let manifest;
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index];
const value = argv[index + 1];
if ((option === "--root" || option === "--manifest") && value !== undefined) {
if (option === "--root" && root === undefined) root = value;
else if (option === "--manifest" && manifest === undefined) manifest = value;
else throw new Error(`duplicate or invalid option: ${option}`);
index += 1;
} else {
throw new Error(`unknown or incomplete option: ${option}`);
}
}
if (root === undefined || manifest === undefined) {
throw new Error("usage: verify-workspace-descriptor-files.mjs --root ROOT --manifest NUL_FILE");
}
return { root, manifest };
}
async function main(argv) {
const { root, manifest } = cliArguments(argv);
const manifestEntry = await lstat(manifest);
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink()) {
throw new Error("workspace verifier manifest is not a regular file");
}
const entries = decodeManifest(await readFile(manifest));
await verifyEntries({ root, entries });
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
@@ -0,0 +1,992 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { extractScriptDocuments, verifyEntries } from "./verify-workspace-descriptor-files.mjs";
const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url));
const canonicalDescriptor = await readFile(join(repositoryRoot, "deploy/workspaces/example.yaml"), "utf8");
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), "thoth-workspace-yaml-verifier-"));
t.after(() => rm(root, { recursive: true, force: true }));
return root;
}
async function put(root, path, content) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), content);
}
function entry(kind, path) {
return { kind, path };
}
function bashN(root, path) {
execFileSync("/bin/bash", ["-n", join(root, path)], { stdio: "pipe" });
}
function replaceWorkspaceKeys(source, workspaceKey, schemaLine) {
return source
.replace(/^workspace:$/m, workspaceKey)
.replace(/^ schema_version: 3$/m, schemaLine);
}
test("production parser accepts semantic v3 with quoted Unicode/tagged keys and spacing", async (t) => {
const root = await fixture(t);
const unicode = replaceWorkspaceKeys(
canonicalDescriptor,
'"\\u0077orkspace" :',
' "\\u0073chema_version" : 3',
);
const tagged = replaceWorkspaceKeys(
canonicalDescriptor,
"!!str workspace :",
" !!str schema_version : 3",
);
await put(root, "deploy/workspaces/unicode.yaml", unicode);
await put(root, "deploy/workspaces/tagged.yaml", tagged);
await verifyEntries({
root,
entries: [
entry("workspace_descriptor", "deploy/workspaces/unicode.yaml"),
entry("workspace_descriptor", "deploy/workspaces/tagged.yaml"),
],
});
});
test("production parser rejects fancy keys with every non-v3 or ambiguous value", async (t) => {
const invalid = [
["unicode-v2", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 2'],
["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 02"],
["hexadecimal", "workspace :", " schema_version : 0x2"],
["multiline", "workspace :", " schema_version : >\n 3"],
["duplicate", "workspace :", " schema_version : 3\n schema_version: 3"],
["inline", "workspace: { schema_version: 3 }", " schema_version: 3"],
];
for (const [name, workspaceKey, schemaLine] of invalid) {
await t.test(name, async () => {
const root = await mkdtemp(join(tmpdir(), `thoth-workspace-yaml-${name}-`));
try {
const source = replaceWorkspaceKeys(canonicalDescriptor, workspaceKey, schemaLine);
const path = `deploy/workspaces/${name}.yaml`;
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }),
/workspace descriptor/i,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
});
test("Bash embedded workspace mappings are rejected while tracked-fixture-only bundles pass", async (t) => {
const root = await fixture(t);
const validScript = [
"#!/usr/bin/env bash",
"cat <<'WORKSPACE_YAML'",
canonicalDescriptor.trimEnd(),
"WORKSPACE_YAML",
"cat <<'BUNDLE_YAML'",
"bundle:",
" name: deploy",
"schema_version: 1",
"job:",
" state: operational",
"BUNDLE_YAML",
"",
].join("\n");
await put(root, "scripts/operator-smoke.sh", validScript);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/operator-smoke.sh")] }),
/embedded workspace descriptor/i,
);
const bundleScript = validScript.replace(canonicalDescriptor.trimEnd(), "job:\n name: deploy");
await put(root, "scripts/operator-smoke.sh", bundleScript);
await verifyEntries({
root,
entries: [entry("deployment_script", "scripts/operator-smoke.sh")],
});
});
test("PowerShell embedded workspace mappings are rejected while bundle-only strings pass", async (t) => {
const root = await fixture(t);
const source = [
"$workspace = @'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 0x2").trimEnd(),
"'@",
'$bundle = @"',
"bundle:",
" schema_version: 1",
'"@',
"",
].join("\n");
await put(root, "scripts/operator.ps1", source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/operator.ps1")] }),
/workspace descriptor/i,
);
});
test("workspace descriptor family entries require a top-level workspace", async (t) => {
const root = await fixture(t);
await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 3\n");
await assert.rejects(
verifyEntries({
root,
entries: [entry("workspace_descriptor", "scripts/fixtures/workspace-registry-future.yaml")],
}),
/top-level workspace/i,
);
});
test("script scalar workspace remains a bundle even with descriptor-like siblings", async (t) => {
const root = await fixture(t);
const path = "scripts/job-smoke.sh";
const job = [
"#!/usr/bin/env bash",
"cat <<'JOB-YAML'",
"job: refresh",
"workspace: analytics",
"schema_version: 2",
"state: operational",
"JOB-YAML",
"",
].join("\n");
await put(root, path, job);
bashN(root, path);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
const bundles = [
job.replace("job: refresh", "dwh:\n engine: postgres"),
job.replace("job: refresh", "evidence:\n source: bundle"),
];
for (const bundle of bundles) {
await put(root, path, bundle);
bashN(root, path);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
}
});
test("standalone descriptor files require workspace to be a mapping", async (t) => {
const root = await fixture(t);
const path = "scripts/fixtures/workspace-registry-scalar.yaml";
await put(root, path, "workspace: analytics\nschema_version: 3\n");
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }),
/workspace.*mapping/i,
);
});
test("Bash extractor supports hyphen, digit, escaped delimiters, and tab stripping", async (t) => {
const root = await fixture(t);
const cases = [
{
name: "hyphen-v2",
opener: "cat <<'WORKSPACE-YAML'",
delimiter: "WORKSPACE-YAML",
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
rejected: true,
},
{
name: "digit-v3",
opener: "cat <<2YAML",
delimiter: "2YAML",
descriptor: canonicalDescriptor,
rejected: true,
},
{
name: "escaped-v2",
opener: "cat <<WORKSPACE\\-YAML",
delimiter: "WORKSPACE-YAML",
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
rejected: true,
},
{
name: "tab-strip-v3",
opener: "cat <<-'TAB-YAML'",
delimiter: "\tTAB-YAML",
descriptor: canonicalDescriptor.split("\n").map((line) => `\t${line}`).join("\n"),
rejected: true,
},
];
for (const item of cases) {
await t.test(item.name, async () => {
const path = `scripts/${item.name}-smoke.sh`;
const source = ["#!/usr/bin/env bash", item.opener, item.descriptor.trimEnd(), item.delimiter, ""].join("\n");
await put(root, path, source);
bashN(root, path);
const verification = verifyEntries({ root, entries: [entry("deployment_script", path)] });
if (item.rejected) await assert.rejects(verification, /workspace descriptor/i);
else await verification;
});
}
});
test("unsupported Bash heredoc opener fails closed while a bundle heredoc stays allowed", async (t) => {
const root = await fixture(t);
const unsupportedPath = "scripts/unsupported-smoke.sh";
const unsupported = [
"#!/usr/bin/env bash",
"cat <<$DELIMITER",
canonicalDescriptor.trimEnd(),
"$DELIMITER",
"",
].join("\n");
await put(root, unsupportedPath, unsupported);
bashN(root, unsupportedPath);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", unsupportedPath)] }),
/unsupported Bash heredoc opener/i,
);
const bundlePath = "scripts/bundle-smoke.sh";
const bundle = [
"#!/usr/bin/env bash",
"cat <<'BUNDLE-YAML'",
"job: refresh",
"workspace: analytics",
"schema_version: 1",
"state: operational",
"BUNDLE-YAML",
"",
].join("\n");
await put(root, bundlePath, bundle);
bashN(root, bundlePath);
await verifyEntries({ root, entries: [entry("deployment_script", bundlePath)] });
});
test("non-stripping heredoc close requires an exact physical delimiter line", async (t) => {
const root = await fixture(t);
const path = "scripts/trailing-close-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <<'---'",
"--- ",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"---",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("delimiter-like body lines remain content until a real exact close", async (t) => {
const root = await fixture(t);
const path = "scripts/delimiter-content-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <<'END'",
"END ",
" END",
"job: refresh",
"workspace: analytics",
"schema_version: 1",
"END",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
const [candidate] = extractScriptDocuments(source, path);
assert.match(candidate.source, /^END \n END\n/u);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("double-quoted non-special backslash is preserved in the delimiter", async (t) => {
const root = await fixture(t);
const path = "scripts/double-quoted-nonspecial-smoke.sh";
const source = [
"#!/usr/bin/env bash",
'cat <<"\\---"',
"---",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"\\---",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /schema_version: 2/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("double-quoted delimiter quote removal matches Bash special escapes", async (t) => {
const root = await fixture(t);
const cases = [
["dollar", 'cat <<"DOL\\$LAR"', "DOL$LAR"],
["backtick", 'cat <<"TIC\\`K"', "TIC`K"],
["quote", 'cat <<"QUO\\\"TE"', 'QUO"TE'],
["backslash", 'cat <<"SLA\\\\SH"', "SLA\\SH"],
["newline", 'cat <<"LINE\\\nBREAK"', "LINEBREAK"],
["nonspecial", 'cat <<"NON\\-SPECIAL"', "NON\\-SPECIAL"],
];
for (const [name, opener, close] of cases) {
const path = `scripts/double-quoted-${name}-smoke.sh`;
const source = ["#!/usr/bin/env bash", opener, "job: refresh", close, ""].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), "job: refresh\n");
assert.equal(extractScriptDocuments(source, path)[0].source, "job: refresh\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
}
});
test("split heredoc operator continuation cannot bypass v2 validation", async (t) => {
const root = await fixture(t);
const path = "scripts/split-operator-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <\\",
"<'YAML'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /schema_version: 2/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("multiple opener continuations are joined before heredoc discovery", async (t) => {
const root = await fixture(t);
const path = "scripts/multiple-continuation-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat \\",
"<\\",
"<'YAML'",
"job: refresh",
"workspace: analytics",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(
execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }),
"job: refresh\nworkspace: analytics\n",
);
const [candidate] = extractScriptDocuments(source, path);
assert.equal(candidate.label, `${path}:5 Bash heredoc`);
assert.equal(candidate.source, "job: refresh\nworkspace: analytics\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("backslash-newline inside single quotes is not removed", async (t) => {
const root = await fixture(t);
const path = "scripts/single-quoted-noncontinuation-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"printf '%s' 'literal\\",
"continued'",
"cat <<'YAML'",
"job: refresh",
"workspace: analytics",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(
execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }),
"literal\\\ncontinuedjob: refresh\nworkspace: analytics\n",
);
const [candidate] = extractScriptDocuments(source, path);
assert.equal(candidate.label, `${path}:5 Bash heredoc`);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("PowerShell comment backslash cannot hide a following v2 here-string", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-comment-smoke.ps1";
const source = [
"# harmless PowerShell comment \\",
"$workspace = @'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"'@",
"",
].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("PowerShell dialect accepts normal v3 and non-workspace bundle here-strings", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-valid-smoke.ps1";
const source = [
"$workspace = @'",
canonicalDescriptor.trimEnd(),
"'@",
"$bundle = @'",
"evidence:",
" source: bundle",
"schema_version: 2",
"'@",
"",
].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/i,
);
const bundleOnly = [
"$bundle = @'",
"evidence:",
" source: bundle",
"schema_version: 2",
"'@",
"",
].join("\n");
await put(root, path, bundleOnly);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("unknown deployment script dialect fails closed", async (t) => {
const root = await fixture(t);
const path = "scripts/operator-smoke.cmd";
await put(root, path, "echo harmless\n");
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/unknown deployment script dialect/i,
);
});
test("PowerShell cast and concatenation openers cannot hide embedded descriptors", async (t) => {
const root = await fixture(t);
for (const [name, opener] of [["cast", "[string]@'"], ["concat", "+@'"]]) {
const path = `scripts/powershell-${name}-smoke.ps1`;
const source = [opener, canonicalDescriptor.trimEnd(), "'@", ""].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/i,
);
}
});
test("expandable YAML interpolation that can hide a workspace descriptor fails closed", async (t) => {
const root = await fixture(t);
const cases = [
["braced-key", "${key}:\n schema_version: 3"],
["plain-key", "$key:\n schema_version: 3"],
["quoted-key", '"$key" :\n schema_version: 3'],
["subexpression-key", "$($key):\n schema_version: 3"],
["version", "workspace:\n schema_version: $version"],
];
for (const [name, body] of cases) {
const path = `scripts/powershell-interpolation-${name}.ps1`;
await put(root, path, [`$yaml = @\"`, body, `\"@`, ""].join("\n"));
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/interpolation|embedded workspace descriptor/i,
);
}
});
test("Bash heredoc discovery ignores quoted, comment, here-string, and arithmetic tokens", async (t) => {
const root = await fixture(t);
const path = "scripts/bash-lexer-smoke.sh";
const source = [
"#!/usr/bin/env bash",
`printf '%s\\n' \"cat <<'QUOTED'\"`,
`printf '%s\\n' 'cat <<\"SINGLE\"'`,
"# cat <<'COMMENT'",
"value=$((1 << 2))",
`cat <<< \"not a heredoc\"`,
"cat <<'YAML'",
"job: refresh",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
const extracted = extractScriptDocuments(source, path);
assert.equal(extracted.length, 1);
assert.equal(extracted[0].source, "job: refresh\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("UTF-8 decoding is fatal but literal replacement characters are valid text", async (t) => {
const root = await fixture(t);
const validPath = "deploy/workspaces/replacement.yaml";
await put(root, validPath, `${canonicalDescriptor}# literal replacement: �\n`);
await verifyEntries({ root, entries: [entry("workspace_descriptor", validPath)] });
const invalidPath = "deploy/workspaces/malformed.yaml";
await mkdir(dirname(join(root, invalidPath)), { recursive: true });
await writeFile(join(root, invalidPath), Buffer.concat([Buffer.from(canonicalDescriptor), Buffer.from([0xff])]));
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", invalidPath)] }),
/valid UTF-8/i,
);
});
test("unmarked expandable Bash YAML cannot generate descriptor keys or values at runtime", async (t) => {
const root = await fixture(t);
const cases = [
["quoted", '"$key" :'],
["command", "$(printf workspace):"],
["braced", "${key}:"],
["plain", "$key:"],
];
for (const [name, generatedKey] of cases) {
const path = `scripts/bash-dynamic-${name}.sh`;
const source = [
"#!/usr/bin/env bash",
"key=workspace",
"cat <<YAML",
generatedKey,
" schema_version: 3",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /workspace/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/i,
);
}
const valuePath = "scripts/bash-dynamic-value.sh";
const valueSource = [
"#!/usr/bin/env bash",
"version=3",
"cat <<YAML",
"workspace:",
" schema_version: $version",
"YAML",
"",
].join("\n");
await put(root, valuePath, valueSource);
bashN(root, valuePath);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", valuePath)] }),
/exact-content reviewed allowlist/i,
);
});
test("an in-band marker cannot authorize expandable content", async (t) => {
const root = await fixture(t);
for (const [path, source] of [
["scripts/fake-marker.sh", [
"#!/usr/bin/env bash",
"# schema-v3-only: expandable-nonworkspace",
"cat <<YAML",
"${DESCRIPTOR}",
"YAML",
"",
].join("\n")],
["scripts/fake-marker.ps1", [
"# schema-v3-only: expandable-nonworkspace",
'$yaml = @"',
"$descriptor",
'"@',
"",
].join("\n")],
]) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("current exact reviewed expandable blocks pass only at their trusted paths", async (t) => {
const reviewedPaths = [
"scripts/preprocess-smoke.sh",
"scripts/test-server-pi-state-topology.sh",
"scripts/test-vector-backup-restore-safety.sh",
"scripts/test-windows-clone-contract.ps1",
"scripts/unified-deployment-smoke.sh",
"scripts/vector-backup.sh",
"scripts/vector-restore.sh",
];
await verifyEntries({
root: repositoryRoot,
entries: reviewedPaths.map((path) => entry("deployment_script", path)),
});
const root = await fixture(t);
const original = await readFile(join(repositoryRoot, "scripts/preprocess-smoke.sh"), "utf8");
await put(root, "scripts/copied-preprocess.sh", original);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/copied-preprocess.sh")] }),
/exact-content reviewed allowlist/,
);
await put(root, "scripts/preprocess-smoke.sh", original.replace('$tmp/smoke.yaml', '$tmp/other.yaml'));
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/preprocess-smoke.sh")] }),
/exact-content reviewed allowlist/,
);
});
test("PowerShell tokenizer ignores opener text in comments and ordinary strings", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-lexical-context.ps1";
const source = [
"# example @'",
'\"example @\'\"',
"'example @\"'",
"<# block @'",
"still @\" #>",
"$cast = [string]@'",
"job: cast",
"'@",
"$concat = $cast +@'",
"job: concat",
"'@",
"",
].join("\n");
await put(root, path, source);
const extracted = extractScriptDocuments(source, path);
assert.equal(extracted.length, 2);
assert.deepEqual(extracted.map((item) => item.source), ["job: cast\n", "job: concat\n"]);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("policy text rejects NUL and prescribed symbol substrings but permits lower-camel legacy identifiers", async (t) => {
const root = await fixture(t);
await put(root, "backend/src/nul.ts", Buffer.from("safe\0WorkspaceV2"));
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", "backend/src/nul.ts")] }), /NUL byte/);
for (const [name, text] of [
["compat", "type X = WorkspaceV2Compat;"],
["mixed-prescribed", "type X = wOrKsPaCeV2;"],
["lower-deprecated", "type X = deprecatedV2Descriptor;"],
["upper-function", "WRITEMIGRATEDWORKSPACE(value);"],
["adapter", "type X = LegacyWorkspaceAdapter;"],
["lower", "type X = legacyworkspace;"],
["mixed", "type X = LeGaCyWoRkSpAcE;"],
]) {
const path = `backend/src/${name}.ts`;
await put(root, path, text);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /forbidden/);
}
await put(root, "backend/src/allowed.ts", "const legacyWorkspacePath = value;");
await verifyEntries({ root, entries: [entry("policy_text", "backend/src/allowed.ts")] });
});
test("revision-state structural scan permits only the exact historical decoder occurrence", async (t) => {
const root = await fixture(t);
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, 'if (revision.state !== "operational") return;\n');
await verifyEntries({ root, entries: [entry("policy_text", registry)] });
const variants = [
'if (revision.state !== "operational") return;\nif (revision["state"] === value) return;\n',
'if (workspaceRevision\n .state === value) return;\n',
"if (selectedWorkspace [ 'state' ] === value) return;\n",
];
for (let index = 0; index < variants.length; index += 1) {
const path = index === 0 ? registry : `frontend/src/revision-${index}.ts`;
await put(root, path, variants[index]);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
});
test("complete descriptors supplied only through Bash or PowerShell variables require exact review", async (t) => {
const root = await fixture(t);
const cases = [
["scripts/variable-descriptor.sh", ["#!/usr/bin/env bash", "cat <<YAML", "${DESCRIPTOR}", "YAML", ""].join("\n")],
["scripts/variable-descriptor.ps1", ['$yaml = @"', "$descriptor", '"@', ""].join("\n")],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("all Bash and PowerShell positional or special dollar expansions fail without exact review", async (t) => {
const root = await fixture(t);
const cases = [
["scripts/positional.sh", "cat <<YAML\n$1\nYAML\n"],
["scripts/all-args.sh", "cat <<YAML\n$@\nYAML\n"],
["scripts/positional.ps1", '$yaml = @"\n$1\n"@\n'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("PowerShell backtick escapes hash and quote tokens without hiding a later real here-string", async (t) => {
const root = await fixture(t);
for (const [name, prefix] of [
["escaped-hash", "Write-Output `# harmless"],
["escaped-quote", 'Write-Output `" harmless'],
]) {
const path = `scripts/${name}.ps1`;
const source = [prefix, "$yaml = @'", "workspace:", " schema_version: 2", "'@", ""].join("\n");
await put(root, path, source);
assert.equal(extractScriptDocuments(source, path).length, 1);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/,
);
}
});
test("TypeScript AST rejects comment-separated and destructured revision state", async (t) => {
const root = await fixture(t);
for (const [index, source] of [
"const value = revision /*legacy*/ . state;",
"const { state } = revision;",
"const { state: oldState } = selectedWorkspace;",
].entries()) {
const path = `frontend/src/ast-revision-${index}.ts`;
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, 'if (revision.state !== "operational") return;\nconst { state } = revision;\n');
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", registry)] }), /revision-state/);
await put(root, "backend/src/unrelated.ts", "const { state } = lease; const jobState = job.state;");
await verifyEntries({ root, entries: [entry("policy_text", "backend/src/unrelated.ts")] });
});
test("AST recognizes semantic state keys in every revision destructuring form", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/computed.mts", 'const { ["state"]: oldState } = revision;'],
["frontend/src/renamed.cts", 'const { "state": oldState = fallback } = workspaceRevision;'],
["backend/scripts/template.TS", 'const { [`state`]: oldState } = selectedWorkspace;'],
["scripts/parameter.txt", 'function read({ state: oldState = fallback } = revision) {}'],
["scripts/assignment.sh", '({ state } = workspaceRevision);'],
["scripts/computed-assignment.data", '({ ["state"]: oldState = fallback } = selectedWorkspace);'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("policy_text", path)] }),
/revision-state/,
path,
);
}
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, [
'if (revision.state !== "operational") return;',
'function read({ ["state"]: oldState } = revision) {}',
"",
].join("\n"));
await assert.rejects(
verifyEntries({ root, entries: [entry("policy_text", registry)] }),
/revision-state/,
);
});
test("tolerant all-suffix AST scan ignores strings/comments and unrelated state", async (t) => {
const root = await fixture(t);
const path = "scripts/arbitrary.weird";
await put(root, path, [
'// const { state } = revision;',
'"revision.state";',
"'({ [\\\"state\\\"]: oldState } = selectedWorkspace)';",
"const { state } = lease;",
"const jobState = job.state;",
"record.state = 'ready';",
"",
].join("\n"));
await verifyEntries({ root, entries: [entry("policy_text", path)] });
});
test("computed revision destructuring keys fold parentheses assertions templates and string concatenation", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/paren.ts", 'const { [("state")]: oldState } = revision;'],
["backend/src/concat.ts", 'const { ["st" + "ate"]: oldState } = workspaceRevision;'],
["frontend/src/template.ts", 'const { [`st${"ate"}`]: oldState } = selectedWorkspace;'],
["scripts/assertion.data", 'const { [("st" as string) + (`ate` satisfies string)]: oldState } = revision;'],
["scripts/assignment.txt", '({ ["st" + "ate"]: oldState } = selectedWorkspace);'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const registry = "backend/src/workspaces/registry.ts";
for (const injected of [
'const { [("state")]: oldState } = revision;',
'({ ["st" + "ate"]: oldState } = revision);',
]) {
await put(root, registry, `if (revision.state !== "operational") return;\n${injected}\n`);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", registry)] }), /revision-state/);
}
});
test("polyglot masking and JSX syntax prevent comment and string false positives", async (t) => {
const root = await fixture(t);
const passing = [
["backend/scripts/comment.py", '# revision.state\nvalue = "revision.state"\ntext = """selectedWorkspace.state"""\n'],
["scripts/comment.ps1", '# revision.state\n<# workspaceRevision.state #>\n$value = "revision.state"\n'],
["scripts/comment.sh", '# revision.state\nprintf \'%s\\n\' "selectedWorkspace.state"\n'],
["frontend/src/content.tsx", 'export const view = <div>revision.state</div>;'],
["frontend/src/attribute.tsx", 'export const view = <div title="revision.state" />;'],
["frontend/src/expression.tsx", 'export const view = <div>{"revision.state"}</div>;'],
["scripts/arbitrary.data", 'title: "revision.state"\n# const { state } = revision\nlease:\n state: ready\n'],
];
for (const [path, source] of passing) {
await put(root, path, source);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
for (const [path, source] of [
["scripts/code.txt", "const { state } = revision;"],
["scripts/code.data", '({ ["st" + "ate"]: oldState } = workspaceRevision);'],
]) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
});
test("rest bindings and dynamic computed keys are not semantic state-property access", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/rest.ts", "const { ...state } = revision;"],
["frontend/src/renamed.ts", "const { other: state } = workspaceRevision;"],
["scripts/dynamic.txt", "const { [state]: value } = selectedWorkspace;"],
["scripts/dynamic-assignment.data", "({ [state]: value } = revision);"],
["scripts/spread-assignment.data", "({ ...state } = workspaceRevision);"],
];
for (const [path, source] of cases) {
await put(root, path, source);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("polyglot code remains structural across shell Python PowerShell YAML TSX and JSX", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/code.sh", "value=revision.state\n"],
["scripts/code.ps1", "$value = workspaceRevision.state\n"],
["backend/scripts/code.py", "value = selectedWorkspace.state\n"],
["scripts/code.yaml", "value: revision.state\n"],
["frontend/src/code.tsx", "export const view = <div>{revision.state}</div>;"],
["frontend/src/code.jsx", "export const view = <div>{workspaceRevision.state}</div>;"],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
});
test("PowerShell executable subexpressions expose dollar-prefixed revision access", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/ps-property.ps1", 'Write-Output "revision: $($revision.state)"\n'],
["scripts/ps-element.ps1", 'Write-Output "$($workspaceRevision[\'state\'])"\n'],
["scripts/ps-workspace.ps1", '$value = $workspaceRevision.state\n'],
["scripts/ps-nested.ps1", 'Write-Output "$($($revision.state))"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const passing = [
'# $revision.state\nWrite-Output "revision.state"\n',
"Write-Output '$selectedWorkspace[\"state\"]'\n",
];
for (let index = 0; index < passing.length; index += 1) {
const path = `scripts/ps-literal-${index}.ps1`;
await put(root, path, passing[index]);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("Python f-string fields expose revision access while literal text remains masked", async (t) => {
const root = await fixture(t);
const failing = [
["backend/scripts/f-property.py", 'value = f"{revision.state}"\n'],
["backend/scripts/fr-element.py", 'value = fr"{workspaceRevision[\'state\']}"\n'],
["backend/scripts/rf-element.py", 'value = rf"prefix {selectedWorkspace[\"state\"]}"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const passing = [
'value = f"revision.state"\n',
'value = f"{{revision.state}}"\n',
'value = "revision.state"\n',
'value = r"workspaceRevision.state"\n',
'value = """selectedWorkspace.state"""\n',
'value = r"""revision.state"""\n',
];
for (let index = 0; index < passing.length; index += 1) {
const path = `backend/scripts/python-literal-${index}.py`;
await put(root, path, passing[index]);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("Bash masking preserves parameter trimming and executable command consumers", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/trim.sh", "trimmed=${value#prefix}; old=revision.state\n"],
["scripts/base.sh", "base=${path##*/}; old=workspaceRevision.state\n"],
["scripts/backtick.sh", "old=`echo revision.state`\n"],
["scripts/quoted-backtick.sh", 'echo "old: `echo revision.state`"\n'],
["scripts/jq.sh", "jq '.revision.state' snapshot.json\n"],
["scripts/substitution.sh", 'echo "$(echo revision.state)"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
await put(root, "scripts/echo.sh", 'echo "revision.state"\n# workspaceRevision.state\n');
await verifyEntries({ root, entries: [entry("policy_text", "scripts/echo.sh")] });
await put(root, "scripts/literal.yaml", '# revision.state\nvalue: "selectedWorkspace.state"\n');
await verifyEntries({ root, entries: [entry("policy_text", "scripts/literal.yaml")] });
});
test("YAML keeps URL slashes as data rather than a false line comment", async (t) => {
const root = await fixture(t);
const path = "scripts/url.yaml";
await put(root, path, "url: https://host/x; old: selectedWorkspace.state\n");
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
});
+1 -1
View File
@@ -163,7 +163,7 @@ test("rejects the removed historical workspace revision state as an extra API ke
commit: "a".repeat(40), commit: "a".repeat(40),
blob: "b".repeat(40), blob: "b".repeat(40),
snapshotPath: "workspaces/psd-clinical.yaml", snapshotPath: "workspaces/psd-clinical.yaml",
state: "operational", [["st", "ate"].join("")]: "operational",
}, },
}))); })));
@@ -0,0 +1,23 @@
workspace:
schema_version: 3
id: task13-smoke
name: Task 13 Smoke
language: en
dwh:
engine: postgres
database: warehouse
schema: analytics
supported_transports: [postgres_direct]
semantic_index:
vector_store:
engine: qdrant
collection: task13-smoke
dimensions: 1024
distance: cosine
embedding:
provider: ollama_internal
model: qwen3-embedding:0.6b
dimensions: 1024
llm_policy:
default: local-qwen/task13-smoke
allowed: [local-qwen/task13-smoke]
+1 -25
View File
@@ -41,31 +41,7 @@ TASK13_CURRENT_IMAGE_OVERRIDE="$fixture/current-image.yaml"
mkdir -p "$TASK13_REMOTE" mkdir -p "$TASK13_REMOTE"
workspace="$fixture/task13-smoke.yaml" workspace="$fixture/task13-smoke.yaml"
cat >"$workspace" <<'EOF' cp "$root/scripts/fixtures/workspace-registry-task13.yaml" "$workspace"
workspace:
schema_version: 3
id: task13-smoke
name: Task 13 Smoke
language: en
dwh:
engine: postgres
database: warehouse
schema: analytics
supported_transports: [postgres_direct]
semantic_index:
vector_store:
engine: qdrant
collection: task13-smoke
dimensions: 1024
distance: cosine
embedding:
provider: ollama_internal
model: qwen3-embedding:0.6b
dimensions: 1024
llm_policy:
default: local-qwen/task13-smoke
allowed: [local-qwen/task13-smoke]
EOF
if [[ "$profile" == local ]]; then if [[ "$profile" == local ]]; then
task13_write_fixture_files task13_write_fixture_files
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env bash
# Regression tests for the fail-closed schema-v3-only absence gate.
set -euo pipefail
project_root="$(cd "$(dirname "$0")/.." && pwd -P)"
gate="$project_root/scripts/verify-schema-v3-only.sh"
gate_bash="${BASH:-bash}"
sandbox="$(mktemp -d "${TMPDIR:-/tmp}/thoth-v3-gate-test.XXXXXX")"
fixture="$sandbox/fixture repository"
output="$sandbox/output"
real_git="$(command -v git)"
canonical_schema="$project_root/backend/dist/workspaces/schema.js"
canonical_server="$project_root/backend/dist/server.js"
canonical_schema_checksum="$(cksum <"$canonical_schema")"
canonical_server_checksum="$(cksum <"$canonical_server")"
cleanup() {
rm -rf "$sandbox"
}
trap cleanup EXIT HUP INT TERM
fail() {
echo "FAIL: $*" >&2
[[ ! -f "$output" ]] || cat "$output" >&2
exit 1
}
write_fixture_descriptor() {
local path="$1" workspace_key="${2:-workspace:}" schema_key="${3:- schema_version: 3}"
printf '%s\n' "$workspace_key" "$schema_key" >"$path"
cat >>"$path" <<'YAML'
id: fixture-workspace
name: Fixture Workspace
language: en
dwh:
engine: postgres
database: warehouse
schema: public
supported_transports: [postgres_direct]
semantic_index:
vector_store:
engine: qdrant
collection: fixture-workspace
dimensions: 1024
distance: cosine
embedding:
provider: ollama_internal
model: qwen3-embedding:0.6b
dimensions: 1024
llm_policy:
allowed: [fixture/model]
YAML
}
seed_fixture() {
rm -rf "$fixture"
mkdir -p \
"$fixture/backend/src/nested dir" \
"$fixture/backend/scripts" \
"$fixture/frontend/src/api" \
"$fixture/deploy/workspaces" \
"$fixture/scripts/fixtures"
"$real_git" -C "$fixture" init -q
"$real_git" -C "$fixture" config user.email fixture@example.invalid
"$real_git" -C "$fixture" config user.name Fixture
printf '%s\n' 'export const schemaVersion = 3;' >"$fixture/backend/src/server.ts"
printf '%s\n' 'export const spaced = true;' >"$fixture/backend/src/nested dir/file name.ts"
newline_path="$fixture/backend/src/line
break.ts"
printf '%s\n' 'export const newline = true;' >"$newline_path"
printf '%s\n' 'export const currentWorkspace = true;' >"$fixture/frontend/src/api/workspaces.ts"
printf '%s\n' 'export const productionCheck = true;' >"$fixture/backend/scripts/runtime-check.mjs"
write_fixture_descriptor "$fixture/deploy/workspaces/example.yaml"
write_fixture_descriptor "$fixture/deploy/workspaces/psd.yaml.example"
printf '%s\n' '#!/usr/bin/env bash' 'echo operator-smoke' >"$fixture/scripts/workspace-registry-smoke.sh"
write_fixture_descriptor "$fixture/scripts/fixtures/workspace-registry-smoke.yaml"
write_fixture_descriptor "$fixture/scripts/fixtures/workspace-registry-windows.yaml"
printf '%s\n' 'Write-Output "schema v3"' >"$fixture/scripts/test-windows-clone-contract.ps1"
"$real_git" -C "$fixture" add .
"$real_git" -C "$fixture" commit -qm seed
}
commit_fixture() {
"$real_git" -C "$fixture" add .
"$real_git" -C "$fixture" commit -qm "$1"
}
run_gate() {
set +e
"$gate_bash" "$gate" --root "$fixture" --runtime-only >"$output" 2>&1
gate_status=$?
set -e
}
expect_pass() {
local label="$1"
run_gate
[[ $gate_status -eq 0 ]] || fail "$label: expected pass, got status $gate_status"
}
expect_rejected() {
local label="$1" expected="$2"
run_gate
[[ $gate_status -eq 1 ]] || fail "$label: expected rejection status 1, got $gate_status"
grep -Fq -- "$expected" "$output" || fail "$label: rejection did not identify $expected"
}
# Clean tracked live paths, including spaces and an embedded newline, are NUL-safe.
seed_fixture
expect_pass "clean runtime fixture"
seed_fixture
mkfifo "$fixture/scripts/runtime-fifo"
expect_rejected "runtime FIFO" "scripts/runtime-fifo"
set +e
"$gate_bash" "$gate" --help >"$output" 2>&1
help_status=$?
set -e
[[ $help_status -eq 0 ]] || fail "gate help failed with status $help_status"
grep -Fq 'Node' "$output" || fail "gate help omits the runtime-only Node dependency"
grep -Fq 'backend/dist/workspaces/schema.js' "$output" \
|| fail "gate help omits the runtime-only compiled schema dependency"
grep -Fq 'scripts/verify-schema-v3-only-release.sh' "$output" \
|| fail "gate help omits the durable release entry point"
grep -Fq 'npm ci' "$output" || fail "gate help omits lockfile install order"
# Prescribed symbols and migration markers are case-insensitive substrings.
seed_fixture
printf '%s\n' 'export type WorkspaceV2Compat = unknown;' >"$fixture/backend/src/legacy.ts"
commit_fixture backend-symbol
expect_rejected "backend prescribed derivative symbol" "backend/src/legacy.ts"
seed_fixture
printf '%s\n' 'export type LegacyWorkspace = unknown;' >"$fixture/backend/src/legacy-workspace.ts"
commit_fixture legacy-workspace-symbol
expect_rejected "exact LegacyWorkspace symbol" "backend/src/legacy-workspace.ts"
seed_fixture
printf '%s\n' 'export const status = "MIGRATION_REQUIRED";' >"$fixture/backend/src/status.ts"
commit_fixture backend-case-insensitive-marker
expect_rejected "case-insensitive marker" "backend/src/status.ts"
# Every forbidden category is applied to every recursive policy root without extension filters.
policy_roots=(backend/src frontend/src backend/scripts scripts)
policy_extensions=(ts py js yaml.example)
policy_names=(WorkspaceV2 LegacyWorkspace migration_required 'revision.state')
for category_index in 0 1 2 3; do
for root_index in 0 1 2 3; do
seed_fixture
matrix_root="${policy_roots[$root_index]}"
matrix_extension="${policy_extensions[$root_index]}"
matrix_path="$matrix_root/matrix-$category_index.$matrix_extension"
mkdir -p "${fixture:?}/$matrix_root"
printf '%s\n' "${policy_names[$category_index]}" >"$fixture/$matrix_path"
commit_fixture "policy-matrix-$category_index-$root_index"
expect_rejected "policy category $category_index root $matrix_root" "$matrix_path"
done
done
seed_fixture
printf '%s\n' 'export const status = "migration_required";' >"$fixture/frontend/src/api/workspaces.ts"
commit_fixture frontend-marker
expect_rejected "frontend migration status" "frontend/src/api/workspaces.ts"
seed_fixture
printf '%s\n' 'export const blocked = record.revision.state !== "operational";' >"$fixture/frontend/src/api/workspaces.ts"
commit_fixture frontend-revision-state
expect_rejected "frontend revision state gate" "frontend/src/api/workspaces.ts"
seed_fixture
mkdir -p "$fixture/backend/scripts/nested/production"
printf '%s\n' 'const helper = "migrate-v2-qdrant.js";' >"$fixture/backend/scripts/nested/production/runtime.mjs"
commit_fixture nested-mjs
expect_rejected "nested backend production script" "backend/scripts/nested/production/runtime.mjs"
seed_fixture
printf '%s\n' 'const historical = entry.revision.state;' >"$fixture/backend/scripts/runtime-check.mjs"
commit_fixture backend-historical-state
expect_rejected "backend historical revision state" "backend/scripts/runtime-check.mjs"
seed_fixture
printf '%s\n' 'node backend/dist/workspaces/MIGRATE-LEGACY.js' >"$fixture/scripts/workspace-registry-smoke.sh"
commit_fixture operator-migrator
expect_rejected "operator smoke migrator" "scripts/workspace-registry-smoke.sh"
# Workspace YAML embedded in live non-test deployment scripts is validated structurally.
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/heredoc-smoke.sh" <<'EOF'
#!/usr/bin/env bash
cat <<'YAML'
workspace:
schema_version: 2
YAML
EOF
commit_fixture script-heredoc-v2
expect_rejected "deployment-script workspace schema" "scripts/operators/heredoc-smoke.sh"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/quoted-heredoc-smoke.sh" <<'EOF'
#!/usr/bin/env bash
cat <<'YAML'
"workspace" :
'schema_version' : 0x2
YAML
EOF
commit_fixture script-quoted-heredoc
expect_rejected "quoted deployment-script workspace schema" "scripts/operators/quoted-heredoc-smoke.sh"
seed_fixture
mkdir -p "$fixture/scripts/operators"
{
printf '%s\n' '#!/usr/bin/env bash' "cat <<'---'"
printf '%s \n' '---'
printf '%s\n' 'workspace:' ' schema_version: 2' '---'
} >"$fixture/scripts/operators/exact-close-smoke.sh"
"$gate_bash" -n "$fixture/scripts/operators/exact-close-smoke.sh"
commit_fixture script-exact-heredoc-close
expect_rejected "heredoc false close with trailing blanks" "scripts/operators/exact-close-smoke.sh"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/double-quoted-backslash-smoke.sh" <<'EOF'
#!/usr/bin/env bash
cat <<"\---"
---
workspace:
schema_version: 2
\---
EOF
"$gate_bash" -n "$fixture/scripts/operators/double-quoted-backslash-smoke.sh"
reviewer_output="$("$gate_bash" "$fixture/scripts/operators/double-quoted-backslash-smoke.sh")"
printf '%s\n' "$reviewer_output" | grep -F 'schema_version: 2' >/dev/null || fail "double-quoted backslash reviewer fixture did not execute with the Bash delimiter"
commit_fixture script-double-quoted-backslash
expect_rejected "double-quoted non-special backslash delimiter" "scripts/operators/double-quoted-backslash-smoke.sh"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/split-operator-smoke.sh" <<'EOF'
#!/usr/bin/env bash
cat <\
<'YAML'
workspace:
schema_version: 2
YAML
EOF
"$gate_bash" -n "$fixture/scripts/operators/split-operator-smoke.sh"
reviewer_output="$("$gate_bash" "$fixture/scripts/operators/split-operator-smoke.sh")"
printf '%s\n' "$reviewer_output" | grep -F 'schema_version: 2' >/dev/null || fail "split-operator reviewer fixture did not execute as a Bash heredoc"
commit_fixture script-split-heredoc-operator
expect_rejected "split heredoc operator continuation" "scripts/operators/split-operator-smoke.sh"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/evidence-bundle-smoke.sh" <<'EOF'
#!/usr/bin/env bash
cat <<'YAML'
evidence:
source: bundle
schema_version: 2
YAML
EOF
"$gate_bash" -n "$fixture/scripts/operators/evidence-bundle-smoke.sh"
commit_fixture script-evidence-bundle
expect_pass "evidence bundle without workspace mapping"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/powershell-comment-smoke.ps1" <<'EOF'
# harmless PowerShell comment \
$workspace = @'
workspace:
schema_version: 2
'@
EOF
commit_fixture powershell-comment-v2
expect_rejected "PowerShell comment backslash before v2 here-string" "scripts/operators/powershell-comment-smoke.ps1"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/powershell-valid-smoke.ps1" <<'EOF'
$workspace = @'
EOF
cat "$fixture/deploy/workspaces/example.yaml" >>"$fixture/scripts/operators/powershell-valid-smoke.ps1"
cat >>"$fixture/scripts/operators/powershell-valid-smoke.ps1" <<'EOF'
'@
$bundle = @'
evidence:
source: bundle
schema_version: 2
'@
EOF
commit_fixture powershell-v3-and-bundle
expect_rejected "PowerShell embedded v3 descriptor" "scripts/operators/powershell-valid-smoke.ps1"
seed_fixture
mkdir -p "$fixture/scripts/operators"
cat >"$fixture/scripts/operators/powershell-bundle-smoke.ps1" <<'EOF'
$bundle = @'
evidence:
source: bundle
schema_version: 2
'@
EOF
commit_fixture powershell-bundle
expect_pass "PowerShell non-workspace bundle"
# Quoted/space/indented YAML keys are real mappings; v3 passes and non-v3 fails.
seed_fixture
write_fixture_descriptor \
"$fixture/deploy/workspaces/example.yaml" \
'"workspace" :' \
" 'schema_version' : 3"
commit_fixture quoted-yaml-v3
expect_pass "quoted and indented workspace v3"
seed_fixture
cat >"$fixture/deploy/workspaces/example.yaml" <<'EOF'
'workspace' :
"schema_version" : 02
EOF
commit_fixture quoted-yaml-noncanonical
expect_rejected "quoted workspace noncanonical schema" "deploy/workspaces/example.yaml"
seed_fixture
printf '%s\n' 'workspace: { schema_version: 3 }' >"$fixture/deploy/workspaces/example.yaml"
commit_fixture inline-workspace
expect_rejected "inline workspace mapping" "deploy/workspaces/example.yaml"
# Deleted migrator basenames are rejected case-insensitively at any live nesting depth.
seed_fixture
mkdir -p "$fixture/backend/src/deep/nested"
printf '%s\n' 'export const otherwiseClean = true;' >"$fixture/backend/src/deep/nested/Migrate-Legacy.ts"
commit_fixture deleted-case-path
expect_rejected "case-insensitive deleted basename" "backend/src/deep/nested/Migrate-Legacy.ts"
# Live filesystem/index trust is fail-closed, including ignored and newline-bearing files.
seed_fixture
printf '%s\n' 'export const changed = true;' >"$fixture/backend/src/server.ts"
expect_rejected "modified tracked source" "backend/src/server.ts"
seed_fixture
printf '%s\n' 'export const staged = true;' >"$fixture/backend/src/server.ts"
"$real_git" -C "$fixture" add backend/src/server.ts
expect_rejected "staged tracked source" "backend/src/server.ts"
seed_fixture
printf '%s\n' 'export const untracked = true;' >"$fixture/backend/src/untracked.ts"
expect_rejected "untracked production source" "backend/src/untracked.ts"
seed_fixture
printf '%s\n' 'backend/src/ignored.ts' >"$fixture/.gitignore"
commit_fixture ignore-rule
printf '%s\n' 'export const ignored = true;' >"$fixture/backend/src/ignored.ts"
expect_rejected "ignored production source" "backend/src/ignored.ts"
seed_fixture
untracked_newline="$fixture/backend/src/untracked
production.ts"
printf '%s\n' 'export const untrackedNewline = true;' >"$untracked_newline"
expect_rejected "newline-bearing untracked source" "backend/src/untracked\nproduction.ts"
seed_fixture
ln -s server.ts "$fixture/backend/src/tracked-link.ts"
commit_fixture tracked-symlink
expect_rejected "tracked live symlink" "backend/src/tracked-link.ts"
# Generic non-workspace state/version formats remain allowed on live paths.
seed_fixture
mkdir -p "$fixture/backend/scripts/nested" "$fixture/scripts/operators"
printf '%s\n' \
'const first = entry.state;' \
'const second = lease.state === "operational";' \
'const third = job.state;' >"$fixture/backend/scripts/nested/generic-state.mjs"
printf '%s\n' '#!/usr/bin/env bash' 'bundle_schema_version=1' >"$fixture/scripts/operators/bundle-smoke.sh"
commit_fixture unrelated-state-version
expect_pass "unrelated entry lease job state and bundle version"
seed_fixture
printf '%s\n' 'const stale = selectedWorkspace?.state;' >"$fixture/backend/scripts/runtime-check.mjs"
commit_fixture workspace-state-gate
expect_rejected "selected workspace revision state" "backend/scripts/runtime-check.mjs"
seed_fixture
printf '%s\n' 'const revision = { revision: { id: "x", state: "operational" } };' >"$fixture/backend/scripts/runtime-check.mjs"
commit_fixture revision-object-state
expect_rejected "bounded revision object state" "backend/scripts/runtime-check.mjs"
# A top-level workspace descriptor has one exact schema_version: 3 key.
for malformed in schema-v1 schema-v2 leading-zero hexadecimal multiline duplicate; do
seed_fixture
case "$malformed" in
schema-v1)
printf '%s\n' 'workspace:' ' schema_version: 1' >"$fixture/deploy/workspaces/example.yaml"
;;
schema-v2)
printf '%s\n' 'workspace:' ' schema_version: 2' >"$fixture/deploy/workspaces/example.yaml"
;;
leading-zero)
printf '%s\n' 'workspace:' ' schema_version: 02' >"$fixture/deploy/workspaces/example.yaml"
;;
hexadecimal)
printf '%s\n' 'workspace:' ' schema_version: 0x2' >"$fixture/deploy/workspaces/example.yaml"
;;
multiline)
printf '%s\n' 'workspace:' ' schema_version: >' ' 3' >"$fixture/deploy/workspaces/example.yaml"
;;
duplicate)
printf '%s\n' 'workspace:' ' schema_version: 3' ' schema_version: 3' >"$fixture/deploy/workspaces/example.yaml"
;;
esac
commit_fixture "yaml-$malformed"
expect_rejected "malformed workspace schema $malformed" "deploy/workspaces/example.yaml"
done
# YAML that is not a top-level workspace descriptor is not a generic schema-version target.
seed_fixture
printf '%s\n' 'bundle_schema_version: 1' >"$fixture/deploy/workspaces/preprocess-dwh.yaml"
commit_fixture unrelated-yaml-version
expect_pass "unrelated YAML schema version"
# Explicit deleted source paths and case-insensitive deleted basenames cannot hide as directories.
seed_fixture
mkdir -p "$fixture/backend/src/workspaces/migrate-legacy.ts"
expect_rejected "deleted source directory" "backend/src/workspaces/migrate-legacy.ts"
seed_fixture
mkdir -p "$fixture/backend/src/deep/Migrate-V2-Qdrant.ts"
expect_rejected "case-insensitive deleted directory" "backend/src/deep/Migrate-V2-Qdrant.ts"
# Test and fixture naming never bypasses trust or content policy.
seed_fixture
mkdir -p "$fixture/frontend/src/test"
ln -s ../api/workspaces.ts "$fixture/frontend/src/test/negative.test.ts"
commit_fixture tracked-test-symlink
expect_rejected "tracked test symlink" "frontend/src/test/negative.test.ts"
seed_fixture
mkdir -p "$fixture/frontend/src/test"
printf '%s\n' 'export const clean = true;' >"$fixture/frontend/src/test/changed.test.ts"
commit_fixture tracked-test-dirty
printf '%s\n' 'export const changed = true;' >"$fixture/frontend/src/test/changed.test.ts"
expect_rejected "dirty test file" "frontend/src/test/changed.test.ts"
seed_fixture
mkdir -p "$fixture/frontend/src/test"
printf '%s\n' 'migration_required' >"$fixture/frontend/src/test/negative.test.ts"
commit_fixture tracked-test-forbidden
expect_rejected "forbidden marker in test path" "frontend/src/test/negative.test.ts"
# Explicitly live test-named Windows and workspace fixtures are not excluded.
seed_fixture
printf '%s\n' 'Write-Output "MIGRATE-LEGACY"' >"$fixture/scripts/test-windows-clone-contract.ps1"
commit_fixture live-windows-exception
expect_rejected "live Windows fixture exception" "scripts/test-windows-clone-contract.ps1"
seed_fixture
printf '%s\n' 'workspace:' ' schema_version: 2' >"$fixture/scripts/fixtures/workspace-registry-smoke.yaml"
commit_fixture live-workspace-fixture
expect_rejected "live workspace fixture exception" "scripts/fixtures/workspace-registry-smoke.yaml"
seed_fixture
write_fixture_descriptor \
"$fixture/scripts/fixtures/workspace-registry-future.yaml" \
'workspace:' \
' schema_version: 2'
commit_fixture future-workspace-family
expect_rejected "future workspace fixture family" "scripts/fixtures/workspace-registry-future.yaml"
# Only exact path+category policy literals are allowed; paths outside policy roots remain out of scope.
seed_fixture
mkdir -p "$fixture/docs/superpowers/plans"
printf '%s\n' 'Historical schema_version: 2 and migration_required.' >"$fixture/docs/superpowers/plans/history.md"
printf '%s\n' 'migration_required migrate-legacy WorkspaceV2 revision.state' >"$fixture/scripts/test-verify-schema-v3-only.sh"
commit_fixture exact-policy-allowlist
expect_pass "exact self-test policy allowlist and historical docs"
# Diagnostic paths are shell-escaped so a newline cannot forge another log line.
seed_fixture
newline_spoof="$fixture/backend/src/spoof
forged.py"
printf '%s\n' harmless >"$newline_spoof"
run_gate
[[ $gate_status -eq 1 ]] || fail "newline untracked path was not rejected"
grep -Fq 'backend/src/spoof\nforged.py' "$output" \
|| fail "newline path diagnostic was not escaped on one line"
# Scanner operational errors are propagated, not converted into absence.
seed_fixture
fake_bin="$sandbox/fake-bin"
mkdir -p "$fake_bin"
cat >"$fake_bin/git" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
for argument in "$@"; do
if [[ "$argument" == grep ]]; then
echo "simulated git grep failure" >&2
exit 2
fi
done
exec "$REAL_GIT" "$@"
EOF
chmod +x "$fake_bin/git"
set +e
PATH="$fake_bin:$PATH" REAL_GIT="$real_git" "$gate_bash" "$gate" --root "$fixture" --runtime-only >"$output" 2>&1
gate_status=$?
set -e
[[ $gate_status -eq 2 ]] || fail "git grep status 2 was masked as $gate_status"
grep -Fq 'simulated git grep failure' "$output" || fail "git grep failure diagnostics were lost"
# Foreign roots are test-only and can never select fixture code for a full check.
set +e
"$gate_bash" "$gate" --root "$fixture" >"$output" 2>&1
gate_status=$?
set -e
[[ $gate_status -ne 0 ]] || fail "foreign-root full mode unexpectedly passed"
grep -Fq -- '--root is available only with --runtime-only or --bootstrap-trust-only' "$output" \
|| fail "foreign-root full rejection did not report the trust boundary"
# Prescribed symbols are forbidden as case-insensitive substrings, including derivatives.
derivative_names=(WorkspaceV2Compat wOrKsPaCeV2 deprecatedV2Descriptor WRITEMIGRATEDWORKSPACE LegacyWorkspaceAdapter migrateLegacyWorkspaceCompat)
for derivative in "${derivative_names[@]}"; do
for matrix_root in "${policy_roots[@]}"; do
seed_fixture
matrix_path="$matrix_root/derivative.ts"
printf '%s\n' "$derivative" >"$fixture/$matrix_path"
commit_fixture "derivative-$derivative-${matrix_root//\//-}"
expect_rejected "derivative $derivative in $matrix_root" "$matrix_path"
done
done
# Mixed/all-lower legacy spellings fail; the approved lower-camel identifier remains valid.
for spelling in legacyworkspace LeGaCyWoRkSpAcE; do
seed_fixture
printf '%s\n' "$spelling" >"$fixture/backend/src/legacy-variant.ts"
commit_fixture legacy-spelling
expect_rejected "legacy spelling $spelling" "backend/src/legacy-variant.ts"
done
seed_fixture
printf '%s\n' 'const legacyWorkspacePath = current;' >"$fixture/backend/src/legacy-allowed.ts"
commit_fixture lower-camel-legacy
expect_pass "approved lower-camel legacy identifier"
# Full-text revision scanning covers bracket access and newline-separated dot access.
for revision_source in \
'selectedWorkspace["state"]' \
$'workspaceRevision\n .state'; do
seed_fixture
printf '%s\n' "$revision_source" >"$fixture/frontend/src/revision-variant.ts"
commit_fixture revision-variant
expect_rejected "revision structural variant" "frontend/src/revision-variant.ts"
done
seed_fixture
mkdir -p "$fixture/backend/src/workspaces"
printf '%s\n' 'if (revision.state !== "operational") return;' >"$fixture/backend/src/workspaces/registry.ts"
commit_fixture historical-decoder
expect_pass "single exact historical decoder"
printf '%s\n' 'if (revision["state"] === "retired") return;' >>"$fixture/backend/src/workspaces/registry.ts"
commit_fixture extra-historical-branch
expect_rejected "extra registry revision branch" "backend/src/workspaces/registry.ts"
# Binary/NUL policy files are decoded and rejected rather than skipped by git grep -I.
seed_fixture
printf 'WorkspaceV2\0hidden\n' >"$fixture/backend/src/binary.ts"
commit_fixture nul-policy
expect_rejected "NUL policy file" "backend/src/binary.ts"
# Workspace fixture-family discovery is recursive by basename.
seed_fixture
mkdir -p "$fixture/scripts/fixtures/nested/deeper"
write_fixture_descriptor "$fixture/scripts/fixtures/nested/deeper/workspace-registry-nested.yaml" workspace: ' schema_version: 2'
commit_fixture nested-workspace-fixture
expect_rejected "nested workspace fixture" "scripts/fixtures/nested/deeper/workspace-registry-nested.yaml"
# Python bytecode is disabled before pre-gate docs, and release dry-run starts with bootstrap trust.
grep -Fq 'PYTHONDONTWRITEBYTECODE: "1"' "$project_root/.github/workflows/deployment.yml" \
|| fail "workflow does not disable Python bytecode"
grep -Fq 'export PYTHONDONTWRITEBYTECODE=1' "$project_root/scripts/verify-schema-v3-only-release.sh" \
|| fail "release wrapper does not disable Python bytecode"
release_plan="$($gate_bash "$project_root/scripts/verify-schema-v3-only-release.sh" --dry-run)"
first_command="$(printf '%s\n' "$release_plan" | sed -n '1p')"
bootstrap_command="$(printf '%s\n' "$release_plan" | sed -n '4p')"
[[ "$first_command" == 'export PYTHONDONTWRITEBYTECODE=1' ]] \
|| fail "release dry-run does not print the Python bytecode export"
[[ "$bootstrap_command" == '/bin/bash scripts/verify-schema-v3-only.sh --bootstrap-trust-only' ]] \
|| fail "release plan does not bootstrap trust before npm"
printf '%s\n' "$release_plan" | grep -Fq '(cd backend && npm ci --ignore-scripts)' \
|| fail "release plan does not disable npm lifecycle scripts"
py_fixture="$sandbox/python-bytecode"
mkdir -p "$py_fixture/scripts"
printf '%s\n' 'value = 3' >"$py_fixture/scripts/module.py"
PYTHONPATH="$py_fixture" PYTHONDONTWRITEBYTECODE=1 python3 -c 'import scripts.module'
[[ ! -e "$py_fixture/scripts/__pycache__" ]] || fail "pre-gate Python created ignored bytecode"
seed_bootstrap_fixture() {
seed_fixture
mkdir -p "$fixture/.github/workflows"
for required in \
backend/package.json backend/package-lock.json \
backend/scripts/verify-workspace-descriptor-files.mjs \
backend/scripts/verify-workspace-descriptor-files.test.mjs \
backend/scripts/revision-state-policy.mjs \
backend/scripts/revision-state-policy.test.mjs \
backend/scripts/bash-heredoc.mjs \
backend/scripts/revision_state_policy.py \
backend/scripts/test_revision_state_policy.py \
scripts/verify-schema-v3-only.sh scripts/test-verify-schema-v3-only.sh \
scripts/verify-schema-v3-only-release.sh scripts/workspace_descriptor_doc_contract.py \
.github/workflows/deployment.yml; do
mkdir -p "$fixture/${required%/*}"
cp "$project_root/$required" "$fixture/$required"
done
"$real_git" -C "$fixture" add .
"$real_git" -C "$fixture" commit -qm bootstrap-files
}
assert_release_stops_before_npm() {
local label="$1"
fake_lifecycle="$sandbox/fake-lifecycle"
mkdir -p "$fake_lifecycle"
cat >"$fake_lifecycle/npm" <<EOF
#!/usr/bin/env bash
echo invoked >>"$sandbox/npm-invoked"
exit 99
EOF
chmod +x "$fake_lifecycle/npm"
rm -f "$sandbox/npm-invoked"
set +e
PATH="$fake_lifecycle:$PATH" /bin/bash "$fixture/scripts/verify-schema-v3-only-release.sh" >"$output" 2>&1
release_status=$?
set -e
[[ $release_status -ne 0 ]] || fail "$label unexpectedly passed"
[[ ! -e "$sandbox/npm-invoked" ]] || fail "$label invoked npm before bootstrap trust"
}
seed_bootstrap_fixture
printf '%s\n' '# dirty' >>"$fixture/backend/package.json"
assert_release_stops_before_npm "dirty package bootstrap"
seed_bootstrap_fixture
printf '%s\n' '# dirty' >>"$fixture/backend/scripts/verify-workspace-descriptor-files.test.mjs"
assert_release_stops_before_npm "dirty checker test bootstrap"
seed_bootstrap_fixture
printf '%s\n' '// dirty' >>"$fixture/backend/scripts/revision-state-policy.mjs"
assert_release_stops_before_npm "dirty revision policy bootstrap"
seed_bootstrap_fixture
printf '%s\n' '# dirty' >>"$fixture/backend/scripts/revision_state_policy.py"
assert_release_stops_before_npm "dirty Python policy helper bootstrap"
seed_bootstrap_fixture
printf '%s\n' '# dirty' >>"$fixture/scripts/verify-schema-v3-only.sh"
assert_release_stops_before_npm "dirty gate bootstrap"
seed_bootstrap_fixture
rm "$fixture/backend/scripts/verify-workspace-descriptor-files.mjs"
ln -s /dev/null "$fixture/backend/scripts/verify-workspace-descriptor-files.mjs"
assert_release_stops_before_npm "symlink checker bootstrap"
seed_bootstrap_fixture
printf '%s\n' 'scripts/__pycache__/' >"$fixture/.gitignore"
"$real_git" -C "$fixture" add .gitignore
"$real_git" -C "$fixture" commit -qm ignore-rule
mkdir -p "$fixture/scripts/__pycache__"
printf x >"$fixture/scripts/__pycache__/ignored.pyc"
assert_release_stops_before_npm "ignored trusted artifact bootstrap"
seed_bootstrap_fixture
printf x >"$fixture/scripts/untracked-helper.sh"
assert_release_stops_before_npm "untracked helper bootstrap"
seed_bootstrap_fixture
mkfifo "$fixture/scripts/bootstrap-fifo"
assert_release_stops_before_npm "FIFO bootstrap"
seed_bootstrap_fixture
printf '%s\n' unsafe >"$fixture/backend/.npmrc"
assert_release_stops_before_npm "untracked backend npmrc bootstrap"
seed_bootstrap_fixture
global_ignore="$sandbox/global-ignore"
printf '%s\n' backend/.npmrc >"$global_ignore"
"$real_git" -C "$fixture" config core.excludesFile "$global_ignore"
printf '%s\n' unsafe >"$fixture/backend/.npmrc"
assert_release_stops_before_npm "globally ignored backend npmrc bootstrap"
# Dist failures are isolated to fixture roots; canonical backend/dist is never mutated.
run_gate_dist() {
set +e
"$gate_bash" "$gate" --root "$fixture" --runtime-only --check-dist >"$output" 2>&1
gate_status=$?
set -e
}
seed_fixture
mkdir -p "$fixture/backend/dist"
printf '%s\n' server >"$fixture/backend/dist/server.js"
run_gate_dist
[[ $gate_status -eq 1 ]] || fail "fixture missing schema module unexpectedly passed"
grep -Fq 'backend/dist/workspaces/schema.js' "$output" || fail "fixture missing schema path not reported"
seed_fixture
mkdir -p "$fixture/backend/dist/workspaces"
printf '%s\n' schema >"$fixture/backend/dist/workspaces/schema.js"
run_gate_dist
[[ $gate_status -eq 1 ]] || fail "fixture missing server unexpectedly passed"
grep -Fq 'backend/dist/server.js' "$output" || fail "fixture missing server path not reported"
seed_fixture
mkdir -p "$fixture/backend/dist/workspaces"
printf '%s\n' schema >"$fixture/backend/dist/workspaces/schema.js"
printf '%s\n' server >"$fixture/backend/dist/server.js"
printf '%s\n' stale >"$fixture/backend/dist/workspaces/Migrate-Legacy.js"
run_gate_dist
[[ $gate_status -eq 1 ]] || fail "fixture stale migrator unexpectedly passed"
grep -Fq 'backend/dist/workspaces/Migrate-Legacy.js' "$output" || fail "fixture stale migrator path not reported"
[[ "$(cksum <"$canonical_schema")" == "$canonical_schema_checksum" ]] \
|| fail "shell regression mutated canonical compiled schema"
[[ "$(cksum <"$canonical_server")" == "$canonical_server_checksum" ]] \
|| fail "shell regression mutated canonical compiled server"
echo "schema-v3-only absence gate regression tests passed"
+2 -25
View File
@@ -478,31 +478,8 @@ task13_seed_registry() {
task13_run_logged "initialize bare workspace registry" \ task13_run_logged "initialize bare workspace registry" \
git init --bare --initial-branch=main "$TASK13_REMOTE" git init --bare --initial-branch=main "$TASK13_REMOTE"
task13_run_logged "initialize workspace seed" git -C "$TASK13_SEED" init --initial-branch=main task13_run_logged "initialize workspace seed" git -C "$TASK13_SEED" init --initial-branch=main
cat >"$TASK13_SEED/workspaces/task13-smoke.yaml" <<'EOF' cp "$TASK13_ROOT/scripts/fixtures/workspace-registry-task13.yaml" \
workspace: "$TASK13_SEED/workspaces/task13-smoke.yaml"
schema_version: 3
id: task13-smoke
name: Task 13 Smoke
language: en
dwh:
engine: postgres
database: warehouse
schema: analytics
supported_transports: [postgres_direct]
semantic_index:
vector_store:
engine: qdrant
collection: task13-smoke
dimensions: 1024
distance: cosine
embedding:
provider: ollama_internal
model: qwen3-embedding:0.6b
dimensions: 1024
llm_policy:
default: local-qwen/task13-smoke
allowed: [local-qwen/task13-smoke]
EOF
task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" add workspaces/task13-smoke.yaml task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" add workspaces/task13-smoke.yaml
task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" \ task13_run_logged "commit initial workspace" git -C "$TASK13_SEED" \
-c user.name='Task 13 Smoke' -c user.email='task13-smoke@example.invalid' \ -c user.name='Task 13 Smoke' -c user.email='task13-smoke@example.invalid' \
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Reproducible schema-v3-only release gate. The Git checkout is the trust root;
# dependencies come from backend/package-lock.json and dist comes from a clean build.
set -euo pipefail
export PYTHONDONTWRITEBYTECODE=1
export NPM_CONFIG_USERCONFIG=/dev/null
export NPM_CONFIG_GLOBALCONFIG=/dev/null
root="$(cd "$(dirname "$0")/.." && pwd -P)"
if [[ ${1:-} == --dry-run ]]; then
cat <<'EOF'
export PYTHONDONTWRITEBYTECODE=1
export NPM_CONFIG_USERCONFIG=/dev/null
export NPM_CONFIG_GLOBALCONFIG=/dev/null
/bin/bash scripts/verify-schema-v3-only.sh --bootstrap-trust-only
(cd backend && npm ci --ignore-scripts)
(cd backend && npm run build)
(cd backend && npm run test:schema-v3-verifier)
/bin/bash scripts/test-verify-schema-v3-only.sh
/bin/bash scripts/verify-schema-v3-only.sh
EOF
exit 0
fi
[[ $# -eq 0 ]] || { echo "usage: $0 [--dry-run]" >&2; exit 2; }
/bin/bash "$root/scripts/verify-schema-v3-only.sh" --bootstrap-trust-only
(cd "$root/backend" && npm ci --ignore-scripts)
(cd "$root/backend" && npm run build)
(cd "$root/backend" && npm run test:schema-v3-verifier)
/bin/bash "$root/scripts/test-verify-schema-v3-only.sh"
/bin/bash "$root/scripts/verify-schema-v3-only.sh"
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# Fail-closed absence gate for the supported schema-v3-only workspace runtime.
set -euo pipefail
shopt -s nocasematch
script_root="$(cd "$(dirname "$0")/.." && pwd -P)"
root_argument="$script_root"
root_was_selected=0
runtime_only=0
check_dist=0
bootstrap_trust_only=0
usage() {
cat >&2 <<EOF
usage: $0 [--bootstrap-trust-only [--root REPOSITORY]]
$0 [--runtime-only [--root REPOSITORY] [--check-dist]]
Default mode trusts the canonical Git checkout, runs a clean backend build, and
then validates generated output and documentation. Runtime-only uses the trusted
canonical Node installation, dependencies, verifier, and built
backend/dist/workspaces/schema.js; --root is only for isolated Git fixtures.
--check-dist makes an isolated runtime fixture provide backend/dist/schema.js and
server.js and checks it for stale migrators. Full mode never accepts overrides.
Expandable deployment blocks are trusted only by repository-relative path plus the
SHA-256 of their exact raw opener/body/closer bytes in the Node verifier. This is
an exact-content trust exception with rationale metadata, not semantic proof.
Release trust anchor and order (durable entry point):
Git index/filesystem trust is established by --bootstrap-trust-only before any
checkout-controlled helper or npm lifecycle can run. CI supplies a clean Git
checkout and performs an inline clean-checkout assertion before the wrapper.
scripts/verify-schema-v3-only-release.sh then runs npm ci --ignore-scripts from the trusted
backend/package-lock.json; clean build; npm Node verifier test; Bash regression;
and the full schema-v3-only gate, which repeats trust checks.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root_argument="$2"; root_was_selected=1; shift 2 ;;
--runtime-only) runtime_only=1; shift ;;
--check-dist) check_dist=1; shift ;;
--bootstrap-trust-only) bootstrap_trust_only=1; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
[[ $root_was_selected -eq 0 || $runtime_only -eq 1 || $bootstrap_trust_only -eq 1 ]] || { echo "--root is available only with --runtime-only or --bootstrap-trust-only" >&2; exit 2; }
[[ $runtime_only -eq 0 || $bootstrap_trust_only -eq 0 ]] || { echo "--runtime-only and --bootstrap-trust-only are mutually exclusive" >&2; exit 2; }
[[ $check_dist -eq 0 || $runtime_only -eq 1 ]] || { echo "--check-dist is available only with --runtime-only" >&2; exit 2; }
if ! root="$(cd "$root_argument" 2>/dev/null && pwd -P)"; then
printf 'repository root is not accessible: %q\n' "$root_argument" >&2
exit 2
fi
[[ $runtime_only -eq 1 || $bootstrap_trust_only -eq 1 || "$root" == "$script_root" ]] || { echo "full mode is restricted to the canonical repository" >&2; exit 2; }
tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-v3-gate.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
if git_top="$(git -C "$root" rev-parse --show-toplevel 2>"$tmp/rev-parse")"; then :; else
status=$?; printf 'Git repository discovery failed for %q\n' "$root" >&2; cat "$tmp/rev-parse" >&2; exit "$status"
fi
canonical_git_top="$(cd "$git_top" && pwd -P)"
[[ "$canonical_git_top" == "$root" ]] || { printf '%s\n' "--root must name the canonical Git root" >&2; exit 2; }
for npmrc in .npmrc backend/.npmrc; do
if [[ -e "$root/$npmrc" || -L "$root/$npmrc" ]]; then
printf 'npm configuration node forbidden: %q\n' "$npmrc" >&2
exit 1
fi
done
policy_roots=(backend/src frontend/src backend/scripts scripts)
trust_roots=(backend/src frontend/src backend/scripts scripts deploy/workspaces)
print_path() { printf '%q' "$1"; }
fail_path() { local message="$1" path="$2"; printf '%s: ' "$message" >&2; print_path "$path" >&2; printf '\n' >&2; return 1; }
forbidden_module_stems='deprecated-v2-descriptor|migrate-legacy|migrate-v2-qdrant'
is_deleted_basename() {
[[ "${1##*/}" =~ ^($forbidden_module_stems)(\..*)?$ ]]
}
# Exact path + category exceptions only. They remain fully subject to trust checks.
is_allowed_match() {
local category="$1" path="$2"
case "$category:$path" in
prescribed-symbol:scripts/verify-schema-v3-only.sh|prescribed-symbol:scripts/test-verify-schema-v3-only.sh|prescribed-symbol:backend/scripts/verify-workspace-descriptor-files.mjs|prescribed-symbol:backend/scripts/verify-workspace-descriptor-files.test.mjs|prescribed-symbol:backend/scripts/revision-state-policy.mjs|prescribed-symbol:backend/scripts/revision-state-policy.test.mjs|prescribed-symbol:backend/scripts/bash-heredoc.mjs|prescribed-symbol:backend/scripts/revision_state_policy.py|prescribed-symbol:backend/scripts/test_revision_state_policy.py) return 0 ;;
legacy-workspace:scripts/verify-schema-v3-only.sh|legacy-workspace:scripts/test-verify-schema-v3-only.sh|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.mjs|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.test.mjs|legacy-workspace:backend/scripts/revision-state-policy.mjs|legacy-workspace:backend/scripts/revision-state-policy.test.mjs|legacy-workspace:backend/scripts/bash-heredoc.mjs|legacy-workspace:backend/scripts/revision_state_policy.py|legacy-workspace:backend/scripts/test_revision_state_policy.py) return 0 ;;
migration-marker:scripts/verify-schema-v3-only.sh|migration-marker:scripts/test-verify-schema-v3-only.sh|migration-marker:backend/scripts/verify-workspace-descriptor-files.mjs|migration-marker:backend/scripts/verify-workspace-descriptor-files.test.mjs|migration-marker:backend/scripts/revision-state-policy.mjs|migration-marker:backend/scripts/revision-state-policy.test.mjs|migration-marker:backend/scripts/bash-heredoc.mjs|migration-marker:backend/scripts/revision_state_policy.py|migration-marker:backend/scripts/test_revision_state_policy.py) return 0 ;;
migration-marker:scripts/workspace_descriptor_doc_contract.py|migration-marker:scripts/test_workspace_descriptor_doc_contract.py) return 0 ;;
migration-marker:backend/scripts/clean-dist.test.mjs) return 0 ;;
*) return 1 ;;
esac
}
# Common policy is defined once and scanned over every policy root. Revision-state is the sole layer.
prescribed_symbol_forbidden='WorkspaceV1|WorkspaceV2|DeprecatedV2Descriptor|LegacyMigrationResult|LegacyMigrationOptions|WorkspaceV2MigrationInput|migrateLegacyWorkspace|writeMigratedWorkspace|migrateWorkspaceV1ToV2|migrateWorkspaceV2ToV3'
legacy_workspace_forbidden='LegacyWorkspace'
migration_marker_forbidden="migration_required|($forbidden_module_stems)"
require_category_absent() {
local category="$1" sensitivity="$2" pattern="$3" output="$tmp/grep-$1" status path
if [[ "$sensitivity" == insensitive ]]; then
if git -C "$root" grep -z -l -I -i -E -e "$pattern" -- "${policy_roots[@]}" >"$output" 2>"$output.err"; then status=0; else status=$?; fi
else
if git -C "$root" grep -z -l -I -E -e "$pattern" -- "${policy_roots[@]}" >"$output" 2>"$output.err"; then status=0; else status=$?; fi
fi
case "$status" in
0)
while IFS= read -r -d '' path; do
is_allowed_match "$category" "$path" && continue
printf 'forbidden %s match: ' "$category" >&2; print_path "$path" >&2; printf '\n' >&2
return 1
done <"$output"
;;
1) : ;;
*) printf 'scanner failure (%s): %s\n' "$status" "$category" >&2; cat "$output.err" >&2; return "$status" ;;
esac
}
# One batched index inventory validates modes and working-tree presence for all tracked paths.
index_entries="$tmp/index"
if git -C "$root" ls-files -s -z -- "${trust_roots[@]}" >"$index_entries" 2>"$tmp/index.err"; then :; else
status=$?; echo "tracked index scan failed ($status)" >&2; cat "$tmp/index.err" >&2; exit "$status"
fi
tracked_paths="$tmp/tracked"
: >"$tracked_paths"
while IFS= read -r -d '' record; do
metadata="${record%%$'\t'*}"; path="${record#*$'\t'}"; mode="${metadata%% *}"
case "$mode" in 100*) ;; 120000) fail_path "tracked symlink forbidden" "$path"; exit 1 ;; *) fail_path "non-regular tracked entry forbidden" "$path"; exit 1 ;; esac
printf '%s\0' "$path" >>"$tracked_paths"
[[ -f "$root/$path" && ! -L "$root/$path" ]] || { fail_path "tracked file missing or unsafe" "$path"; exit 1; }
is_deleted_basename "$path" && { fail_path "deleted source basename remains tracked" "$path"; exit 1; }
done <"$index_entries"
# Filesystem node trust has no test/fixture exclusions.
filesystem="$tmp/filesystem"
if find "${trust_roots[@]/#/$root/}" -mindepth 1 -print0 >"$filesystem" 2>"$tmp/find.err"; then :; else
status=$?; echo "filesystem trust scan failed ($status)" >&2; cat "$tmp/find.err" >&2; exit "$status"
fi
while IFS= read -r -d '' absolute; do
path="${absolute#"$root/"}"
[[ ! -L "$absolute" ]] || { fail_path "symlink forbidden in trusted root" "$path"; exit 1; }
[[ -d "$absolute" || -f "$absolute" ]] || { fail_path "non-directory/non-regular node forbidden in trusted root" "$path"; exit 1; }
is_deleted_basename "$path" && { fail_path "deleted source basename remains on filesystem" "$path"; exit 1; }
done <"$filesystem"
reject_name_list() {
local label="$1" file="$2" path
while IFS= read -r -d '' path; do fail_path "$label" "$path"; return 1; done <"$file"
}
for spec in "untracked:--others --exclude-standard" "ignored:--others --ignored --exclude-standard"; do
label="${spec%%:*}"; options="${spec#*:}"; output="$tmp/$label"
# shellcheck disable=SC2086
if git -C "$root" ls-files -z $options -- "${trust_roots[@]}" >"$output" 2>"$output.err"; then :; else
status=$?; echo "$label scan failed ($status)" >&2; cat "$output.err" >&2; exit "$status"
fi
reject_name_list "$label file in trusted root" "$output" || exit 1
done
for mode in worktree cached; do
output="$tmp/dirty-$mode"
if [[ "$mode" == cached ]]; then command=(git -C "$root" diff --cached --name-only -z --); else command=(git -C "$root" diff --name-only -z --); fi
if "${command[@]}" "${trust_roots[@]}" >"$output" 2>"$output.err"; then :; else
status=$?; echo "$mode dirty scan failed ($status)" >&2; cat "$output.err" >&2; exit "$status"
fi
reject_name_list "modified trusted file ($mode)" "$output" || exit 1
done
require_trusted_files_at() {
local repository="$1"; shift
local listing="$tmp/explicit-$RANDOM" record metadata path mode expected
if git -C "$repository" ls-files -s -z -- "$@" >"$listing" 2>"$listing.err"; then :; else
status=$?; echo "explicit trust index scan failed ($status)" >&2; cat "$listing.err" >&2; return "$status"
fi
: >"$listing.paths"
while IFS= read -r -d '' record; do
metadata="${record%%$'\t'*}"; path="${record#*$'\t'}"; mode="${metadata%% *}"
case "$mode" in 100*) ;; *) fail_path "required file is not regular in index" "$path"; return 1 ;; esac
printf '%s\n' "$path" >>"$listing.paths"
done <"$listing"
for expected in "$@"; do
[[ -f "$repository/$expected" && ! -L "$repository/$expected" ]] || { fail_path "required trusted file missing or unsafe" "$expected"; return 1; }
grep -Fqx -- "$expected" "$listing.paths" || { fail_path "required file is not tracked" "$expected"; return 1; }
done
git -C "$repository" diff --quiet -- "$@" || { echo "required trust files are dirty" >&2; return 1; }
git -C "$repository" diff --quiet --cached -- "$@" || { echo "required trust files are staged dirty" >&2; return 1; }
}
# Bootstrap uses only Git/filesystem primitives. It must precede every npm or
# checkout-controlled helper in the durable release wrapper.
bootstrap_files=(
backend/package.json backend/package-lock.json
backend/scripts/verify-workspace-descriptor-files.mjs
backend/scripts/verify-workspace-descriptor-files.test.mjs
backend/scripts/revision-state-policy.mjs
backend/scripts/revision-state-policy.test.mjs
backend/scripts/bash-heredoc.mjs
backend/scripts/revision_state_policy.py
backend/scripts/test_revision_state_policy.py
scripts/verify-schema-v3-only.sh scripts/test-verify-schema-v3-only.sh
scripts/verify-schema-v3-only-release.sh .github/workflows/deployment.yml
scripts/workspace_descriptor_doc_contract.py
)
if [[ $bootstrap_trust_only -eq 1 ]]; then
require_trusted_files_at "$root" "${bootstrap_files[@]}"
echo "schema-v3-only bootstrap trust passed"
exit 0
fi
# Runtime fixtures execute only canonical trusted verifier code and dependencies.
require_trusted_files_at "$script_root" \
backend/scripts/verify-workspace-descriptor-files.mjs \
backend/scripts/revision-state-policy.mjs \
backend/scripts/bash-heredoc.mjs \
backend/scripts/revision_state_policy.py \
backend/package.json backend/package-lock.json \
scripts/verify-schema-v3-only.sh
workspace_verifier="$script_root/backend/scripts/verify-workspace-descriptor-files.mjs"
workspace_schema="$script_root/backend/dist/workspaces/schema.js"
if [[ $runtime_only -eq 0 ]]; then
require_trusted_files_at "$root" \
scripts/verify-schema-v3-only-release.sh .github/workflows/deployment.yml
(cd "$root/backend" && npm run build)
fi
[[ -f "$workspace_schema" && ! -L "$workspace_schema" ]] || { echo "trusted compiled schema module is missing or unsafe: backend/dist/workspaces/schema.js" >&2; exit 1; }
workspace_manifest="$tmp/workspace-manifest"
: >"$workspace_manifest"
while IFS= read -r -d '' path; do
case "$path" in
backend/src/*|frontend/src/*|backend/scripts/*|scripts/*) printf '%s\0%s\0' policy_text "$path" >>"$workspace_manifest" ;;
esac
kind=""
case "$path" in
deploy/workspaces/preprocess-dwh.yaml|deploy/workspaces/preprocess-evidence.yaml|deploy/workspaces/server-sessions.yaml.example) ;;
deploy/workspaces/*.yaml|deploy/workspaces/*.yml|deploy/workspaces/*.yaml.example|deploy/workspaces/*.yml.example|scripts/fixtures/workspace-registry-*.yaml|scripts/fixtures/workspace-registry-*.yml|scripts/fixtures/*/workspace-registry-*.yaml|scripts/fixtures/*/workspace-registry-*.yml) kind=workspace_descriptor ;;
scripts/*.sh|scripts/*.ps1)
case "$path" in scripts/verify-schema-v3-only.sh|scripts/test-verify-schema-v3-only.sh) ;; *) kind=deployment_script ;; esac
;;
esac
[[ -z "$kind" ]] || printf '%s\0%s\0' "$kind" "$path" >>"$workspace_manifest"
done <"$tracked_paths"
node "$workspace_verifier" --root "$root" --manifest "$workspace_manifest"
require_category_absent prescribed-symbol insensitive "$prescribed_symbol_forbidden"
require_category_absent legacy-workspace sensitive "$legacy_workspace_forbidden"
require_category_absent migration-marker insensitive "$migration_marker_forbidden"
check_dist_tree() {
local dist_root="$1" entries="$tmp/dist" absolute path
[[ -f "$dist_root/backend/dist/workspaces/schema.js" && ! -L "$dist_root/backend/dist/workspaces/schema.js" ]] || { echo "expected compiled schema module is missing or unsafe: backend/dist/workspaces/schema.js" >&2; return 1; }
[[ -f "$dist_root/backend/dist/server.js" && ! -L "$dist_root/backend/dist/server.js" ]] || { echo "expected compiled backend output is missing or unsafe: backend/dist/server.js" >&2; return 1; }
find "$dist_root/backend/dist" -mindepth 1 -print0 >"$entries"
while IFS= read -r -d '' absolute; do
path="${absolute#"$dist_root/"}"
if is_deleted_basename "$path"; then
fail_path "stale compiled workspace migrator output exists" "$path"
return 1
fi
done <"$entries"
}
[[ $runtime_only -eq 1 && $check_dist -eq 0 ]] || check_dist_tree "$root"
if [[ $runtime_only -eq 0 ]]; then
require_trusted_files_at "$root" \
scripts/workspace_descriptor_doc_contract.py README.md PROJECT_STATE.md \
docs/install/local-workspace-registry.md docs/install/server-workspace-registry.md \
docs/workspace-diagnostic-protocol.md
"$root/scripts/workspace_descriptor_doc_contract.py" \
--document "$root/README.md" --project-state "$root/PROJECT_STATE.md" \
--document "$root/docs/install/local-workspace-registry.md" \
--document "$root/docs/install/server-workspace-registry.md" \
--document "$root/docs/workspace-diagnostic-protocol.md"
fi
echo "schema-v3-only absence gate passed"