Files
plugin-legal-ai/scripts/int9-guard.mjs
Chaim Marcus b24d816cc0 chore(int9): שער-סטטי ל-INV-INT9 — issues.update({status}) רק במסלול sync-case-status (legal-ai #618)
ל-`issue.status` כותב לגיטימי אחד בפלאגין: הג'וב `sync-case-status`
(`ctx.jobs.register("sync-case-status", …)` ב-`src/worker.ts`). עד היום
האכיפה הייתה code-review בלבד — וזה בדיוק מה שאפשר את הבאג של legal-ai
‏#446 (flip-flop `done → in_progress`), כותב שני שנכנס בלי שאיש שם לב.

`scripts/int9-guard.mjs` — Node ESM חסר-תלויות. טוקנייזר תו-תו
(מחרוזות/תבניות עם `${}` מקונן/הערות/רגקס-ליטרלים) בונה מסכת "קוד רגיל",
ומעליה: כל `jobs.register("sync-case-status", …)` מגדיר **טווח-תווים מותר**
(איזון-סוגריים), וכל `issues.update(…)` שרשימת-הארגומנטים שלו מכילה את
המזהה `status` חייב לשבת בתוכו.

הגרעיניות היא טווח-קוד ולא רשימת-קבצים (הדפוס של `leak_guard.py` ב-legal-ai)
במכוון: הקריאה המותרת היחידה וכל קריאה עתידית אסורה יושבות באותו
`worker.ts`. הטווח נגזר מהמבנה האמיתי, ולכן שורד מעבר של הג'וב לקובץ אחר —
ושינוי שם-הג'וב מפיל את השער בקול במקום לפתוח חור שקט.

חריגה מכוונת: `// noqa: INT9 — <נימוק>`. `noqa: INT9` בלי נימוק הוא הפרה
בעצמו — אין השתקה שקטה. כשל-פענוח (סוגר שלא נסגר) מפיל את השער ולא נבלע.

`--self-test` מריץ 5 פיקסצ'רים (`scripts/fixtures/int9/`, מחוץ ל-`src/`
בכוונה) ומאמת ספירת-הפרות מדויקת — הוכחה שהשער נושך.

**ללא חיווט-CI.** ה-runner היחיד על nautilus רשום בהיקף-repo ל-`legal-ai`
בלבד (`action_runner.repo_id = 6`), ולכן job של הריפו הזה לא נאסף לעולם
(נמדד: ריצה 3200 / job 3250 תקוע ב-`queued`, `started_at: 1970-01-01`).
קובץ ה-workflow יושב ב-PR נפרד וחסום, כדי שלא ייכנס ל-`main` שער שמייצר
check תקוע-לנצח בכל PR עתידי. עד אז: `npm run int9:guard` /
`npm run int9:guard:self-test`.

מגבלה מוצהרת: תיל-מעידה סטטי, לא הוכחה — `issues.update(id, patch)` שבו
אובייקט-העדכון נבנה במקום אחר אינו נתפס.

Refs ezer-mishpati/legal-ai#618

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 04:40:19 +03:00

547 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* INV-INT9 guard — enforce single write-ownership of `issue.status`
* (legal-ai docs/spec/X7-paperclip-client-params.md §4 INV-INT9).
*
* `issues.status` has exactly one legitimate writer in this plugin: the
* `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)`
* in `src/worker.ts`), which mirrors legal-ai's case status onto the linked
* Paperclip issue. Any OTHER `ctx.issues.update(...)` call that touches
* `status` is a second writer racing the sync job — exactly the flip-flop
* (`done` → `in_progress`) bug class legal-ai issue #618 exists to close off
* for good, before a second call site is ever added.
*
* IN SCOPE: every `.ts`/`.tsx` file under `src/` (including `*.test.ts` —
* there is no false positive there today, and the noqa escape hatch below
* covers the day there is one).
*
* OUT OF SCOPE: this is NOT a general call-graph analysis. It is a
* structural, single-pass lexer over the source text — no type information,
* no cross-file resolution. It answers exactly one question per call site:
* "does the character range of this `ctx.issues.update(...)` call sit
* inside the character range of the one whitelisted
* `ctx.jobs.register("sync-case-status", …)` call?". Deliberately NOT a
* per-file allowlist (the pattern `scripts/leak_guard.py` in legal-ai uses)
* — that shape does not fit here, because the one legitimate call site and
* every future illegitimate one live in the very same file (`worker.ts`).
* The granularity has to be a code RANGE derived from the real structure,
* not a file name.
*
* Escape hatch (G1 — fix at the source, don't paper over a false positive):
* a call can be justified in place with
* // noqa: INT9 — <reason, at least 3 characters>
* on the line immediately above the call, or on any line spanned by its
* argument list. A bare `// noqa: INT9` with no reason is itself a
* violation (AC3 — exceptions must be justified in writing, not silent).
*
* Usage:
* node scripts/int9-guard.mjs # scan src/**\/*.{ts,tsx}; exit 1 on any violation
* node scripts/int9-guard.mjs <path>... # scan only the given files/directories
* node scripts/int9-guard.mjs --self-test # run the fixture suite (scripts/fixtures/int9/), prove the gate bites
*
* NOT WIRED INTO CI YET: the Gitea Actions runner on this server is
* registered at *repository* scope for `legal-ai` only
* (`action_runner.repo_id = 6`), so any job queued for `plugin-legal-ai` is
* never dispatched — measured on legal-ai issue #618, run 3200 / job 3250
* sat in `queued` with `started_at: 1970-01-01`. Until a runner is
* registered for this repo, run this by hand:
* npm run int9:guard
* npm run int9:guard:self-test
* The workflow file itself (`.gitea/workflows/int9-guard.yaml`) lives in a
* separate, deliberately-blocked PR, so merging it does not leave a
* permanently-pending check on every future PR in this repo.
*
* Zero dependencies (node:fs / node:path / node:process only) — no `npm ci`
* needed in CI; the runner image ships node 24.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import process from "node:process";
const REPO_ROOT = resolve(import.meta.dirname, "..");
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
const SOURCE_EXT = new Set([".ts", ".tsx"]);
const SPEC_REF = "docs/spec/X7-paperclip-client-params.md §4 INV-INT9";
const NOQA_RE = /\/\/\s*noqa\s*:\s*INT9\b(?:\s*[-—]\s*(.*))?/i;
const REGEX_PREV_CHARS = new Set("(,=:[!&|?{};+-*%~^<>".split(""));
const REGEX_PREV_WORDS = new Set([
"return",
"typeof",
"case",
"in",
"of",
"new",
"delete",
"void",
"instanceof",
"do",
"else",
"yield",
"await",
]);
// ---------------------------------------------------------------------
// Tokenizer — classify every character as "plain code" or not, so that
// structural regexes and paren-matching never see string/template
// contents, comments, or regex-literal bodies.
// ---------------------------------------------------------------------
/** @returns {Uint8Array} mask[i] === 1 iff text[i] is plain code. */
function classify(text) {
const n = text.length;
const mask = new Uint8Array(n);
/** @type {Array<{kind: "template"} | {kind: "exprCode", depth: number}>} */
const stack = [];
let state = "code";
let lastSignificant = "";
let lastWord = "";
let i = 0;
function noteCodeChar(ch) {
if (/\s/.test(ch)) return;
lastSignificant = ch;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord += ch;
} else {
lastWord = "";
}
}
while (i < n) {
const ch = text[i];
if (state === "code") {
const next = text[i + 1];
if (ch === "/" && next === "/") {
state = "lcomment";
i += 2;
continue;
}
if (ch === "/" && next === "*") {
state = "bcomment";
i += 2;
continue;
}
if (ch === "'") {
state = "squote";
i += 1;
continue;
}
if (ch === '"') {
state = "dquote";
i += 1;
continue;
}
if (ch === "`") {
stack.push({ kind: "template" });
state = "template";
i += 1;
continue;
}
if (ch === "/") {
const isRegex =
lastSignificant === "" ||
REGEX_PREV_CHARS.has(lastSignificant) ||
REGEX_PREV_WORDS.has(lastWord);
if (isRegex) {
state = "regex";
i += 1;
continue;
}
// else: division — falls through to plain-code handling below.
}
mask[i] = 1;
const top = stack[stack.length - 1];
if (ch === "{" && top && top.kind === "exprCode") {
top.depth += 1;
} else if (ch === "}" && top && top.kind === "exprCode") {
top.depth -= 1;
if (top.depth === 0) {
stack.pop();
state = "template";
}
}
noteCodeChar(ch);
i += 1;
continue;
}
if (state === "squote" || state === "dquote") {
const quote = state === "squote" ? "'" : '"';
if (ch === "\\") {
i += 2;
continue;
}
if (ch === quote) {
state = "code";
noteCodeChar(quote);
i += 1;
continue;
}
if (ch === "\n") {
// Unterminated string — bail back to code rather than eat the file.
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "template") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "`") {
stack.pop();
state = "code";
i += 1;
continue;
}
if (ch === "$" && text[i + 1] === "{") {
stack.push({ kind: "exprCode", depth: 1 });
state = "code";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "lcomment") {
if (ch === "\n") {
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "bcomment") {
if (ch === "*" && text[i + 1] === "/") {
state = "code";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "regex") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "[") {
state = "regexclass";
i += 1;
continue;
}
if (ch === "/") {
i += 1;
while (i < n && /[a-z]/i.test(text[i])) i += 1;
state = "code";
continue;
}
if (ch === "\n") {
// Unterminated regex — bail back to code rather than eat the file.
state = "code";
i += 1;
continue;
}
i += 1;
continue;
}
if (state === "regexclass") {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "]") {
state = "regex";
i += 1;
continue;
}
i += 1;
continue;
}
// Unreachable, but never silently drop a character.
i += 1;
}
return mask;
}
/** Yield regex matches whose full span lies entirely in plain-code region. */
function* matchAllCode(text, mask, pattern) {
for (const m of text.matchAll(pattern)) {
const start = m.index;
const end = start + m[0].length;
let allCode = true;
for (let i = start; i < end; i++) {
if (!mask[i]) {
allCode = false;
break;
}
}
if (allCode) yield m;
}
}
/** Index of the `)` matching the `(` at `openIndex`, counting only plain-code parens. */
function matchParen(text, mask, openIndex, filePath) {
let depth = 0;
for (let i = openIndex; i < text.length; i++) {
if (!mask[i]) continue;
if (text[i] === "(") depth += 1;
else if (text[i] === ")") {
depth -= 1;
if (depth === 0) return i;
}
}
throw new Error(`${filePath}: פענוח נכשל — סוגר לא נסגר`);
}
function buildLineStarts(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n") starts.push(i + 1);
}
return starts;
}
function lineOfOffset(lineStarts, idx) {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= idx) lo = mid;
else hi = mid - 1;
}
return lo; // 0-based line number
}
const STATUS_WORD_RE = /\bstatus\b/;
const JOBS_REGISTER_RE = /jobs\.register\s*\(/g;
const ISSUES_UPDATE_RE = /issues\.update\s*\(/g;
const SYNC_CASE_STATUS_LITERAL_RE = /^\s*["']sync-case-status["']/;
/**
* Scan one file's source text and return a list of violation objects
* ({ file, line, message }). Throws if a `(` is never closed (see
* matchParen) — the caller must NOT treat that as "no violations".
*/
function scanFile(filePath, text) {
const mask = classify(text);
const lines = text.split("\n");
const lineStarts = buildLineStarts(text);
const rel = filePath;
const allowedRanges = [];
for (const m of matchAllCode(text, mask, JOBS_REGISTER_RE)) {
const openIdx = m.index + m[0].length - 1;
const after = text.slice(openIdx + 1, openIdx + 60);
if (SYNC_CASE_STATUS_LITERAL_RE.test(after)) {
const closeIdx = matchParen(text, mask, openIdx, rel);
allowedRanges.push([openIdx, closeIdx]);
}
}
const violations = [];
for (const m of matchAllCode(text, mask, ISSUES_UPDATE_RE)) {
const openIdx = m.index + m[0].length - 1;
const closeIdx = matchParen(text, mask, openIdx, rel);
const content = text.slice(openIdx + 1, closeIdx);
if (!STATUS_WORD_RE.test(content)) continue; // not a status-write call
const inAllowedRange = allowedRanges.some(
([s, e]) => openIdx >= s && openIdx <= e,
);
if (inAllowedRange) continue;
const callStartLine = lineOfOffset(lineStarts, m.index);
const callEndLine = lineOfOffset(lineStarts, closeIdx);
let noqaMatch;
for (let ln = callStartLine - 1; ln <= callEndLine && !noqaMatch; ln += 1) {
if (ln < 0 || ln >= lines.length) continue;
const m2 = NOQA_RE.exec(lines[ln]);
if (m2) noqaMatch = m2;
}
const reportLine = callStartLine + 1; // 1-based for humans
if (noqaMatch) {
const reason = (noqaMatch[1] ?? "").trim();
if (reason.length >= 3) continue; // justified exception — allowed
violations.push({
file: rel,
line: reportLine,
message: "noqa: INT9 ללא נימוק — חריגה חייבת להיות מנומקת בכתב (AC3)",
});
continue;
}
violations.push({
file: rel,
line: reportLine,
message:
`issues.update({status}) מחוץ למסלול sync-case-status (INV-INT9). ` +
`ראה ${SPEC_REF}. חריגה מוצדקת → // noqa: INT9 — <נימוק>.`,
});
}
return violations;
}
// ---------------------------------------------------------------------
// File collection
// ---------------------------------------------------------------------
function walk(dir, out) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, out);
} else if (SOURCE_EXT.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
out.push(full);
}
}
return out;
}
function collectDefault() {
const srcDir = join(REPO_ROOT, "src");
return walk(srcDir, []);
}
function collectFromArgs(args) {
const out = [];
for (const a of args) {
const p = resolve(a);
const st = statSync(p);
if (st.isDirectory()) {
walk(p, out);
} else {
out.push(p);
}
}
return out;
}
function runGate(files) {
const violations = [];
for (const abs of files) {
const rel = relative(REPO_ROOT, abs);
const text = readFileSync(abs, "utf-8");
violations.push(...scanFile(rel, text));
}
return violations;
}
function reportGate(violations, scannedCount) {
if (violations.length > 0) {
process.stderr.write(
`✗ INV-INT9 gate — issue.status write-ownership violated ` +
`(${violations.length} finding(s)):\n\n`,
);
for (const v of violations) {
process.stderr.write(`${v.file}:${v.line}: ${v.message}\n`);
}
process.stderr.write(`\nSee ${SPEC_REF}.\n`);
return 1;
}
process.stdout.write(
`✓ INV-INT9 gate: מסלול-כתיבה יחיד ל-issue.status ` +
`(${scannedCount} קבצים נסרקו).\n`,
);
return 0;
}
// ---------------------------------------------------------------------
// Self-test
// ---------------------------------------------------------------------
const SELF_TEST_EXPECTED = {
"violating.ts": 1,
"allowed-inside-job.ts": 0,
"noqa-justified.ts": 0,
"noqa-bare.ts": 1,
"non-status-update.ts": 0,
};
function runSelfTest() {
const fixturesDir = join(REPO_ROOT, "scripts", "fixtures", "int9");
let pass = 0;
let total = 0;
const failures = [];
for (const [name, expected] of Object.entries(SELF_TEST_EXPECTED)) {
total += 1;
const abs = join(fixturesDir, name);
let violations;
try {
const text = readFileSync(abs, "utf-8");
violations = scanFile(name, text);
} catch (err) {
failures.push(
`${name}: ציפינו ${expected} הפרות, אבל הסריקה זרקה שגיאה: ${String(err)}`,
);
continue;
}
if (violations.length === expected) {
pass += 1;
} else {
failures.push(
`${name}: ציפינו ${expected} הפרות, התקבלו ${violations.length}` +
(violations.length
? `:\n${violations.map((v) => ` - ${v.file}:${v.line}: ${v.message}`).join("\n")}`
: ""),
);
}
}
if (failures.length > 0) {
process.stderr.write("✗ INV-INT9 self-test נכשל:\n\n");
for (const f of failures) process.stderr.write(`${f}\n`);
return 1;
}
process.stdout.write(
`✓ INV-INT9 self-test: ${pass}/${total} פיקסצ'רים תואמים לציפייה.\n`,
);
return 0;
}
// ---------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------
function main(argv) {
if (argv.includes("--self-test")) {
return runSelfTest();
}
const pathArgs = argv.filter((a) => !a.startsWith("--"));
const files =
pathArgs.length > 0 ? collectFromArgs(pathArgs) : collectDefault();
let violations;
try {
violations = runGate(files);
} catch (err) {
process.stderr.write(`✗ INV-INT9 gate — ${String(err.message ?? err)}\n`);
return 1;
}
return reportGate(violations, files.length);
}
// `exitCode` ולא `process.exit()` — stdout/stderr אל pipe (וזה בדיוק המצב ב-CI)
// אסינכרוניים ב-POSIX, ו-`process.exit` היה עלול לקטוע את דוח-ההפרות עצמו.
process.exitCode = main(process.argv.slice(2));