Guards the refresh run: no hand-written receipts, no Bash publish path, Pass A freshness tracking, Webflow payload checks.

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.ts 673 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { PassAEntry } from '../types'
5import { plainView } from './plain'
6
7// refresh-run-guard (LC-1712, epic LC-1074).
8// Why it exists: the refresh pipeline's proof (receipts, Pass A) can be
9// defeated by a session that writes receipts by hand or publishes through a
10// side door. The Python PreToolUse hooks in .claude/hooks stay as the floor;
11// this mod adds the cases they cannot see (Bash, Supabase SQL) and a memory
12// of Pass A freshness that a stateless hook does not have.
13//
14// Every gating hook ends in .catch that DENIES: a broken guard must block,
15// never wave a call through. A guard that also false-denies gets switched off,
16// so each rule below is as narrow as its evidence allows.
17
18// Callout convention (LC-1711): every deny / ask / block / changed-something message starts with
19// `[mod:refresh-run-guard]` (that text reaches the model in the tool result, so it can say who acted) and
20// fires a toast naming the mod and the reason (so the operator sees it too). One helper keeps both together.
21const MOD = 'refresh-run-guard'
22function say($: any, reason: string): string {
23 const text = `[mod:${MOD}] ${reason.replace(/^refresh-run-guard: /, '')}`
24 try {
25 $.ui.toast(text.length > 160 ? `${text.slice(0, 157)}...` : text)
26 } catch { /* a toast must never be the reason a guard fails */ }
27 return text
28}
29
30// Field slugs and collection ids below are copied from
31// .claude/skills/article-technical-audit/SKILL.md (§1 Blog Posts, §3 Products) and
32// .claude/skills/webflow-cms/SKILL.md (Architecture 2: the Blog Posts body is the rich-text fields
33// `intro`, `post-content`, `post-content-two`; the audit register also lists `conclusion`); none are guessed.
34// `post-body` is NOT a Blog Posts field (it is link-migration's configurable default) and is not used here.
35const PRODUCTS_COLLECTION = '65fa33419d90d967c68a0963'
36const BLOG_COLLECTION = '65fa33419d90d967c68a0964'
37const BODY_FIELDS = ['intro', 'post-content', 'post-content-two', 'conclusion']
38const GO_LINK = /^https:\/\/go\.livingcozy\.com\/[A-Za-z0-9][A-Za-z0-9_\-/]*$/
39
40const passA = atom({ plugin: 'refresh-run-guard', key: 'passA' } as const, {} as Record<string, PassAEntry>)
41// Keyed `${itemId}:${field}`: one baseline per replaced body field.
42const pills = atom({ plugin: 'refresh-run-guard', key: 'pills' } as const, {} as Record<string, number>)
43
44// Any server's execute_sql, not just one named "Supabase": the connector id
45// differs per environment and a name-keyed guard would silently never fire.
46const SQL_TOOL = /^mcp__.*__(?:execute_sql|apply_migration)$/
47// One anchored pattern on the Webflow MCP's own data_*_tool names; the connector id is a UUID here, so the
48// connector name cannot be required. Other tools (SQL, ask_webflow_ai) stay on their own matchers.
49const WEBFLOW_TOOL = /^mcp__.*__data_(?:cms|sites|pages)_tool$/
50
51// ---------- rule 1: hand-written receipts -------------------------------
52
53const RECEIPT_WHY =
54 'Receipts are written by the pipeline tools, never by hand (run 96a5cfa9, 2026-09-20: hand-written receipts defeated the gate). ' +
55 'Use `python3 webflow/bulk-operations/refresh-receipt-writer/write_stage_receipt.py` for stage receipts, ' +
56 'or `python3 webflow/bulk-operations/article-audit/article_audit.py --slug <slug> --pass a --write-receipt` for the audit receipt. ' +
57 'Reads (SELECT) stay allowed.'
58
59// Linear comment stripper. A lazy `/\*[\s\S]*?\*/` regex rescans to the end of the input from every
60// `/*` that never closes, which is quadratic on a hostile query; this walks the text once.
61function stripBlockComments(text: string, filler: string): string {
62 let out = ''
63 let i = 0
64 for (;;) {
65 const open = text.indexOf('/*', i)
66 if (open < 0) return out + text.slice(i)
67 out += text.slice(i, open) + filler
68 const close = text.indexOf('*/', open + 2)
69 if (close < 0) return out // unterminated: the comment runs to the end
70 i = close + 2
71 }
72}
73
74const stripLiterals = (s: string): string => s.replace(/'(?:[^']|'')*'/g, "''")
75const stripComments = (s: string, filler: string): string => stripBlockComments(s.replace(/--.*$/gm, filler), filler)
76
77const MAX_CHECKED = 500_000
78
79// The shapes that run text as SQL: a DO block, `execute '...'` / `execute format(...)` / `execute $$...`, and
80// `call proc(...)`. A bare word in a string (`'execute me'`) is not one of them.
81const DYNAMIC_SQL = /\bdo\s*(?:\$|language\b|')|\bexecute\s+(?:format\s*\(|quote_\w+\s*\(|'|\$)|\bcall\s+[\w."]+\s*\(/
82
83// A write's TARGET is the relation right after the verb, not any table named somewhere in the text:
84// `update other set x = (select ... from refresh_run_sections)` writes `other`, not a receipt table.
85const TBL = String.raw`(?:"?[a-z_]+"?\s*\.\s*)?"?(?<t>refresh_run_sections|refresh_runs)"?\b`
86const TARGETS: { verb: string; re: RegExp }[] = [
87 { verb: 'update', re: new RegExp(String.raw`\bupdate\s+(?:only\s+)?${TBL}`) },
88 { verb: 'delete', re: new RegExp(String.raw`\bdelete\s+from\s+(?:only\s+)?${TBL}`) },
89 { verb: 'insert', re: new RegExp(String.raw`\binto\s+(?:table\s+)?${TBL}`) }, // insert into / select into
90 { verb: 'merge', re: new RegExp(String.raw`\bmerge\s+into\s+${TBL}`) },
91 { verb: 'truncate', re: new RegExp(String.raw`\btruncate\s+(?:table\s+)?(?:only\s+)?${TBL}`) },
92 { verb: 'copy', re: new RegExp(String.raw`\bcopy\s+${TBL}`) },
93 { verb: 'drop', re: new RegExp(String.raw`\b(?:drop|alter)\s+(?:table\s+)?(?:if\s+exists\s+)?${TBL}`) },
94]
95const WHOLE_ROW_VERBS = new Set(['delete', 'truncate', 'merge', 'copy', 'drop'])
96
97export function sqlViolation(query: unknown): string | null {
98 if (typeof query !== 'string') return 'query was not a string, so it could not be checked'
99 if (query.trim() === '') return 'query was empty, so there was nothing to check'
100 if (query.length > MAX_CHECKED) return 'query was too large to check'
101 const lower = query.toLowerCase()
102 // String literals are removed before the scan (a quoted `update refresh_run_sections` is data). The
103 // order of literal and comment stripping changes what a stray quote or `--` hides, so both orders are
104 // judged, with comments read as whitespace and as nothing, and a deny on ANY view stands:
105 // `select '--'; update ...` and `-- it's` + a later quote each hide a statement from one order only.
106 const views = [
107 stripLiterals(lower),
108 stripComments(stripLiterals(lower), ' '),
109 stripComments(stripLiterals(lower), ''),
110 stripLiterals(stripComments(lower, ' ')),
111 stripLiterals(stripComments(lower, '')),
112 ]
113 // Dynamic SQL runs text this scan cannot follow, and literal stripping hides the text it would run
114 // (`execute 'update refresh_run_sections ...'`, `execute format(...)`). So, apart from the target scan:
115 // - EXECUTE or CALL as a statement is refused outright (a prepared or stored body is invisible here;
116 // nothing in the refresh pipeline needs either through these tools);
117 // - a DO block, or an EXECUTE/CALL anywhere, in text that names a receipt table at all, INCLUDING inside
118 // a string literal, is refused (judged on views that keep the literals).
119 const keepLiterals = [lower, stripComments(lower, ' '), stripComments(lower, '')]
120 for (const v of keepLiterals) {
121 if (/\b(refresh_run_sections|refresh_runs)\b/.test(v) && DYNAMIC_SQL.test(v)) {
122 return 'This SQL runs dynamic SQL (DO/EXECUTE/CALL) in text that names a receipt table.'
123 }
124 }
125 for (const v of views) {
126 for (const stmt of v.split(';')) {
127 if (/^\s*(?:execute|call)\b/.test(stmt)) return 'This SQL is an EXECUTE/CALL statement, whose body this guard cannot read.'
128 const names = /\b(refresh_run_sections|refresh_runs)\b/.test(stmt)
129 if (!names) continue
130 for (const { verb, re } of TARGETS) {
131 const m = re.exec(stmt)
132 if (!m) continue
133 const table = m.groups?.t
134 if (table === 'refresh_run_sections') return 'This SQL writes refresh_run_sections.'
135 // refresh_runs: whole-row removal destroys receipts; UPDATE/INSERT are a violation only when they
136 // set the receipt columns (a plain status UPDATE, or creating a run row, is the conductor's lifecycle).
137 if (WHOLE_ROW_VERBS.has(verb)) return 'This SQL removes or recreates refresh_runs rows, which destroys their receipts.'
138 if (/\b(stages|selection_provenance)\b/.test(stmt)) return 'This SQL writes refresh_runs.stages / selection_provenance.'
139 }
140 }
141 }
142 return null
143}
144
145// ---------- rule 2: Bash publish path -----------------------------------
146
147// A publish attempt is something that can RUN a command (an executor) plus a Webflow publish call. Judging
148// by "any token containing webflow and publish" false-denies editing, grepping and committing; judging
149// quoted text as code false-denies `gh pr create --body "...publish_site..."`.
150const PUBLISH_MARKER =
151 /\b(?:publish_site|publish_collection_items|publish_collection|publish_items)\b|api\.webflow\.com[^\s'"]*\/publish\b|\/publish\b[^\n]*api\.webflow\.com/i
152const WEBFLOW_PUBLISH_URL = /webflow[^\n]*\/publish\b/i
153// Shells, runtimes and runners. `gh`, `git`, `echo` and friends are deliberately absent.
154const EXECUTOR = /\b(?:curl|wget|httpie|python[0-9.]*|node|deno|bun|npx|npm|pnpm|yarn|make|just|perl|ruby|php|wrangler|bash|sh|zsh|dash|source|exec|eval|xargs)\b|(?<![\w:/.-])http(?=\s)/i
155// A command whose first word is a variable or a path runs something we cannot name (`$CMD ...`, `./x.sh`).
156const COMMAND_POSITION = /^\s*(?:\w+=\S+\s+)*(?:\$\{?\w+|\.{0,2}\/\S+|~\/\S+)/
157// A script NAMED for a Webflow publish is judged by its name, since its body cannot be read from here.
158const PUBLISH_SCRIPT_NAME = /webflow[\w.:\-]*(?<!un)publish|(?<!un)publish[\w.:\-]*webflow/i
159// The ONE exemption: the repo's own hook file, by exact basename. A substring like `-gate` would let
160// `do_webflow_publish-gate.sh` through.
161const GATE_SCRIPT = /(?:^|\/)webflow-publish-gate\.py$/
162const READ_ONLY_FIRST = /^\s*(?:grep|rg|cat|head|tail|ls|find|wc|diff|less|git|sed\s+-n)\b/
163
164// Splits on ; & | newline that are OUTSIDE quotes, so a `;` or `|` inside a quoted argument stays put.
165function splitOutsideQuotes(cmd: string): string[] {
166 const out: string[] = []
167 let cur = ''
168 let q: string | null = null
169 for (let i = 0; i < cmd.length; i++) {
170 const c = cmd[i]
171 if (q) {
172 cur += c
173 // Backslash escapes inside "..." and inside $'...' (ANSI-C quoting, where `\'` does not close it).
174 if (c === '\\' && (q === '"' || q === "$'")) cur += cmd[++i] ?? ''
175 else if (c === (q === "$'" ? "'" : q)) q = null
176 continue
177 }
178 if (c === '$' && cmd[i + 1] === "'") { q = "$'"; cur += c + "'"; i++; continue }
179 if (c === "'" || c === '"') { q = c; cur += c; continue }
180 // A backslash outside quotes escapes the next character: `'don'\\''t'` and `don\\'t` do not open a quote.
181 if (c === '\\') { cur += c + (cmd[++i] ?? ''); continue }
182 if (c === ';' || c === '\n' || c === '|' || c === '&') { out.push(cur); cur = ''; continue }
183 cur += c
184 }
185 out.push(cur)
186 return out.filter(s => s.trim() !== '')
187}
188
189// The ONLY steps that may follow a dropped heredoc body (LC-1734). An allow-list, not a list of things to
190// fear: the file a heredoc wrote can be run in more ways than any deny-list names (`timeout 5 sh f`,
191// `if true; then sh f; fi`, `{ sh f; }`, `npm run p`, a git hook behind `git commit`, ...), so every
192// step must be a plain cat/tee quoted-terminator heredoc write itself, or one of these read-only /
193// test-runner commands. Anything else and the bodies stay judged (the old, stricter behaviour).
194const BENIGN_STEP = [
195 /^(?:cat|tee)\b[^|;&]*<<-?\s*['"]/,
196 /^git\s+(?:add|status|diff)(?:\s|$)/,
197 /^(?:ls|grep|wc)(?:\s|$)/,
198 /^claude\s+plugin\s+(?:test|validate)(?:\s|$)/,
199]
200// A cat/tee heredoc write whose TARGET could change what a later benign git command runs (a hook, a config
201// `fsmonitor` / `diff.external` / `filter` / `textconv`, `.gitattributes`): any path with a `.git...` component,
202// or a variable in it (`$GIT_DIR/config`), keeps the body judged.
203const RISKY_TARGET = /\$|(?:^|[\s/'"=])\.git/
204const CAT_TEE_WRITE = BENIGN_STEP[0]
205export const onlyBenignSteps = (text: string): boolean =>
206 // `2>&1` is a descriptor copy, not a separator: take it out before splitting on `&`.
207 splitOutsideQuotes(text.replace(/[0-9]?>&[0-9]/g, '')).every(seg => {
208 const s = seg.trim()
209 return CAT_TEE_WRITE.test(s) ? !RISKY_TARGET.test(s) : BENIGN_STEP.some(re => re.test(s))
210 })
211
212// Every heredoc body (any consumer, quoted or not) cut out. An EXTRA view only: a body can hold an apostrophe
213// that pairs with a quote on a later line and hides a real call in the raw splitter (`cat > n.md <<'EOF'` /
214// `it's here` / `EOF` then a curl with a quoted header). Not quote-aware on purpose: the raw view is judged too,
215// so this can only add denials.
216function withoutHeredocBodies(text: string): string {
217 const lines = text.split('\n')
218 const out: string[] = []
219 for (let i = 0; i < lines.length; i++) {
220 out.push(lines[i])
221 const m = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(lines[i])
222 if (!m || lines[i].includes('<<<')) continue
223 const end = lines.findIndex((l, k) => k > i && l.replace(/^\t+/, '').trimEnd() === m[2])
224 if (end >= 0) i = end
225 }
226 return out.join('\n')
227}
228
229// A comment cut off at its `#` (a `#` that starts a word). Used ONLY as a second view: the splitter reads an
230// apostrophe in `# it's` as an opening quote that can swallow the next line, so a later publish call
231// hides inside a "quoted" string. Judging this view as well can only add denials; the as-written view is
232// still judged too, because cutting at `#` can itself hide code (`echo \`echo a # b\`; curl ...`).
233const withoutComments = (text: string): string => text.replace(/(^|[\s;&|(])#[^\n]*/g, '$1')
234
235const outsideQuotes = (s: string): string => s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
236
237export function bashPublishViolation(command: unknown): string | null {
238 if (typeof command !== 'string') return 'command was not a string, so it could not be checked'
239 if (command.trim() === '') return 'command was empty, so there was nothing to check'
240 if (command.length > MAX_CHECKED) return 'command was too large to check'
241 // A cat/tee heredoc body in a PLAIN command is text being written to a file (a test that quotes the
242 // publish URL), not something the shell runs, so it is not judged (LC-1734) - but only when every other
243 // step is on the short benign allow-list above (a later step could run the file the heredoc wrote).
244 // A command that is not plain is judged whole, exactly as before.
245 const view = plainView(command)
246 // The exemption: judge ONLY the body-dropped text. Otherwise judge the raw command AND the command with every
247 // heredoc body cut out, and deny if either does (the first can be blinded by a quote inside a body, the second
248 // by a quote the cut leaves unpaired; together they close each other's gap).
249 if (view.plain && onlyBenignSteps(view.text)) return judgeWithComments(view.text)
250 return judgeWithComments(command) ?? judgeWithComments(withoutHeredocBodies(command))
251}
252
253// Comments are judged twice: as written, and cut off at their `#`.
254const judgeWithComments = (t: string): string | null =>
255 judgeText(t) ?? (t.includes('#') ? judgeText(withoutComments(t)) : null)
256
257function judgeText(judgedText: string): string | null {
258 // Segments that only read a file are dropped; the rest are judged together so a URL set in one
259 // segment and fetched in the next is still seen.
260 const kept = splitOutsideQuotes(judgedText).filter(
261 seg => !(READ_ONLY_FIRST.test(seg) && !EXECUTOR.test(outsideQuotes(seg).replace(READ_ONLY_FIRST, ''))),
262 )
263 const rest = kept.join('\n')
264 const runs = kept.some(seg => EXECUTOR.test(outsideQuotes(seg)) || COMMAND_POSITION.test(seg))
265 if (!runs) return null
266 const withoutGate = rest.split(/\s+/).filter(tok => !GATE_SCRIPT.test(tok)).join(' ')
267 if (PUBLISH_MARKER.test(rest) || WEBFLOW_PUBLISH_URL.test(rest) || PUBLISH_SCRIPT_NAME.test(withoutGate)) {
268 return 'This Bash command publishes to Webflow.'
269 }
270 return null
271}
272
273// ---------- shared: Webflow action payloads -----------------------------
274
275type Json = Record<string, any>
276
277// Reads `actions` at the top level AND under `input`, so tool.call and tool.check judge the same
278// payload whichever envelope the MCP call arrives in.
279function actionsOf(input: unknown): Json[] {
280 const o = input as Json | null
281 const out: Json[] = []
282 for (const a of [o?.actions, o?.input?.actions]) {
283 if (Array.isArray(a)) out.push(...a.filter((x): x is Json => !!x && typeof x === 'object'))
284 }
285 return out
286}
287
288// create passes items[] each with fieldData (the older shape is a fieldData LIST); update passes items[]
289// each with a fieldData OBJECT. All are handled (the shapes webflow-no-slug-guard.py reads).
290function writes(action: Json): { kind: 'create' | 'update'; collection: string; id: string; fd: Json }[] {
291 const out: { kind: 'create' | 'update'; collection: string; id: string; fd: Json }[] = []
292 // Any write-shaped key naming collection items is parsed (so a `_live` or `upsert_` variant cannot dodge
293 // the payload checks). Only `create*` is a create; everything else is judged as an update, the stricter side.
294 for (const [key, body] of Object.entries(action)) {
295 if (!/collection_items/.test(key) || READ_ACTION.test(key) || /(?:^|_)(?:un)?publish|^delete_/.test(key)) continue
296 const kind: 'create' | 'update' = key.startsWith('create') ? 'create' : 'update'
297 if (!body || typeof body !== 'object') continue
298 const req = body.request ?? {}
299 const collection = String(body.collection_id ?? '')
300 const lone = req.fieldData
301 if (Array.isArray(lone)) {
302 for (const fd of lone) if (fd && typeof fd === 'object') out.push({ kind, collection, id: '', fd })
303 } else if (lone && typeof lone === 'object') {
304 out.push({ kind, collection, id: '', fd: lone })
305 }
306 for (const it of Array.isArray(req.items) ? req.items : []) {
307 if (it && typeof it.fieldData === 'object' && it.fieldData) {
308 out.push({ kind, collection, id: String(it.id ?? ''), fd: it.fieldData })
309 }
310 }
311 }
312 return out
313}
314
315// Action names, from the Webflow MCP schemas of data_cms_tool, data_sites_tool and data_pages_tool (read
316// 2026-10-07 via ToolSearch select:...). Reads: get_*, list_*, query_* (query_pages_schema_markup).
317// Writes: create_/update_/delete_ (collections, fields, field groups, collection items, pages, branches),
318// unpublish_collection_items (reverts a LIVE item to draft), and the ones a create/update prefix misses:
319// bulk_update_pages, bulk_update_pages_schema_markup and merge_branch. The schemas list no `*_live` or
320// `upsert_` variant; item writes are staged and publish_* is the only path live (denied below).
321// An action key in NEITHER list is refused as unrecognized: an unknown shape must not read as permissive.
322const READ_ACTION = /^(?:get|list|query)_/
323const WRITE_ACTION = /^(?:create|update|delete|unpublish|bulk_update|merge|upsert|replace|set|restore)_/
324
325// Keys of every action that are neither a read nor a write nor publish-shaped (`label` is not an action).
326function unrecognizedKeys(input: unknown): string[] {
327 const found: string[] = []
328 for (const a of actionsOf(input)) {
329 for (const k of Object.keys(a)) {
330 if (k === 'label' || READ_ACTION.test(k) || WRITE_ACTION.test(k) || /publish/i.test(k)) continue
331 found.push(k)
332 }
333 }
334 return found
335}
336
337function isWrite(input: unknown): boolean {
338 return actionsOf(input).some(a => Object.keys(a).some(k => WRITE_ACTION.test(k)))
339}
340
341function publishKeys(input: unknown): string[] {
342 const found: string[] = []
343 for (const a of actionsOf(input)) {
344 for (const k of Object.keys(a)) {
345 const lk = k.toLowerCase()
346 if (lk.includes('publish') && !lk.includes('unpublish')) found.push(k)
347 }
348 }
349 return found
350}
351
352const pillCount = (html: unknown): number =>
353 typeof html === 'string' ? (html.match(/lc-deal-pill/g) ?? []).length : 0
354
355// ---------- rule 4: payload checks --------------------------------------
356
357type Verdict = { deny?: string; ask?: string }
358
359// Pill loss mirrors markdown_to_webflow/pill_guard.py (LCH-302): per replaced body field, compare the live
360// count with the incoming one. That guard fetches the live item itself; a hook cannot, so the baseline is
361// whatever an earlier Webflow read in this session returned. Known loss is a deny. An unknown baseline is an
362// ASK, not a deny: a permanent block gets the guard switched off, and the converter's own guard still
363// refuses a real loss at --apply time.
364function payloadVerdict(input: unknown, baseline: Record<string, number>): Verdict {
365 const deny: string[] = []
366 const ask: string[] = []
367 for (const action of actionsOf(input)) {
368 for (const w of writes(action)) {
369 const onProducts = w.collection === PRODUCTS_COLLECTION
370 if (onProducts && 'product-link' in w.fd) {
371 const v = w.fd['product-link']
372 if (typeof v !== 'string' || !GO_LINK.test(v)) {
373 deny.push(
374 `Products \`product-link\` must be a https://go.livingcozy.com/<redirect_slug> URL (LCH-220); got ${JSON.stringify(v)}. ` +
375 'Never a raw brand, canonical or affiliate URL; no valid slug means skip and flag the card, not a fallback.',
376 )
377 }
378 }
379 if (onProducts && 'main-image' in w.fd) {
380 deny.push(
381 'A Products card hero goes to `feature-image`, NOT `main-image` (article-technical-audit §3). ' +
382 '`main-image` is the Blog Posts field.',
383 )
384 }
385 if (w.collection !== BLOG_COLLECTION) continue
386 for (const field of BODY_FIELDS) {
387 if (!(field in w.fd)) continue
388 const body = w.fd[field]
389 if (w.kind === 'update') {
390 const base = w.id ? baseline[`${w.id}:${field}`] : undefined
391 const now = pillCount(body)
392 if (base !== undefined && now < base) {
393 deny.push(
394 `This \`${field}\` replace on item ${w.id} would drop lc-deal-pill spans (${base} seen in the live field, ${now} in the payload; LCH-302). ` +
395 'Carry the pill markup next to the same brand CTA, or have the operator declare the loss on the converter path (accept_pill_loss).',
396 )
397 } else if (base === undefined) {
398 ask.push(
399 `No live read of ${w.id ? `item ${w.id}'s` : 'this item\'s'} \`${field}\` was seen this session, so lc-deal-pill spans cannot be compared (LCH-302). ` +
400 'Read the item first (list_collection_items), or confirm it carries no pills.',
401 )
402 }
403 }
404 // A literal price in prose is sometimes legitimate ("a $1,000 budget"), and the payload alone cannot say
405 // where a {{price:P-NNNNN}} belongs, so this is an ask.
406 if (typeof body === 'string' && /\$\s?\d[\d,]*(?:\.\d{2})?/.test(body.replace(/\{\{price:[^}]*\}\}/g, ''))) {
407 ask.push(
408 `The \`${field}\` payload has a hand-typed price. Product prices belong in a {{price:P-NNNNN}} shortcode (LC-908) so they track the catalog.`,
409 )
410 }
411 }
412 }
413 }
414 return { deny: deny.length ? deny.join(' | ') : undefined, ask: ask.length ? ask.join(' | ') : undefined }
415}
416
417// ---------- Pass A bookkeeping ------------------------------------------
418
419const PASS_A_CMD = /article_audit\.py/
420const PASS_A_FLAGS = /--pass[ =](?:a|both)\b/
421const WRITE_RECEIPT = /--write-receipt\b/
422
423// Pass A is recorded only from a command that cannot splice in extra stdout or mask the exit status. These
424// harmless wrappers are allowed: a leading `cd <dir> &&`, `2>&1`, and a trailing `| tail|head|tee ...`.
425function isSimpleAuditCommand(cmd: string): boolean {
426 let c = cmd.trim()
427 c = c.replace(/^cd\s+[^;&|`$()\n]+?\s*&&\s*/, '')
428 c = c.replace(/\s2>&1/g, '')
429 c = c.replace(/\s*\|\s*(?:tail|head|tee)\b[^;&|`$()\n]*$/, '')
430 return !/[;&|`\n#]|\$\(|\becho\b|\bprintf\b/.test(c)
431}
432
433function describePassA(map: Record<string, PassAEntry>): string {
434 const rows = Object.values(map)
435 if (!rows.length) return 'No Pass A receipt was recorded this session.'
436 return rows
437 .map(r => `${r.slug || r.runId}: ${r.stale ? 'STALE (an item write came after it)' : 'fresh'}`)
438 .join('; ')
439}
440
441async function paintStatus($: any) {
442 const rows = Object.values(await read($, passA))
443 if (!rows.length) return $.ui.status(undefined)
444 const stale = rows.some(r => r.stale)
445 $.ui.status(stale ? 'Pass A STALE — rerun before the publish click' : `Pass A fresh: ${rows.map(r => r.slug || r.runId).join(', ')}`)
446}
447
448// A bookkeeping step threw after the tool already ran. Return the tool's own result (never call
449// next again), but treat Pass A as stale and tell the model so.
450async function bookkeepingFailed($: any, ran: any, what: string) {
451 try {
452 const at = await $.clock.now()
453 await update($, passA, m =>
454 Object.fromEntries(Object.entries(m).map(([k, r]) => [k, { ...r, stale: true, staleAt: at }])),
455 )
456 } catch { /* the note below still goes out */ }
457 return { ...ran, context: [...(ran.context ?? []), say($, `${what} bookkeeping failed; treat Pass A as STALE. ${STALE_NOTE}`)] }
458}
459
460// The guard broke AFTER the tool ran. next is never called again (the tool must not run twice), and
461// the refusal says plainly that the effect may have applied, so the model checks before retrying.
462const AFTER_FAIL =
463 'the guard failed AFTER this tool ran, so its effect may already have applied. ' +
464 'Do not retry blindly: verify the result first, and treat Pass A as STALE.'
465
466const STALE_NOTE = 'Pass A stale — rerun before the publish click: `python3 webflow/bulk-operations/article-audit/article_audit.py --slug <slug> --pass a --write-receipt`.'
467
468// The Webflow MCP answers with one JSON object per action, back to back (measured 2026-10-07):
469// {"label":..,"action":"list_collection_items","result":{"items":[{"id":..,"fieldData":{..}}],"pagination":{..}}}
470// so the text is read as a sequence of top-level JSON values, not one document.
471function jsonValues(text: string | undefined): unknown[] {
472 const out: unknown[] = []
473 if (!text) return out
474 let i = 0
475 while (i < text.length) {
476 const c = text[i]
477 if (c !== '{' && c !== '[') { i++; continue }
478 let depth = 0
479 let inStr = false
480 let j = i
481 for (; j < text.length; j++) {
482 const d = text[j]
483 if (inStr) {
484 if (d === '\\') j++
485 else if (d === '"') inStr = false
486 continue
487 }
488 if (d === '"') inStr = true
489 else if (d === '{' || d === '[') depth++
490 else if (d === '}' || d === ']') { depth--; if (depth === 0) break }
491 }
492 if (depth !== 0) break
493 try { out.push(JSON.parse(text.slice(i, j + 1))) } catch { /* not JSON: skip past it */ }
494 i = j + 1
495 }
496 return out
497}
498
499// Item bodies seen in Webflow read results become the pill baseline, per body field.
500function harvest(value: unknown, out: Record<string, number>, depth = 0): void {
501 if (!value || typeof value !== 'object' || depth > 12) return
502 if (Array.isArray(value)) return void value.forEach(v => harvest(v, out, depth + 1))
503 const o = value as Json
504 if (typeof o.id === 'string' && o.fieldData && typeof o.fieldData === 'object') {
505 for (const f of BODY_FIELDS) if (typeof o.fieldData[f] === 'string') out[`${o.id}:${f}`] = pillCount(o.fieldData[f])
506 }
507 for (const v of Object.values(o)) harvest(v, out, depth + 1)
508}
509
510// The SQL and Bash arguments may arrive at the top level or under `input` (the same two envelopes the
511// Webflow payload is read from). Every present copy is judged and ANY violation denies, so a harmless
512// decoy in one place cannot shield a write in the other. Neither present is judged too (and refused).
513function judged(e: any, field: 'query' | 'command', check: (v: unknown) => string | null): string | null {
514 const copies = [e?.[field], e?.input?.[field]].filter(v => v !== undefined)
515 for (const v of copies.length ? copies : [undefined]) {
516 const hit = check(v)
517 if (hit) return hit
518 }
519 return null
520}
521
522export const register: Register = on => {
523 // Rule 1 ------------------------------------------------------------
524 on('tool.call', { tool: SQL_TOOL }, ($, e: any, next) => {
525 const v = judged(e, 'query', sqlViolation)
526 return v ? { deny: say($, `${v} ${RECEIPT_WHY}`) } : next(e)
527 }).catch(($, e, next) =>
528 next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the SQL guard failed, so this call is refused rather than unchecked.') },
529 )
530
531 // Rules 2 and 3 (Bash) ----------------------------------------------
532 on('tool.call', { tool: 'Bash' }, async ($, e: any, next) => {
533 const v = judged(e, 'command', bashPublishViolation)
534 const cmd = String(e.command ?? e.input?.command ?? '')
535 if (v) {
536 const state = describePassA(await read($, passA))
537 return {
538 deny: say(
539 $,
540 `${v} Publishing is a human click in the Webflow Designer, not a script (LC-1017, same rule as webflow-publish-gate). ` +
541 `${state} ${STALE_NOTE}`,
542 ),
543 }
544 }
545 const ran: any = await next(e)
546 if (ran.deny !== undefined) return ran
547
548 try {
549 // Pass A receipt: exit 0 only (a failed Bash call carries isError). Fresh only when the audit's own
550 // output says so: article_audit.py prints
551 // `receipt written — <slug>: pass_a = PASS (...) -> refresh_runs <run_id>` after the write. The Bash
552 // result's stdout is `ran.result.stdout` (BuiltinToolResults.Bash in the claude-code types).
553 if (
554 PASS_A_CMD.test(cmd) && PASS_A_FLAGS.test(cmd) && WRITE_RECEIPT.test(cmd) &&
555 isSimpleAuditCommand(cmd) && ran.isError !== true
556 ) {
557 const out = String(ran.result?.stdout ?? ran.text ?? '')
558 const m = /^receipt written — (\S+): pass_a = PASS\b.*?refresh_runs (\S+)/m.exec(out)
559 // A pipe (`| tail`) hides the audit's own exit status, so the receipt line is the evidence, and
560 // any failure marker in the same output (a BLOCK finding, a traceback, a failed write) cancels it.
561 if (m && !/::error::|Traceback|RECEIPT WRITE FAILED/.test(out)) {
562 const at = await $.clock.now()
563 await update($, passA, all => ({ ...all, [m[1]]: { slug: m[1], runId: m[2], at, stale: false, staleAt: 0 } }))
564 await paintStatus($)
565 }
566 }
567
568 // v2_s10 handoff: add the freshness note to the result the model reads.
569 if (
570 /write_stage_receipt/.test(cmd) &&
571 (/\bv2_s10\b(?!_)/.test(cmd) || /--stage[ =]10(?:\.0+)?(?=\s|$)/.test(cmd)) &&
572 !/--part[ =]\S*render/.test(cmd)
573 ) {
574 const rows = Object.values(await read($, passA))
575 if (!rows.length || rows.some(r => r.stale)) {
576 return { ...ran, context: [...(ran.context ?? []), say($, `Pass A stale or missing at the publish handoff. ${rows.length ? STALE_NOTE : 'No Pass A receipt was recorded this session. ' + STALE_NOTE}`)] }
577 }
578 }
579 return ran
580 } catch {
581 return bookkeepingFailed($, ran, 'Bash')
582 }
583 }).catch(($, e, next) =>
584 next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the Bash guard failed, so this call is refused rather than unchecked.') },
585 )
586
587 // Rules 2-4 (Webflow MCP) -------------------------------------------
588 on('tool.call', { tool: WEBFLOW_TOOL }, async ($, e: any, next) => {
589 if (actionsOf(e).length === 0) {
590 return { deny: say($, 'this Webflow call carries no `actions` (missing or empty), so its payload cannot be examined.') }
591 }
592 const unknown = unrecognizedKeys(e)
593 if (unknown.length) {
594 return { deny: say($, `unrecognized Webflow action (${unknown.join(', ')}); it is neither a known read nor a known write, so it cannot be examined.`) }
595 }
596 const keys = publishKeys(e)
597 if (keys.length) {
598 return {
599 deny: say(
600 $,
601 `publish-shaped Webflow call (${keys.join(', ')}). Publishing is a human click in the Webflow Designer. ` +
602 `${describePassA(await read($, passA))} ${STALE_NOTE}`,
603 ),
604 }
605 }
606 const verdict = payloadVerdict(e, await read($, pills))
607 if (verdict.deny) return { deny: say($, verdict.deny) }
608
609 const ran: any = await next(e)
610 if (ran.deny !== undefined || ran.isError === true) return ran
611
612 try {
613 if (isWrite(e)) {
614 // The payload cannot be mapped back to one article (Products and Best
615 // Products Data cards are shared), so any item write stales every recorded Pass A.
616 const at = await $.clock.now()
617 await update($, passA, m =>
618 Object.fromEntries(Object.entries(m).map(([k, r]) => [k, { ...r, stale: true, staleAt: at }])),
619 )
620 const written: Record<string, number> = {}
621 for (const a of actionsOf(e)) {
622 for (const w of writes(a)) {
623 if (!w.id || w.collection !== BLOG_COLLECTION) continue
624 for (const f of BODY_FIELDS) if (f in w.fd) written[`${w.id}:${f}`] = pillCount(w.fd[f])
625 }
626 }
627 if (Object.keys(written).length) await update($, pills, m => ({ ...m, ...written }))
628 await paintStatus($)
629 } else {
630 const seen: Record<string, number> = {}
631 for (const v of jsonValues(ran.text)) harvest(v, seen)
632 if (Object.keys(seen).length) await update($, pills, m => ({ ...m, ...seen }))
633 }
634 return ran
635 } catch {
636 return bookkeepingFailed($, ran, 'Webflow')
637 }
638 }).catch(($, e, next) =>
639 next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the Webflow guard failed, so this call is refused rather than unchecked.') },
640 )
641
642 // Asks: the checks the payload alone cannot settle -------------------
643 on('tool.check', { tool: WEBFLOW_TOOL }, async ($, e: any, next) => {
644 if (actionsOf(e.input).length === 0) {
645 return { decision: 'deny', reason: say($, 'this Webflow call carries no `actions` (missing or empty), so its payload cannot be examined.') }
646 }
647 const unknown = unrecognizedKeys(e.input)
648 if (unknown.length) {
649 return { decision: 'deny', reason: say($, `unrecognized Webflow action (${unknown.join(', ')}); it cannot be examined.`) }
650 }
651 const keys = publishKeys(e.input)
652 if (keys.length) {
653 return {
654 decision: 'deny',
655 reason: say(
656 $,
657 `publish-shaped Webflow call (${keys.join(', ')}). Publishing is a human click in the Webflow Designer. ` +
658 `${describePassA(await read($, passA))} ${STALE_NOTE}`,
659 ),
660 }
661 }
662 const verdict = payloadVerdict(e.input, await read($, pills))
663 if (verdict.deny) return { decision: 'deny', reason: say($, verdict.deny) }
664 if (verdict.ask) return { decision: 'ask', reason: say($, verdict.ask) }
665 return next(e)
666 }).catch($ => ({ decision: 'deny' as const, reason: say($, 'the payload check failed, so this call is refused.') }))
667
668 on('session.start', async ($, e, next) => {
669 await paintStatus($)
670 return next(e)
671 })
672}
673hooks/plain.ts 105 lines1// A COPY of run-state's "plain command" test (claude-mods/run-state/hooks/logic.ts, LC-1711/LC-1735):
2// the same code in a plugin of its own, because a mod loads from its own folder and cannot import a
3// sibling's (nor can a test). The CI gate `ci_gates.py parity` compares the block between the markers
4// below in both files and fails the PR if they differ, so a fix made in one cannot silently miss the other.
5//
6// What it is for here (LC-1734): a heredoc BODY that cat/tee writes to a file is data, so a test file
7// that merely quotes the Webflow publish URL must not read as a publish attempt. Everything else about
8// the command, and every command that is not plain, is judged exactly as before.
9
10// PLAIN-COMMAND-BEGIN (keep identical in run-state/hooks/logic.ts and refresh-run-guard/hooks/plain.ts; ci_gates.py parity enforces it)
11// ---------------------------------------------------------------- what counts as "plain"
12//
13// Things are exempt from the receipt guard (LC-1711): a --help / --dry-run / --selftest run of the
14// writer, an echo/printf/cat/tee that only prints or writes text, and the body of a quoted-terminator
15// heredoc that cat/tee writes to a file (receipt code and its tests name the scripts constantly).
16// Each exemption is a claim about what the shell will NOT run, and every shell parser that tries to
17// prove that grows holes (escapes, comments, expansions, quoting forms). So there is no parser to
18// get wrong: an exemption applies ONLY when the whole command is plain AND the segment's first word
19// is on a short allow-list (the writer itself, or echo / printf / cat / tee). Anything else is judged
20// exactly as if no exemption existed. Plain is FAIL CLOSED on every character the code does not model.
21//
22// The text outside a cat/tee quoted-terminator heredoc body (the body is raw data, `'EOF'` stops the
23// shell expanding it) is NOT plain if it contains any of:
24// `#` (comment) `\` (escape) `${` `$'` `$"` a backtick `$(` `>(` `<(`
25// a single `|` a single `&` an unclosed quote a heredoc that was not dropped
26// eval / source / alias / unalias / function / a leading `.` / `name()` / a `PATH=` assignment
27// bash sh zsh python python3 node with -c, `-` or any `<` after it (code read from stdin)
28// A command with a comment or a backslash simply gets no exemption: a few false positives are fine.
29// What is left is a command with no escapes, comments or expansions, where quotes just pair up, so
30// the one pass below (quotes only) is exact for it.
31
32type ShellScan = { closed: boolean; heredocs: number; drops: { from: number; to: number }[] }
33
34const HEREDOC_OP = /^(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/
35
36// One pass over the command with the quote state carried across lines. Heredoc bodies are raw text,
37// so they are skipped (never scanned for quotes). Only an operator outside quotes counts as a
38// heredoc. Anything that would make this pass inexact (an escape, a comment) also makes the command
39// not plain, so a wrong drop can never survive into a plain verdict.
40function scanShell(command: string): ShellScan {
41 const lines = command.split('\n')
42 const scan: ShellScan = { closed: true, heredocs: 0, drops: [] }
43 let q = ''
44 for (let i = 0; i < lines.length; i++) {
45 const line = lines[i]
46 let pending: { term: string; dash: boolean; quoted: boolean; consumer: string } | null = null
47 for (let j = 0; j < line.length; j++) {
48 const c = line[j]
49 if (q) { if (c === q) q = ''; continue }
50 if (c === "'" || c === '"') { q = c; continue }
51 if (c === '<' && line[j + 1] === '<') {
52 if (line[j + 2] === '<') { j += 2; continue } // a here-string, not a heredoc
53 const m = HEREDOC_OP.exec(line.slice(j + 2))
54 if (m && !pending) {
55 const before = line.slice(0, j).split(/&&|\|\||;|\|/).pop() ?? ''
56 const word = (before.trim().split(/\s+/)[0] ?? '').split('/').pop() ?? ''
57 pending = { term: m[3], dash: m[1] === '-', quoted: m[2] !== '', consumer: word }
58 scan.heredocs += 1
59 }
60 j += 1
61 }
62 }
63 if (pending) {
64 const p = pending
65 const end = lines.findIndex((l, k) => k > i && (p.dash ? l.replace(/^\t+/, '') : l).trimEnd() === p.term)
66 if (end < 0) continue // unterminated: nothing is skipped or dropped, the rest is scanned as commands
67 if (p.quoted && (p.consumer === 'cat' || p.consumer === 'tee')) scan.drops.push({ from: i + 1, to: end })
68 i = end // the body is raw text for the scan whether or not it is dropped
69 }
70 }
71 scan.closed = q === ''
72 return scan
73}
74
75// The command with every cat/tee quoted-terminator heredoc body removed.
76function dropHeredocBodies(command: string, scan: ShellScan): string {
77 if (scan.drops.length === 0) return command
78 const skip = new Set<number>()
79 for (const d of scan.drops) for (let k = d.from; k <= d.to; k++) skip.add(k)
80 return command.split('\n').filter((_, k) => !skip.has(k)).join('\n')
81}
82
83function looksPlain(text: string, scan: ShellScan): boolean {
84 if (!scan.closed) return false
85 if (scan.heredocs !== scan.drops.length) return false // a heredoc whose body is still in the command
86 if (/[#\\`]|\$[{('"]|[<>]\(/.test(text)) return false // comment, escape, backtick, ${ $( $' $", >( <(
87 if (/(^|[^|])\|([^|]|$)/.test(text)) return false // a single | (|| is fine)
88 if (/(^|[^&>])&([^&>]|$)/.test(text.replace(/[0-9]?>&[0-9]/g, ''))) return false // a single & (not && or fd dup)
89 if (/\b(eval|source|alias|unalias|function)\b/.test(text)) return false
90 if (/(^|[;&|\n(])\s*\.\s/.test(text)) return false
91 if (/[\w.-]+\s*\(\s*\)/.test(text)) return false // name() { ... }
92 if (/\bPATH=/.test(text)) return false
93 // An interpreter word (also as /bin/bash), not a file name that merely ends in .sh.
94 if (/(^|[\s;&|(/])(bash|sh|zsh|python3?|node)(?=\s|$)[^;&|\n]*(\s-\w*c\b|\s-(\s|$)|<)/.test(text)) return false
95 return true
96}
97
98// The command text to scan, and whether exemptions may apply to it.
99export function plainView(command: string): { text: string; plain: boolean } {
100 const scan = scanShell(command)
101 const dropped = dropHeredocBodies(command, scan)
102 return { text: dropped, plain: looksPlain(dropped, scan) }
103}
104// PLAIN-COMMAND-END
105types/index.d.ts 20 lines1export type PassAEntry = {
2 slug: string
3 runId: string
4 /** ms epoch of the exit-0 `article_audit.py --pass a --write-receipt` */
5 at: number
6 stale: boolean
7 /** ms epoch of the Webflow item write that invalidated it; 0 while fresh */
8 staleAt: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'refresh-run-guard': {
14 passA: Record<string, PassAEntry>
15 /** Webflow item id -> count of lc-deal-pill spans seen in its live post-body */
16 pills: Record<string, number>
17 }
18 }
19}
20