From 24ce5dee9d14d878a74a4f34a1a7c8ae1875bef5 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Sun, 11 Oct 2026 20:56:59 +0200 Subject: [PATCH 1/3] fix(motion): awk field references in a multi-line program are not currency amounts Inside a closed single-quoted awk/gawk/mawk body in shell command position (not in a quote, # comment or heredoc) containing `{`, field references such as $3, $NF, ${2} in code position are no longer flagged as amounts. awk string literals, awk comments and bullet lines keep their amounts; sh -c / bash -c and double-quoted awk bodies are scanned as before. Adds a test pinning that the PreToolUse budget note counts completed renders. --- src/policy/motion/program-bodies.ts | 133 ++++++++++++++++++++++++++++ src/policy/motion/sensitive.ts | 10 ++- test/motion-budget-note.test.ts | 37 ++++++++ test/motion-sensitive-diff.test.ts | 122 +++++++++++++++++++++++++ test/motion-sensitive.test.ts | 113 +++++++++++++++++++++++ 5 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/policy/motion/program-bodies.ts create mode 100644 test/motion-budget-note.test.ts create mode 100644 test/motion-sensitive-diff.test.ts diff --git a/src/policy/motion/program-bodies.ts b/src/policy/motion/program-bodies.ts new file mode 100644 index 0000000..6a14702 --- /dev/null +++ b/src/policy/motion/program-bodies.ts @@ -0,0 +1,133 @@ +/** + * @module motion/program-bodies + * Locates single-quoted awk/gawk/mawk program bodies that a quote + * opens AND closes within the same source, so the sensitive-data scan can + * strip shell field references (`$3`, `${2}`) strictly inside them. Quoting + * follows POSIX shell rules: a single quote is never escaped; a double quote + * is escaped by an odd backslash run. An unclosed quote yields no span, so + * whatever follows stays fully scanned. + */ + +/** Shell command whose quoted operand is a program: awk/gawk/mawk (group 1), or sh/bash with `-c` (possibly clustered, e.g. `-ec`); the command word must be followed by whitespace (`awk-free` is prose). */ +const PROGRAM_LINE_RE = /(?:^|[;|&]\s*)\s*(g?awk|mawk)(?=\s)/g; + +/** Heredoc opener (`<= end) return true; + k = nl; + continue; + } + if (ch !== "<" || text[k + 1] !== "<") continue; + HEREDOC_RE.lastIndex = k; + const h = HEREDOC_RE.exec(text); + const bodyStart = text.indexOf("\n", k); + if (h === null || bodyStart === -1 || bodyStart >= end) continue; + const t = new RegExp(`^\\t*${h[1]}$`, "m").exec(text.slice(bodyStart + 1)); + const bodyEnd = t === null ? text.length : bodyStart + 1 + t.index + t[0].length; + if (bodyEnd >= end) return true; + k = bodyEnd - 1; + } + return quote !== null; +} + +/** Field/positional references (`$3`, `${12}`) — or an awk `"…"` string literal, matched first so a `$5` inside a string (`print "Only $5"`) stays text for the amount scan. A following digit/period/comma keeps `$40`, `$1,299.00` intact. */ +const FIELD_REF_RE = /"(?:[^"\\\n]|\\.)*"|#[^\n]*|(?<=^|\n)[ \t]*[-*•+][ \t][^\n]*|(?<=(?:[({[,=<>!+\-*/%&|;:?~]|\b(?:print|printf|return|in)\b)\s*)(?:\$\{\d+\}|\$\d(?![\d.,]))/g; + +/** Half-open `[start, end)` offsets of a quoted program body. */ +export type BodySpan = readonly [number, number]; + +/** Offset of the closing quote for the quote at `open`, or -1 when the quote never closes. The first matching quote ends the body (POSIX §2.2.2); if a letter or digit follows it, it is prose (`c'est`) or a concatenated word (`'{print}'file`), so the body is treated as unclosed and nothing is stripped — a body never extends past that quote. */ +function closeQuoteAt(text: string, open: number): number { + const quote = text[open]; + for (let p = open + 1; p < text.length; p++) { + if (text[p] !== quote) continue; + if (quote !== "'") { + let backslashes = 0; + for (let j = p - 1; j >= 0 && text[j] === "\\"; j--) backslashes++; + if (backslashes % 2 === 1) continue; + } + const next = text[p + 1]; + if (next !== undefined && /[A-Za-z0-9]/.test(next)) return -1; + return p; + } + return -1; +} + +/** + * Closed program-body spans of `text`, in order. Only a quote opened after a + * program command on the same line qualifies; a quote that never closes ends + * the scan without a span (nothing after it can be proven outside a string, + * so nothing there is stripped). Spans already closed stay valid. + */ +export function programBodySpans(text: string): BodySpan[] { + const spans: BodySpan[] = []; + let lineStart = 0; + let from = 0; + let scanFrom = 0; + while (lineStart < text.length) { + const nl = text.indexOf("\n", lineStart); + const lineEnd = nl === -1 ? text.length : nl; + const line = text.slice(lineStart, lineEnd); + PROGRAM_LINE_RE.lastIndex = from; + const m = PROGRAM_LINE_RE.exec(line); + if (m === null) { + lineStart = lineEnd + 1; + from = 0; + continue; + } + let openRel = -1; + for (let k = m.index + m[0].length; k < line.length; k++) { + const ch = line[k]; + // A pipe/list operator before the quote means the quote belongs to another command (`| awk | 'note`). + if (ch === "|" || ch === ";" || ch === "&") break; + // Only a single-quoted program qualifies: in `"…"` the shell expands `$5`, so it is never a plain awk field. + if (ch === "'") openRel = k; + if (ch === "'" || ch === '"') break; + } + if (openRel === -1 || notCommandPosition(text, scanFrom, lineStart + m.index)) { + lineStart = lineEnd + 1; + from = 0; + continue; + } + const open = lineStart + openRel; + const close = closeQuoteAt(text, open); + if (close === -1) return spans; + // An awk program has a `{ action }`; a body without one is prose (`; awk 'est … ' etc`), never stripped. + if (text.slice(open + 1, close).includes("{")) spans.push([open + 1, close]); + scanFrom = close + 1; + lineStart = text.lastIndexOf("\n", close - 1) + 1; + from = close - lineStart + 1; + } + return spans; +} + +/** `text` with field references replaced by `x` inside each closed program body — a letter, never the empty string, so stripped neighbors can never fuse into a currency match (`€${2}5` becomes `€x5`, not `€5`). */ +export function stripProgramBodies(text: string): string { + const spans = programBodySpans(text); + if (spans.length === 0) return text; + let out = ""; + let last = 0; + for (const [start, end] of spans) { + out += text.slice(last, start) + text.slice(start, end).replace(FIELD_REF_RE, (ref) => (ref.startsWith("$") ? "x" : ref)); + last = end; + } + return out + text.slice(last); +} diff --git a/src/policy/motion/sensitive.ts b/src/policy/motion/sensitive.ts index ef001df..0a11921 100644 --- a/src/policy/motion/sensitive.ts +++ b/src/policy/motion/sensitive.ts @@ -9,6 +9,7 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { stripProgramBodies } from "./program-bodies"; import { motionHome } from "./store"; /** Currency amount: `12 €`, `1 299,50 EUR`, `$1,299.00`, `£ 40`. Horizontal spaces only (incl. NBSP / narrow NBSP). */ @@ -17,8 +18,15 @@ const CURRENCY_RE = /\b\d[\d \t  .,]{0,24}[ \t  ]?(?:[$€£]|(?:USD|EUR|G /** Replacement references and positional parameters on recognizable shell-command lines are not prices. */ const REPLACE_LINE_RE = /(?:\breplace(?:All)?\s*\(|\.sub\()/; const SHELL_LINE_RE = /(?:^|[;|&]\s*)\s*(?:echo|printf|awk|gawk|mawk|sh|bash|zsh|exec|export)\b/; + +/** + * Line rules unchanged since dd8a2e1; the only extension is {@link stripProgramBodies}, + * which strips field references in code position inside single-quoted awk/gawk/mawk bodies + * opened AND closed by a quote in the same source. Template literals never qualify: + * `` `${a}$1` `` has the same shape as the real price `` `${cur}$5` ``, so both stay blocked. + */ const stripBackrefs = (text: string): string => - text.split("\n").map((line) => { + stripProgramBodies(text).split("\n").map((line) => { // A standalone positional token is code, not a price declaration; a literal integer dollar price is ambiguous. if (/^\s*\$(?:\d|\{\d+\})\s*$/.test(line)) return ""; if (REPLACE_LINE_RE.test(line)) return line.replace(/\$\d(?![\d.,])/g, ""); diff --git a/test/motion-budget-note.test.ts b/test/motion-budget-note.test.ts new file mode 100644 index 0000000..9566737 --- /dev/null +++ b/test/motion-budget-note.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import { handleHook } from "../src/runtime/handle"; +import { loadBudget } from "../src/policy/motion/budget"; +import { budgetPath } from "../src/policy/motion/budget-store"; +import { canonicalRoot } from "../src/runtime/prd/prd-canon"; +import { bash, isDeny, makeFixture } from "./motion-handle-fixture"; +import type { MotionFixture } from "./motion-handle-fixture"; + +const STILLS = "bash render.sh --stage stills"; +const T0 = 1_000_000; + +/** Run one claude-code hook payload with an injected clock and home. */ +async function runAt(fx: MotionFixture, payload: Record, now: number = T0): Promise { + return (await handleHook("claude-code", payload, { now, cwd: fx.proj, home: fx.home, scope: "motion" })).stdout; +} + +test("the pre note counts COMPLETED renders and stays in sync with budget.json", async () => { + const fx = makeFixture(); + rmSync(fx.draft); // no cold artifact: the one-time first-observation notice is not emitted + const pre1 = bash("s1", STILLS, { tool_use_id: "tu-a" }); + const note1 = await runAt(fx, pre1); + expect(isDeny(note1)).toBe(false); + // The pre note is emitted BEFORE the PostToolUse that increments the counter: + // 0 completed renders, 1 reservation in flight — "stills 0" is correct here. + expect(note1).toContain("Motion budget: renders stills 0 · draft 0 · master 0"); + const post = await runAt(fx, { ...pre1, hook_event_name: "PostToolUse" }); + expect(post).toContain("renders stills 1"); + const root = canonicalRoot(fx.proj); + const onDisk = JSON.parse(readFileSync(budgetPath(root, fx.home), "utf8")) as { renders: Record }; + expect(onDisk.renders.stills).toBe(1); + expect(loadBudget(root, fx.home).renders.stills).toBe(1); + // A NEW pre after the settlement reads a fresh budget: the note matches budget.json. + const note2 = await runAt(fx, bash("s1", STILLS, { tool_use_id: "tu-b" })); + expect(isDeny(note2)).toBe(false); + expect(note2).toContain("Motion budget: renders stills 1 · draft 0 · master 0"); +}); diff --git a/test/motion-sensitive-diff.test.ts b/test/motion-sensitive-diff.test.ts new file mode 100644 index 0000000..47a9263 --- /dev/null +++ b/test/motion-sensitive-diff.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test"; +import { findSensitive } from "../src/policy/motion/sensitive"; + +/** + * Differential vs dd8a2e1 (pass 2): each entry carries the verdict of the + * pre-fix implementation, recomputed on a base verified byte-for-byte against + * `git show dd8a2e1:src/policy/motion/sensitive.ts` (sha256 + * 04f994a23d58061400733861f0e8081c225fafa6cec2bf7a498b615e6277dcd9). + * Invariant: zero allow -> blocked anywhere; blocked -> allowed only in the + * "field-ref" class (references inside an awk/gawk/mawk or `sh -c`/`bash -c` + * program body opened AND closed by a quote in the same source). + */ +type Cls = "field-ref" | "amount" | "adverse" | "clean" | "banned"; +interface Entry { name: string; cls: Cls; content: string; oldBlocked: boolean; terms?: string[] } + +const E = (name: string, cls: Cls, content: string, oldBlocked: boolean, terms?: string[]): Entry => + ({ name, cls, content, oldBlocked, terms }); + +const SQ = "'"; +const AWK = `awk ${SQ}\n { if ($3 > 0.5) n++ }\n END { print n, $NF }\n${SQ} file.txt\nx=\${2}`; + +const CORPUS: Entry[] = [ + // --- field-reference class: the only allowed flips blocked -> allowed --- + E("awk-multiline-$3/$NF/${2}", "field-ref", AWK, true), + E("sh-c-multiline-$2", "adverse", `sh -c ${SQ}\n n=$2\n${SQ}`, true), + E("mawk-multiline-$7", "field-ref", `mawk ${SQ}\n { print $7 }\n${SQ} f`, true), + E("gawk-multiline-$8", "field-ref", `gawk ${SQ}\n { print $8 }\n${SQ} f`, true), + E("awk-multiline-double-quote", "adverse", 'awk "\n { print $3 }\n" f', true), + E("bash-ec-multiline-$4", "field-ref", `bash -ec ${SQ}\n echo $4\n${SQ}`, false), + E("awk-single-line", "field-ref", `awk ${SQ}{ print $3 }${SQ} f`, false), + E("echo-positional", "field-ref", 'echo "$1"', false), + E("standalone-$3", "field-ref", "$3", false), + E("standalone-${12}", "field-ref", "${12}", false), + E("replace-call", "field-ref", 's.replace(/(x)/, "$1")', false), + E("replaceAll-call", "field-ref", 's.replaceAll(/(x)/g, "<$1>$2")', false), + E("python-sub", "field-ref", "re.sub(r'(x)', r'\\1$1', s)", false), + // --- challenger counter-examples (pass-1 regression: must be BLOCK again) --- + E("ce1-export-caption", "adverse", "export const caption = `\n Abonnement $9 par mois\n`;", true), + E("ce2-export-default", "adverse", "export default `\nOnly $5 today\n`;", true), + E("ce3-export-fn", "adverse", "export function f() { return `\nPro: $9\n`; }", true), + E("ce4-exec", "adverse", "exec(`\nprice $5\n`)", true), + E("ce5-echo-fr", "adverse", `echo l${SQ}animation démarre\n\nLe plan coûte $9 par mois.`, true), + E("ce6-echo-comment", "adverse", `echo ok # it${SQ}s fine\nPrice: $5`, true), + E("ce7-bash-fr", "adverse", `bash l${SQ}outil\nCoût $3`, true), + E("ce8-awk-unclosed", "adverse", `awk ${SQ}{print}\nPrix $5`, true), + E("ce9-echo-dq-unclosed", "adverse", 'echo "hello\nTotal $7', true), + E("ce10-crlf", "adverse", `echo ${SQ}x\r\nPrix $5\r\n`, true), + E("ce11-sq-backslash", "adverse", `echo ${SQ}it\\${SQ}s\nPrix $5`, true), + E("ce12-heredoc", "adverse", "cat <" + ` 0) n++ }\n${SQ} f`, true), + E("awk-body-$1,299.00", "amount", `awk ${SQ}\n { t = "$1,299.00" }\n${SQ} f`, true), + E("awk-body-$12.50", "amount", `awk ${SQ}\n { print $12.50 }\n${SQ} f`, true), + E("awk-body-40€", "amount", `awk ${SQ}\n { if ($3 > 40 €) n++ }\n${SQ} f`, true), + E("awk-body-$-40", "amount", `awk ${SQ}\n { print $ 40 }\n${SQ} f`, true), + E("sh-c-body-$1,299.00", "amount", `sh -c ${SQ}\n prix=$1,299.00\n${SQ}`, true), + // --- adverse single-digit vectors --- + E("comment-$5", "adverse", "# coût $5", true), + E("paren-($5)", "adverse", "pay ($5)", true), + E("single-quoted-'$5'", "adverse", "ticket '$5'", true), + E("md-code-`$5`", "adverse", "prix `$5`", true), + E("template-price-$5", "adverse", "const s = `coût $5 pour ${n}`;", true), + E("template-price-49.99", "adverse", "const s = `Prix : $49.99 ${n}`", true), + E("template-free-$1", "adverse", "const s = `${a}\n unit $1\n`;", true), + E("template-glue-left", "adverse", "const s = `${a}$1`;", true), + E("template-glue-right", "adverse", "const s = `$1${a}`;", true), + E("replace-line-quote-leak", "adverse", `awk ${SQ}\n{ print $3 }${SQ} ; s.replace(/x/, "$1\n# coût $5`, true), + E("printf-unclosed-$2", "adverse", 'printf "a\\" \n$2 due', true), + // --- clean non-amounts --- + E("template-glue-multiline", "clean", "const s = `${a}\n$1\n`;", false), + E("version", "clean", "version 1.2.3", false), + E("echo-$HOME", "clean", "echo $HOME", false), + E("plain-number", "clean", "const a = 1299", false), + E("fps", "clean", "fps 30, 1080 x 1920", false), + E("issue-ref", "clean", "#3 of 12", false), + E("date", "clean", "2026-10-08", false), + E("shell-vars", "clean", "$USER $PATH", false), + E("awk-$NF-only", "clean", "awk '{ print $NF }' f", false), + E("prose", "clean", "nothing sensitive here", false), + // --- banned terms (byte-identical path) --- + E("banned-hit", "banned", "we sign with ACME CORP", true, ["Acme Corp"]), + E("banned-clean", "banned", "no match here", false, ["Acme Corp"]), +]; + +test("differential vs dd8a2e1: flips only in the field-ref class, never allow -> blocked", () => { + for (const e of CORPUS) { + const blocked = findSensitive(e.content, e.terms ?? []) !== null; + const expected = e.cls === "field-ref" && e.oldBlocked ? false : e.oldBlocked; + expect([e.name, blocked]).toEqual([e.name, expected]); + if (!e.oldBlocked) expect([e.name, blocked]).toEqual([e.name, false]); // zero allow -> blocked + } +}); diff --git a/test/motion-sensitive.test.ts b/test/motion-sensitive.test.ts index 14daada..086bf3e 100644 --- a/test/motion-sensitive.test.ts +++ b/test/motion-sensitive.test.ts @@ -59,6 +59,119 @@ test("real amounts survive on lines that also hold a replace call", () => { expect(findSensitive('s.replace(/a/, "$1 and 40 €")', [])).not.toBeNull(); }); +test("field references in a multi-line awk program are not amounts", () => { + const script = ["#!/bin/sh", "awk '", " { if ($3 > 0.5) n++ }", " END { print n, $NF }", "' file.txt", "x=${2}"].join("\n"); + expect(findSensitive(script, [])).toBeNull(); + // `sh -c` bodies are out of scope (a positional `$2` and a price `$2` look alike): scanned as in dd8a2e1. + expect(findSensitive("sh -c '\n n=$2\n' ", [])).not.toBeNull(); +}); + +test("real amounts inside an awk program body stay blocked", () => { + expect(findSensitive("awk '\n { if ($40 > 0) n++ }\n' f", [])).not.toBeNull(); + expect(findSensitive("awk '\n { print $12.50 }\n' f", [])).not.toBeNull(); + expect(findSensitive("awk '\n { if ($3 > 40 €) n++ }\n' f", [])).not.toBeNull(); + expect(findSensitive("awk '\n { t = \"$1,299.00\" }\n' f", [])).not.toBeNull(); + expect(findSensitive("sh -c '\n prix=$1,299.00\n'", [])).not.toBeNull(); +}); + +test("stripping a reference never fuses neighbors into an amount (challenger round 2)", () => { + // All were already allowed by dd8a2e1: the strip must not create a currency match. + expect(findSensitive("awk '\n{ print €${2}5 }\n' f", [])).toBeNull(); + expect(findSensitive("awk '\n{ print £${10}40 }\n' f", [])).toBeNull(); + expect(findSensitive("gawk '\n{ print €${2}5 }\n' f", [])).toBeNull(); +}); + +test("a prose apostrophe is not a program-body close (challenger round 2)", () => { + // `c'est`: the apostrophe is followed by a letter, so the awk quote stays unclosed and nothing is stripped. + expect(findSensitive("awk '{print}\nPrix $5 par mois\nc'est prêt", [])).not.toBeNull(); + expect(findSensitive("sh -c '{print}\nPrix $5\nn'est pas", [])).not.toBeNull(); + // A word glued to the closing quote (`'{print}'file`) must not extend the body to a later quote (challenger round 3). + expect(findSensitive("awk '{print}'file\nAbonnement $9 par mois\nfin ' x", [])).not.toBeNull(); + expect(findSensitive("awk '{print}'1\nOnly $5 today\n'", [])).not.toBeNull(); +}); + +test("awk only counts in shell command position, and an awk string keeps its amount (challenger round 4)", () => { + const H = "<" + " 1) n++ }\n' f`, [])).toBeNull(); + expect(findSensitive("# compte\nawk '\n{ if ($3 > 1) n++ }\n' f", [])).toBeNull(); +}); + +test("`$1` glued to a template-literal interpolation stays blocked (same shape as a real price)", () => { + // `${a}$1` is indistinguishable from the real price `${cur}$5` without letting amounts through: + // dd8a2e1 blocked both, and security wins (owner decision, pass 2). + expect(findSensitive("const s = `${a}$1`;", [])).not.toBeNull(); + expect(findSensitive("const s = `$1${a}`;", [])).not.toBeNull(); + expect(findSensitive("const s = `Only $5${per}`;", [])).not.toBeNull(); +}); + +test("single-digit dollar amounts outside field-reference contexts stay blocked", () => { + for (const s of ["# coût $5", "pay ($5)", "ticket '$5'", "prix `$5`", "const s = `coût $5 pour ${n}`;"]) { + expect(findSensitive(s, [])).not.toBeNull(); + } +}); + +test("challenger counter-examples: amounts behind any non-awk quote context stay blocked", () => { + const SQ = "'"; + const cases = [ + "export const caption = `\n Abonnement $9 par mois\n`;", + "export default `\nOnly $5 today\n`;", + "export function f() { return `\nPro: $9\n`; }", + "exec(`\nprice $5\n`)", + `echo l${SQ}animation démarre\n\nLe plan coûte $9 par mois.`, + `echo ok # it${SQ}s fine\nPrice: $5`, + `bash l${SQ}outil\nCoût $3`, + `awk ${SQ}{print}\nPrix $5`, + 'echo "hello\nTotal $7', + `echo ${SQ}x\r\nPrix $5\r\n`, + `echo ${SQ}it\\${SQ}s\nPrix $5`, + "cat <" + ` { + const SQ = "'"; + expect(findSensitive(`mawk ${SQ}\n { print $7 }\n${SQ} f`, [])).toBeNull(); + expect(findSensitive(`gawk ${SQ}\n { print $8 }\n${SQ} f`, [])).toBeNull(); + // `bash -c` and double-quoted awk programs (the shell expands `$3` there) stay scanned as in dd8a2e1. + expect(findSensitive(`bash -c ${SQ}\n n=$2\n${SQ}`, [])).not.toBeNull(); + expect(findSensitive(`awk "\n { print $3 }\n" f`, [])).not.toBeNull(); +}); + +test("an unclosed quote never weakens the scan (printf/export are not program bodies)", () => { + // `\\` is an escaped backslash: the `"` after it genuinely closes, so line 2's + // `$5` is a plain price line and must stay blocked (regression: odd/even parity). + expect(findSensitive('export DIR="C:\\\\"\n$5 due', [])).not.toBeNull(); + // Odd run: the quote really is escaped and stays open, but printf is not an + // awk/`sh -c` program body, so an unclosed quote changes nothing (dd8a2e1 blocked). + expect(findSensitive('printf "a\\\" \n$2 due', [])).not.toBeNull(); +}); + +test("a quote closed on a replace line never leaks program-body state", () => { + expect(findSensitive("awk '\n{ print $3 }' ; s.replace(/x/, \"$1\n# coût $5", [])).not.toBeNull(); + expect(findSensitive("awk '\n{ n=$3\n r = s.replace(/a/, \"$1\") }\n' f", [])).toBeNull(); + // An awk body with no `{ action }` is not provably a program (prose like `; awk 'est … '`): left as dd8a2e1 scanned it. + expect(findSensitive("awk '\n n=$3\n' f", [])).not.toBeNull(); +}); + test("long digit/space runs do not blow up (ReDoS guard)", () => { const t = Date.now(); findSensitive("1 ".repeat(250_000), []); From af3d490922c22f54730c3aabbf64814a2526a229 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Sun, 11 Oct 2026 20:57:00 +0200 Subject: [PATCH 2/3] fix(design): resolve design-system.md from the written file and list what is missing design-system.md is resolved from the written file's directory upward (real paths, confined to the real cwd, empty/unreadable candidates skipped), then cwd, giving a per-file verdict for Write/Edit/apply_patch; session mode and quota still come from cwd. Deny texts list corpus reads at the credited root and screenshots taken/required (N/M); thresholds unchanged. --- src/policy/design/gates-pipeline.ts | 48 ++++-- src/runtime/design-files-gate.ts | 13 +- src/runtime/design-helpers.ts | 72 ++++++++- src/runtime/design.ts | 19 ++- test/design-html-css-pipeline-gate.test.ts | 56 ++++++- test/design-init-parity.test.ts | 84 ++++++++++ test/design-refusal-text.test.ts | 49 ++++++ test/design-system-locate.test.ts | 177 +++++++++++++++++++++ 8 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 test/design-init-parity.test.ts create mode 100644 test/design-refusal-text.test.ts create mode 100644 test/design-system-locate.test.ts diff --git a/src/policy/design/gates-pipeline.ts b/src/policy/design/gates-pipeline.ts index c499fc0..b752385 100644 --- a/src/policy/design/gates-pipeline.ts +++ b/src/policy/design/gates-pipeline.ts @@ -1,21 +1,47 @@ import type { Prompt } from "../../prompt/types"; -import { type DesignState, quotaFor } from "./state"; +import { type DesignMode, type DesignState, quotaFor } from "./state"; +import { corpusReady } from "./corpus"; import { PLUGINS_DIR } from "../file-size"; import { deny } from "./gates"; import { isTemplateUrl } from "./template-urls"; const SKILLS = `${PLUGINS_DIR}/design-expert/skills`; -/** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. */ -export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false): Prompt | null { +/** Per-mode corpus-read requirement spelled out for refusal texts (MIRRORS `corpusReady` — display only, thresholds unchanged). */ +const CORPUS_NEED: Readonly> = { + component: "1 corpus file", + page: "2 corpus files", + full: "README.md + 3 tokens-*.md files", +}; + +/** + * The unmet phase-2 requirements, one entry per gap: corpus reads credited at + * the CREDITED root (with its path) when the corpus is delivered, and the + * screenshot count as taken/required (N/M). A satisfied requirement is simply + * absent from the list, so the refusal names exactly what is missing. + */ +function missingRequirements(state: DesignState, corpusRequired: boolean, corpusRoot: string): string[] { + const missing: string[] = []; + if (corpusRequired && !corpusReady(state.corpusReads, state.mode)) { + missing.push(`corpus reads: ${state.corpusReads.length} credited under '${corpusRoot}' (required: ${CORPUS_NEED[state.mode]})`); + } + const needed = quotaFor(state.mode, corpusRequired); + if (state.screenshotsCount < needed) { + missing.push(`screenshots: ${state.screenshotsCount}/${needed} (any fuse-browser capture counts — browser_screenshot, browser_shots_batch, browser_site_shots)`); + } + return missing; +} + +/** Gate writing design-system.md: requires phase >= 2 and the per-mode screenshot quota. The refusal lists exactly what is missing. */ +export function designSystemWriteGate(filePath: string, state: DesignState, corpusRequired = false, corpusRoot = ""): Prompt | null { if (!filePath.endsWith("design-system.md")) return null; if (state.currentPhase < 2) { + const missing = missingRequirements(state, corpusRequired, corpusRoot); return deny( - `BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. ` + + `BLOCKED: Cannot write design-system.md at phase ${state.currentPhase}. Missing: ${missing.join("; ") || "phase-2 requirements"}. ` + "RECOVERY: 1) Read identity templates from skills/design-system/ " + "2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) with the Read tool " + - "4) Screenshot real sector sites with mcp__fuse-browser__browser_screenshot on a LIVE session " + - "(browser_shots_batch/browser_site_shots also count, 1 credit per call) " + + "4) Capture real sector sites with any fuse-browser screenshot tool (browser_screenshot, browser_shots_batch, browser_site_shots — 1 credit per call) " + "5) Then write design-system.md", ); } @@ -23,10 +49,8 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp if (state.screenshotsCount < needed) { return deny( `BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. ` + - `RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done ` + - `2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with ` + - "mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots also count, 1 credit per call) " + - "3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md", + `RECOVERY: take ${needed - state.screenshotsCount} more capture(s) with any fuse-browser screenshot tool ` + + "(browser_screenshot, browser_shots_batch, browser_site_shots — 1 credit per call), then write design-system.md", ); } return null; @@ -50,8 +74,8 @@ export function htmlCssPipelineGate(filePath: string, state: DesignState, design return deny( `BLOCKED: cannot write '${filePath}' before the design-system pipeline is complete (${gaps.join(", ")}). ` + "RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md " + - "3) Read the refs-design corpus with the Read tool 4) Screenshot real sector sites with " + - "mcp__fuse-browser__browser_screenshot on a LIVE session 5) Write a valid design-system.md " + + "3) Read the refs-design corpus with the Read tool 4) Capture real sector sites with any fuse-browser " + + "screenshot tool (browser_screenshot, browser_shots_batch, browser_site_shots) 5) Write a valid design-system.md " + "6) Then write .html/.css", ); } diff --git a/src/runtime/design-files-gate.ts b/src/runtime/design-files-gate.ts index b467c77..f0613bc 100644 --- a/src/runtime/design-files-gate.ts +++ b/src/runtime/design-files-gate.ts @@ -73,10 +73,11 @@ export function substituteLiteral(s: string, from: string, to: string, all: bool /** * Gate every file of a multi-file write primitive; the first violation blocks - * the envelope. `designSystemFileExists` is caller-computed (`findDesignSystem` + * the envelope. `designSystemExistsFor` is caller-injected (`findDesignSystem*` * lives in `design-helpers.ts`, which itself imports `substituteLiteral` from - * THIS module — importing it back here would cycle) so both write paths - * (Write/Edit in `design.ts`, apply_patch here) consume the SAME disk read. + * THIS module — importing it back here would cycle) and is evaluated PER FILE: + * each patched file gets the same design-system.md verdict it would get + * written alone by Write/Edit — a hit for one file never leaks to the others. */ export function designFilesGate( files: readonly NormalizedFile[], @@ -85,7 +86,7 @@ export function designFilesGate( corpusRoot: string, corpusRequired: boolean, cwd: string, - designSystemFileExists: boolean, + designSystemExistsFor: (filePath: string) => boolean, ): Prompt | null { for (const f of files) { const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath); @@ -95,7 +96,7 @@ export function designFilesGate( if (htmlCssHit) return htmlCssHit; // Parity with design.ts's htmlCssPipelineGate call: SAME verdict for the // SAME file+state on both write paths (Write/Edit vs apply_patch). - const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemFileExists); + const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemExistsFor(f.filePath)); if (pipelineHit) return pipelineHit; // apply_patch parity for the Gemini precondition (design.ts:72-74, D2 gap): // same condition, same Prompt shape as the Write/Edit branch — opt-in via @@ -104,7 +105,7 @@ export function designFilesGate( return { kind: "block", title: "Design pipeline", reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.", actions: ["Call mcp__gemini-design__create_frontend first"] }; } if (!f.filePath.endsWith("design-system.md")) continue; - const gate = designSystemWriteGate(f.filePath, state, corpusRequired) + const gate = designSystemWriteGate(f.filePath, state, corpusRequired, corpusRoot) ?? (f.op === "add" ? designSystemContentGate({ filePath: f.filePath, tool: "Write", content: f.content, oldString: undefined, replaceAll: false, state, corpusRoot, corpusRequired }) : null); diff --git a/src/runtime/design-helpers.ts b/src/runtime/design-helpers.ts index 9b0dd91..a5fe648 100644 --- a/src/runtime/design-helpers.ts +++ b/src/runtime/design-helpers.ts @@ -5,8 +5,8 @@ * `design.ts` to keep that file within the SOLID size budget (SRP). * @packageDocumentation */ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { NormalizedEvent } from "./normalize"; import { type DesignState, saveDesignState } from "../policy/design/state"; import { recordNavigate, recordScroll, recordScreenshot, recordValidDesignSystem } from "../policy/design/transitions"; @@ -40,6 +40,74 @@ export function findDesignSystem(cwd: string): string { return ""; } +/** + * Resolve design-system.md for a FILE WRITE: walk up from dirname(`filePath`) + * toward `cwd` (inclusive) — a design-system.md sitting next to the written + * file, or anywhere between it and the project root, now counts — then fall + * back to the historical cwd walk ({@link findDesignSystem}). The upward walk + * NEVER climbs above cwd (containment via `path.relative`, so `..` escapes + * after join-normalization are refused), hence nothing is picked up from + * $HOME or / that the old code would not already have found. With no written + * file (empty `filePath`) the behavior is byte-identical to before. + * + * Two passe-2 hardenings, both dd8a2e1-parity restorations: + * - REAL-PATH containment: a candidate only counts when `realpathSync` + * places it inside `realpathSync(cwd)` — a symlinked directory pointing + * outside cwd, or a design-system.md that is itself a symlink to an + * outside file, is SKIPPED (the old code never saw it either). Both + * sides are realpath'd: macOS resolves /var → /private/var, so comparing + * a real path against a lexical one false-negatives. + * - NO SHADOWING: an empty, unreadable, or not-a-file candidate + * (directory) does not win — the walk continues upward, then the cwd + * fallback applies. The fallback itself keeps the historical + * first-found-wins semantics, so a present-but-empty cwd file still + * reads as absent exactly like dd8a2e1. + */ +export function findDesignSystemForFile(cwd: string, filePath: string): string { + const root = resolve(cwd); + if (filePath) { + const abs = resolve(root, filePath); // resolve passes absolute filePath through, normalized + const rel = relative(root, abs); + const inside = rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); + if (inside) { + let realRoot: string; + try { + realRoot = realpathSync(root); + } catch { + realRoot = root; + } + let dir = dirname(abs); + for (;;) { + const p = join(dir, "design-system.md"); + if (existsSync(p)) { + let real: string; + try { + real = realpathSync(p); + } catch { + real = ""; + } + const relReal = real === "" ? ".." : relative(realRoot, real); + const realInside = real !== "" && relReal !== ".." && !relReal.startsWith(`..${sep}`) && !isAbsolute(relReal); + if (realInside) { + try { + const content = readFileSync(p, "utf8"); + if (content !== "") return content; + } catch { + // unreadable or a directory (EISDIR): not a usable design system + } + } + // outside the real cwd, empty, or unreadable: keep walking up. + } + if (dir === root) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + } + return findDesignSystem(cwd); +} + /** Apply a PostToolUse fuse-browser transition to the design state. */ export function recordPost(event: NormalizedEvent, cacheDir: string, state: DesignState, corpusRoot = "", corpusRequired = false, cwd = ""): void { if (SHOT_TOOLS.has(event.tool)) saveDesignState(cacheDir, recordScreenshot(state, corpusRequired)); diff --git a/src/runtime/design.ts b/src/runtime/design.ts index 8e27da1..918490b 100644 --- a/src/runtime/design.ts +++ b/src/runtime/design.ts @@ -6,7 +6,7 @@ import { activeDesignAgent } from "../policy/design/flag"; import { runDesignChecks } from "../policy/design/content-checks"; import { uiDesignSkillGate } from "../policy/design/skill-gate"; import { collectDesignEvidence } from "../policy/design/skill-evidence"; -import { findDesignSystem, recordPost, designSystemContentGate } from "./design-helpers"; +import { findDesignSystem, findDesignSystemForFile, recordPost, designSystemContentGate } from "./design-helpers"; import { checkDesignSystemContent } from "./design-content-gate"; import { designFilesGate } from "./design-files-gate"; import { resolveCorpusRoot, resolvePluginsRoot, pluginsWriteGuard } from "../policy/design/corpus"; @@ -59,6 +59,10 @@ export function designGate(payload: Record, event: NormalizedEv let state = loadDesignState(cacheDir, id); if (!state) { if (!active) return null; + // dd8a2e1 parity (passe 3): the SESSION mode/quota is decided by the cwd + // walk ONLY — never by the first event's filePath (a Read/Grep/Write under + // a sub/ holding a design-system.md must not flip the session to "page"). + // The per-file resolver (findDesignSystemForFile) stays at the write gates. const dsExists = findDesignSystem(cwd) !== ""; state = initDesignState(id, dsExists ? "page" : "full", dsExists); saveDesignState(cacheDir, state); @@ -82,7 +86,7 @@ export function designGate(payload: Record, event: NormalizedEv const fp = event.filePath ?? ""; // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate). const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) - ?? htmlCssPipelineGate(fp, state, findDesignSystem(cwd) !== "") ?? designSystemWriteGate(fp, state, corpusRequired) + ?? htmlCssPipelineGate(fp, state, findDesignSystemForFile(cwd, fp) !== "") ?? designSystemWriteGate(fp, state, corpusRequired, corpusRoot) ?? designSystemContentGate({ filePath: fp, tool: event.tool, content: event.content ?? "", oldString: event.oldString, replaceAll: event.input.replace_all === true, state, corpusRoot, corpusRequired }); if (base) return base; if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) { @@ -90,8 +94,13 @@ export function designGate(payload: Record, event: NormalizedEv } return null; } - // Codex apply_patch (D2): gate each fanned-out file like a Write. - if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd, findDesignSystem(cwd) !== ""); + // Codex apply_patch (D2): gate each fanned-out file like a Write. The + // design-system.md lookup is PER FILE (passe 2): each patched file gets the + // verdict it would get written alone — resolving once from the first file + // let a found (or missing) design-system.md leak onto the whole envelope. + if (event.files && event.files.length > 0) { + return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd, (fp) => findDesignSystemForFile(cwd, fp) !== ""); + } if (event.tool === NAV) { return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : ""); } @@ -104,7 +113,7 @@ export function designGate(payload: Record, event: NormalizedEv // Parity validate-design-system.py DENY_NOT_FOUND: a MISSING file gets its own // recovery message, distinct from the "too generic" message for a present-but-thin one. if (ds === "") { - return { kind: "block", title: "Design pipeline", reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) 4) Screenshot 1-2 real sector sites 5) Write design-system.md, then retry create_frontend.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; + return { kind: "block", title: "Design pipeline", reason: "BLOCKED: design-system.md not found. RECOVERY: 1) Read the identity templates 2) Read design-inspiration.md 3) Read the refs-design corpus (README.md + relevant tokens-*.md) 4) Capture 1-2 real sector sites with any fuse-browser screenshot tool (browser_screenshot, browser_shots_batch, browser_site_shots) 5) Write design-system.md, then retry create_frontend.", actions: ["Create design-system.md via the pipeline, then retry create_frontend"] }; } // Same content rules as the nominal write path (jointure included): the // Gemini branch must not be weaker than the gate the Write just went through. diff --git a/test/design-html-css-pipeline-gate.test.ts b/test/design-html-css-pipeline-gate.test.ts index c8445c9..e73a284 100644 --- a/test/design-html-css-pipeline-gate.test.ts +++ b/test/design-html-css-pipeline-gate.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { normalizeEvent } from "../src/runtime/normalize"; @@ -32,12 +32,12 @@ const activatePhase0 = (cache: string): void => { setActiveDesignAgent(cache, "ag"); saveDesignState(cache, initDesignState("ag", "full", false)); }; -/** Design agent with the design-system validated BOTH in state AND on disk — the only ALLOW combination. */ -const activateReady = (cache: string, proj: string): void => { +/** Design agent with the design-system validated BOTH in state AND on disk — the only ALLOW combination. `writeDs=false` keeps the state ready but leaves the file off disk. */ +const activateReady = (cache: string, proj: string, writeDs = true): void => { setActiveDesignAgent(cache, "ag"); const state: DesignState = { ...initDesignState("ag", "page", true), currentPhase: 3, designSystemValid: true, designSystemExists: true }; saveDesignState(cache, state); - writeFileSync(join(proj, "design-system.md"), URL_DS); + if (writeDs) writeFileSync(join(proj, "design-system.md"), URL_DS); }; test("1) design-agent, .html, phase 0 -> DENY (the fix)", () => { @@ -103,3 +103,51 @@ test("scope proof: htmlCssPipelineGate is a pure predicate over (filePath, state expect(htmlCssPipelineGate("Foo.tsx", phase0, false)).toBeNull(); expect(htmlCssPipelineGate("index.html", phase0, false)?.kind).toBe("block"); }); + +// --- Defect 2: design-system.md is resolved from dirname() up toward cwd, +// then falls back to the historical cwd walk. Never climbs above cwd; a +// design-system.md in cwd yields the exact same verdict as before. + +test("8) design-system.md NEXT TO the written file (subdir of cwd) counts — the write passes", () => { + const cache = tmp(); + const proj = tmp(); + const sub = join(proj, "landing"); + mkdirSync(sub); + activateReady(cache, sub); // ready state + design-system.md in the SUBDIR, not in cwd + expect(designGate({ agent_id: "ag" }, writeEv(sub, "index.html", ""), cache, proj, "")).toBeNull(); +}); + +test("9) no design-system.md anywhere -> refusal unchanged", () => { + const cache = tmp(); + const proj = tmp(); + activateReady(cache, proj, false); // ready state, but no design-system.md on disk at all + const v = designGate({ agent_id: "ag" }, writeEv(proj, "index.html", ""), cache, proj, ""); + expect(v?.kind).toBe("block"); +}); + +test("10) design-system.md only in cwd -> verdict identical to before (write from a subdir allowed)", () => { + const cache = tmp(); + const proj = tmp(); + activateReady(cache, proj); // design-system.md at the cwd root, as today + expect(designGate({ agent_id: "ag" }, writeEv(join(proj, "landing"), "index.html", ""), cache, proj, "")).toBeNull(); +}); + +test("11) a design-system.md OUTSIDE the cwd subtree is never picked up", () => { + const cache = tmp(); + const proj = tmp(); + const outside = tmp(); + writeFileSync(join(outside, "design-system.md"), URL_DS); + activateReady(cache, proj, false); + const v = designGate({ agent_id: "ag" }, writeEv(outside, "index.html", ""), cache, proj, ""); + expect(v?.kind).toBe("block"); +}); + +test("12) apply_patch parity: design-system.md next to the patched file counts too", () => { + const cache = tmp(); + const proj = tmp(); + const sub = join(proj, "landing"); + mkdirSync(sub); + activateReady(cache, sub); + const ev = apEv(patchAdd(join(sub, "index.html"), ""), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")).toBeNull(); +}); diff --git a/test/design-init-parity.test.ts b/test/design-init-parity.test.ts new file mode 100644 index 0000000..ec8f520 --- /dev/null +++ b/test/design-init-parity.test.ts @@ -0,0 +1,84 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState } from "../src/policy/design/state"; +import type { NormalizedEvent } from "../src/runtime/normalize"; + +/** + * Init parity with dd8a2e1 (passe 3, défaut 2): the design state initializes + * from `findDesignSystem(cwd)` — the cwd walk ONLY — never from the FIRST + * event's filePath. A `sub/design-system.md` seen through a first Read/Grep/ + * Write must NOT flip the session into `page` mode (screenshot quota 2 + * instead of 4 without corpus, phase computed accordingly). The per-file + * resolver (passe 2) stays in charge at the WRITE gate only. + */ + +const VALID_DS = "## Design Reference\n- Source: https://example.com\n--a: oklch(0.62 0.19 250);"; +const SHOT = "mcp__fuse-browser__browser_screenshot"; + +function setup() { + const cache = mkdtempSync(join(tmpdir(), "fh-di-")); + const proj = mkdtempSync(join(tmpdir(), "fh-di-p-")); + const plugins = mkdtempSync(join(tmpdir(), "fh-di-g-")); + mkdirSync(join(proj, "sub"), { recursive: true }); + writeFileSync(join(proj, "sub", "design-system.md"), VALID_DS); + setActiveDesignAgent(cache, "ag"); + const gate = (e: NormalizedEvent) => designGate({ agent_id: "ag" }, e, cache, proj, "", plugins); + return { cache, proj, gate }; +} + +const pre = (tool: string, filePath?: string, content = ""): NormalizedEvent => + ({ phase: "pre", tool, input: {}, sessionId: "s", filePath, content }); +const post = (tool: string, filePath?: string): NormalizedEvent => + ({ phase: "post", tool, input: {}, sessionId: "s", filePath }); + +/** First-event shapes that must ALL initialize the state identically (dd8a2e1). */ +const FIRST_EVENTS: Array<[string, (proj: string) => NormalizedEvent]> = [ + ["pre Read sub/notes.md", (p) => pre("Read", join(p, "sub", "notes.md"))], + ["pre Grep sub/notes.md", (p) => pre("Grep", join(p, "sub", "notes.md"))], + ["pre Write sub/a.html", (p) => pre("Write", join(p, "sub", "a.html"), "x")], + ["pre Edit sub/notes.md", (p) => pre("Edit", join(p, "sub", "notes.md"), "x")], + ["pre apply_patch sub/a.html", (p) => ({ phase: "pre", tool: "apply_patch", input: {}, sessionId: "s", files: [{ filePath: join(p, "sub", "a.html"), content: "", op: "add" }] })], + ["post Read sub/notes.md", (p) => post("Read", join(p, "sub", "notes.md"))], + ["pre Grep (no filePath)", () => pre("Grep")], +]; + +test("init parity: the first event's filePath NEVER decides the session mode (dd8a2e1)", () => { + for (const [name, build] of FIRST_EVENTS) { + const { cache, proj, gate } = setup(); + gate(build(proj)); + const s = loadDesignState(cache, "ag"); + expect(s, name).not.toBeNull(); + expect(s!.mode, name).toBe("full"); + expect(s!.designSystemExists, name).toBe(false); + // 2 post captures: full mode without corpus needs 4 — phase stays 0. + gate(post(SHOT)); + gate(post(SHOT)); + expect(loadDesignState(cache, "ag")!.currentPhase, name).toBe(0); + // The counter-example's final write MUST stay blocked (2/4 screenshots). + const p = gate(pre("Write", join(proj, "design-system.md"), VALID_DS)); + expect(p?.kind, name).toBe("block"); + expect(p?.reason, name).toContain("2/4"); + } +}); + +test("owner case still passes: Write /index.html with /design-system.md present, cwd a parent", () => { + const { cache, proj, gate } = setup(); + // State initialized as in dd8a2e1 (no design-system.md at cwd → mode full). + gate(pre("Read", join(proj, "sub", "notes.md"))); + expect(loadDesignState(cache, "ag")!.mode).toBe("full"); + // Full pipeline, no corpus: 4 screenshots reach phase 2. + for (let i = 0; i < 4; i++) gate(post(SHOT)); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(2); + // A valid design-system.md write in sub/ passes PRE, and POST validates the real file → phase 3. + expect(gate(pre("Write", join(proj, "sub", "design-system.md"), VALID_DS))).toBeNull(); + gate(post("Write", join(proj, "sub", "design-system.md"))); + const s = loadDesignState(cache, "ag")!; + expect(s.currentPhase).toBe(3); + expect(s.designSystemValid).toBe(true); + // The owner case passes via the per-file resolver (passe 2) — NOT a mode change. + expect(gate(pre("Write", join(proj, "sub", "index.html"), ""))).toBeNull(); +}); diff --git a/test/design-refusal-text.test.ts b/test/design-refusal-text.test.ts new file mode 100644 index 0000000..8330e69 --- /dev/null +++ b/test/design-refusal-text.test.ts @@ -0,0 +1,49 @@ +import { test, expect } from "bun:test"; +import { initDesignState, type DesignState } from "../src/policy/design/state"; +import { designSystemWriteGate } from "../src/policy/design/gates-pipeline"; + +/** + * Defect 3: the design-system.md write refusal said "Screenshot real sector + * sites" (any fuse-browser capture counts, not just single shots) and never + * said WHAT was missing. The refusal must now list exactly the unmet + * requirements: corpus reads credited at the credited root (with its path) + * and screenshots taken/required (N/M). Blocking conditions and thresholds + * are unchanged — only the text moves. + */ +const ROOT = "/plugins/design-expert/skills/refs-design"; +const st = (over: Partial): DesignState => ({ ...initDesignState("ag", "full", false), ...over }); + +test("refusal with 0 screenshots shows 0/M and the credited corpus root path", () => { + const v = designSystemWriteGate("design-system.md", st({ currentPhase: 1 }), true, ROOT); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("0/2"); // full mode + corpus delivered => quota 2 + expect(v?.reason).toContain(ROOT); +}); + +test("screenshots sufficient but corpus missing -> ONLY the corpus gap is listed", () => { + const v = designSystemWriteGate("design-system.md", st({ currentPhase: 1, screenshotsCount: 2 }), true, ROOT); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("corpus reads"); + expect(v?.reason).toContain(ROOT); + expect(v?.reason).not.toContain("screenshots:"); +}); + +test("corpus absent (requirement waived) -> no corpus line, full-mode quota is 4", () => { + const v = designSystemWriteGate("design-system.md", st({ currentPhase: 1 }), false); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("0/4"); + expect(v?.reason).not.toContain("corpus reads"); +}); + +test("any fuse-browser capture counts — the refusal no longer mandates a single-tool screenshot", () => { + const v = designSystemWriteGate("design-system.md", st({ currentPhase: 1 }), true, ROOT); + expect(v?.reason).not.toContain("Screenshot real sector sites"); + expect(v?.reason).toContain("browser_shots_batch"); +}); + +test("thresholds unchanged: quota met at phase 2 passes, unmet screenshot quota still blocks with N/M", () => { + expect(designSystemWriteGate("design-system.md", st({ currentPhase: 2, screenshotsCount: 2 }), true, ROOT)).toBeNull(); + const v = designSystemWriteGate("design-system.md", st({ currentPhase: 2, mode: "page", screenshotsCount: 0 }), true, ROOT); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("0/1"); +}); diff --git a/test/design-system-locate.test.ts b/test/design-system-locate.test.ts new file mode 100644 index 0000000..cecfc2f --- /dev/null +++ b/test/design-system-locate.test.ts @@ -0,0 +1,177 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { designGate } from "../src/runtime/design"; +import { findDesignSystemForFile } from "../src/runtime/design-helpers"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { saveDesignState, initDesignState, loadDesignState } from "../src/policy/design/state"; +import type { DesignState } from "../src/policy/design/state"; + +/** + * Passe-2 counter-example matrix for `findDesignSystemForFile` (the + * file-write design-system.md locator introduced in passe 1): + * 1. SYMLINK ESCAPE — a design-system.md reachable only through a symlink + * whose REAL target sits outside the real cwd must NOT count + * (dd8a2e1 refused those writes; passe 1 allowed them lexically). + * 2. MASKING — an empty / unreadable / directory `sub/design-system.md` + * must not shadow a valid one higher up: the walk continues (and the + * historical cwd fallback still applies), restoring dd8a2e1's ALLOW. + * 3. apply_patch PARITY — the design-system.md lookup is PER FILE: every + * file of the patch gets the verdict it would get written alone by Write. + * The pass-1 feature itself is pinned too: a real, non-empty, readable + * design-system.md inside the real cwd, in the written file's directory or + * one of its parents, DOES count (blocked → allowed vs dd8a2e1, the only + * sanctioned flip class). + */ +const DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-dsl-")); + +/** cwd layout: proj/{sub/deep,} + sibling out/ (OUTSIDE proj) holding a valid design-system.md. */ +const layout = (): { T: string; proj: string } => { + const T = tmp(); + const proj = join(T, "proj"); + mkdirSync(join(proj, "sub/deep"), { recursive: true }); + mkdirSync(join(T, "out"), { recursive: true }); + writeFileSync(join(T, "out/design-system.md"), DS); + return { T, proj }; +}; + +/** Phase-3 + validated state: the ONLY remaining allow condition is a design-system.md found on disk. */ +const readyState = (cache: string): void => { + setActiveDesignAgent(cache, "ag"); + const state: DesignState = { ...initDesignState("ag", "page", true), currentPhase: 3, designSystemValid: true, designSystemExists: true }; + saveDesignState(cache, state); +}; +const writeEv = (fp: string) => + normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: fp, content: "" }, session_id: "s", agent_id: "ag" }); +const patchEv = (files: [string, string][]) => + normalizeEvent("codex", { + hook_event_name: "PreToolUse", tool_name: "apply_patch", session_id: "s", agent_id: "ag", + tool_input: { command: `*** Begin Patch\n${files.map(([p, c]) => `*** Add File: ${p}\n+${c}`).join("\n")}\n*** End Patch` }, + }); + +// --- 1. Symlink escape ------------------------------------------------------- + +test("unit: design-system.md reachable only via a symlinked DIR pointing outside cwd -> NOT found", () => { + const { T, proj } = layout(); + symlinkSync(join(T, "out"), join(proj, "link")); + expect(findDesignSystemForFile(proj, join(proj, "link/page.html"))).toBe(""); +}); + +test("unit: sub/design-system.md being a SYMLINK to a file outside cwd -> NOT found", () => { + const { T, proj } = layout(); + symlinkSync(join(T, "out/design-system.md"), join(proj, "sub/design-system.md")); + expect(findDesignSystemForFile(proj, join(proj, "sub/page.html"))).toBe(""); +}); + +test("unit: symlinked dir whose REAL target stays INSIDE cwd -> found (real-path containment, not lexical)", () => { + const { proj } = layout(); + mkdirSync(join(proj, "real"), { recursive: true }); + writeFileSync(join(proj, "real/design-system.md"), DS); + symlinkSync(join(proj, "real"), join(proj, "alias")); + expect(findDesignSystemForFile(proj, join(proj, "alias/page.html"))).toBe(DS); +}); + +test("gate: Write link/page.html (link -> ../out, outside) with phase-3 validated state -> DENY (dd8a2e1 parity)", () => { + const { T, proj } = layout(); + const cache = tmp(); + readyState(cache); + symlinkSync(join(T, "out"), join(proj, "link")); + const v = designGate({ agent_id: "ag" }, writeEv(join(proj, "link/page.html")), cache, proj, ""); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("not found on disk"); +}); + +// --- 2. Masking by an empty / directory design-system.md --------------------- + +test("unit: empty sub/design-system.md does NOT shadow the valid cwd one", () => { + const { proj } = layout(); + writeFileSync(join(proj, "design-system.md"), DS); + writeFileSync(join(proj, "sub/design-system.md"), ""); + expect(findDesignSystemForFile(proj, join(proj, "sub/page.html"))).toBe(DS); +}); + +test("unit: sub/design-system.md being a DIRECTORY does NOT shadow the valid cwd one", () => { + const { proj } = layout(); + writeFileSync(join(proj, "design-system.md"), DS); + mkdirSync(join(proj, "sub/design-system.md")); + expect(findDesignSystemForFile(proj, join(proj, "sub/page.html"))).toBe(DS); +}); + +test("unit: an empty design-system.md alone still counts as absent", () => { + const { proj } = layout(); + writeFileSync(join(proj, "sub/design-system.md"), ""); + expect(findDesignSystemForFile(proj, join(proj, "sub/page.html"))).toBe(""); +}); + +test("gate: Write sub/page.html with valid cwd design-system.md + EMPTY sub one, phase-3 validated -> ALLOW (dd8a2e1 parity)", () => { + const { proj } = layout(); + const cache = tmp(); + readyState(cache); + writeFileSync(join(proj, "design-system.md"), DS); + writeFileSync(join(proj, "sub/design-system.md"), ""); + expect(designGate({ agent_id: "ag" }, writeEv(join(proj, "sub/page.html")), cache, proj, "")).toBeNull(); +}); + +test("init: masking fixed at auto-init too — cwd valid + sub empty -> mode 'page', not 'full'", () => { + const { proj } = layout(); + const cache = tmp(); + setActiveDesignAgent(cache, "ag"); // flag set, NO state file: designGate auto-inits + writeFileSync(join(proj, "design-system.md"), DS); + writeFileSync(join(proj, "sub/design-system.md"), ""); + designGate({ agent_id: "ag" }, writeEv(join(proj, "sub/page.html")), cache, proj, ""); + expect(loadDesignState(cache, "ag")?.mode).toBe("page"); +}); + +// --- 3. apply_patch per-file resolution -------------------------------------- + +test("unit (pass-1 feature pinned): real design-system.md next to the written file, inside cwd -> found", () => { + const { proj } = layout(); + writeFileSync(join(proj, "sub/design-system.md"), DS); + expect(findDesignSystemForFile(proj, join(proj, "sub/page.html"))).toBe(DS); + expect(findDesignSystemForFile(proj, join(proj, "sub/deep/x.css"))).toBe(DS); +}); + +test("gate: apply_patch [sub/a.css, sub/page.html] with design-system.md in sub/ -> ALLOW, same verdict as each file written alone by Write", () => { + const { proj } = layout(); + const cache = tmp(); + readyState(cache); + writeFileSync(join(proj, "sub/design-system.md"), DS); + expect(designGate({ agent_id: "ag" }, writeEv(join(proj, "sub/a.css")), cache, proj, "")).toBeNull(); + expect(designGate({ agent_id: "ag" }, writeEv(join(proj, "sub/page.html")), cache, proj, "")).toBeNull(); + const patch = designGate({ agent_id: "ag" }, patchEv([[join(proj, "sub/a.css"), "a{}"], [join(proj, "sub/page.html"), ""]]), cache, proj, ""); + expect(patch).toBeNull(); +}); + +test("gate: apply_patch [sub/page.html, other.css] with design-system.md only in sub/ -> DENY on other.css (per-file verdict: other.css alone would be denied; a ds found for the FIRST file must not leak to the rest)", () => { + const { proj } = layout(); + const cache = tmp(); + readyState(cache); + writeFileSync(join(proj, "sub/design-system.md"), DS); + const solo = designGate({ agent_id: "ag" }, writeEv(join(proj, "other.css")), cache, proj, ""); + expect(solo?.kind).toBe("block"); + const v = designGate({ agent_id: "ag" }, patchEv([[join(proj, "sub/page.html"), ""], [join(proj, "other.css"), "a{}"]]), cache, proj, ""); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("other.css"); +}); + +test("gate: apply_patch [other.css, page.html] with design-system.md only in sub/ -> DENY (neither file has a design-system.md in its own directory chain)", () => { + const { proj } = layout(); + const cache = tmp(); + readyState(cache); + writeFileSync(join(proj, "sub/design-system.md"), DS); + const v = designGate({ agent_id: "ag" }, patchEv([[join(proj, "other.css"), "a{}"], [join(proj, "page.html"), ""]]), cache, proj, ""); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("not found on disk"); +}); + +test("gate: apply_patch [other.css, link/page.html] with design-system.md outside via symlink -> DENY (escape refused per file)", () => { + const { T, proj } = layout(); + const cache = tmp(); + readyState(cache); + symlinkSync(join(T, "out"), join(proj, "link")); + const v = designGate({ agent_id: "ag" }, patchEv([[join(proj, "other.css"), "a{}"], [join(proj, "link/page.html"), ""]]), cache, proj, ""); + expect(v?.kind).toBe("block"); +}); From 0ed17df2d2bdafe7a408ed74aa1fef854c998416 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Sun, 11 Oct 2026 20:57:51 +0200 Subject: [PATCH 3/3] chore: bump version and CHANGELOG to 0.1.106 --- CHANGELOG.md | 9 +++++++++ docs/motion.md | 4 +++- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fec7d1..c7a43d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.106] - 2026-10-11 + +### Fixed + +- **motion: field references in a multi-line awk program (`$3`, `$NF`, `${2}`) are no longer read as currency amounts** (`src/policy/motion/program-bodies.ts`, `sensitive.ts`) — only inside a single-quoted awk/gawk/mawk body in shell command position (not in a quote, `#` comment or heredoc), closed, containing `{`, and only in code position; awk string literals, awk comments and bullet lines keep their amounts; `sh -c`/`bash -c` and double-quoted awk bodies are scanned as before. Known limit: a prose line with an amount placed inside an otherwise-awk body (not valid awk) is not flagged. Tests: `motion-sensitive`, `motion-sensitive-diff`. +- **design: `design-system.md` is resolved from the written file's directory upward** (`src/runtime/design.ts`, `design-helpers.ts`, `design-files-gate.ts`) — real paths, confined to the real cwd, empty/unreadable candidates skipped, then cwd; per-file verdict for Write/Edit/apply_patch; session mode/quota unchanged (still decided from cwd). Tests: `design-system-locate`, `design-init-parity`. +- **design: deny texts list exactly what is missing** (`src/policy/design/gates-pipeline.ts`) — corpus reads counted at the credited root (with its path) and screenshots taken/required (N/M); any fuse-browser capture counts. Blocking conditions and thresholds unchanged. Tests: `design-refusal-text`, `design-html-css-pipeline-gate`. +- **motion: the PreToolUse budget note counts completed renders (incremented by PostToolUse)** — verified, not a bug; test added: `motion-budget-note`. + ## [0.1.105] - 2026-10-11 ### Fixed diff --git a/docs/motion.md b/docs/motion.md index d517c26..fba45ce 100644 --- a/docs/motion.md +++ b/docs/motion.md @@ -156,7 +156,9 @@ unreadable list supplies no terms. Currency amounts (`$`, `€`, `£`, Write/Edit/MultiEdit content under `sourceDir` is scanned in full (no size cut-off), not the on-disk file or arbitrary Bash-generated content. Shell positional parameters (`$1`, `${2}`, `awk '{print $1}'`) are not treated as -currency amounts. Detected values deny the write +currency amounts, nor are field references (`$3`, `$NF`, `${2}`) in the code +of a closed, single-quoted multi-line awk/gawk/mawk program in shell command +position; awk string literals and comments are still scanned. Detected values deny the write and are masked in the report: one-/two-character values are fully masked; longer ones retain only their first character. Source: [`sensitive.ts`](../src/policy/motion/sensitive.ts). diff --git a/package.json b/package.json index baf9130..3aa6492 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.105", + "version": "0.1.106", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts",