SLOPSHOPPER

design-guardrail

Blocks Write and Edit on UI files that hardcode hex/rgb/hsl colors or px spacing and font values, and names the design token to use instead.

newguardtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · design-guardrail
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ design-guardrail │ ⏺ Read(src/auth.ts) │ design-guardrail: no tokens file at │ ⎿ Read 6 lines │ design-tokens.json; set tokensFile to turn │ ⏺ Update(src/auth.ts) │ the guard on │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ design-guardrail: 🎨 design-guardrail idle: no tokens file at design-tokens.json
README

guardrail-mods

Four Claude Code mods that hold Claude to your project's rules while it works. Each mod is its own plugin under mods/, written in TypeScript, and the repository is a plugin marketplace named guardrail-mods.

ModWhat it doesEvent it hooks
wcag-enforcerAfter a Write, Edit or shell command changes a .tsx, .jsx, .html, .vue or .css file, runs automated WCAG checks (eslint-plugin-jsx-a11y, axe-core, and a WCAG 2.2 AA text contrast check) and hands Claude each failure with file, line and ruletool.call (after Write, Edit and Bash run), classic.Stop
design-guardrailBefore a Write or Edit to a UI file, blocks hardcoded hex/rgb/hsl colors and px spacing and font values, naming the design token to use; refuses shell commands that write UI files and checks what other commands changedtool.call (before Write and Edit, around Bash)
qa-gateWhen Claude tries to finish a turn after changing code (by any tool, the shell included), runs the lint, type check, spell check and test scripts in package.json and keeps Claude working until they passtool.call, classic.Stop
secret-scrubberRedacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, with a running count in the status linesession.append (tool results), prompt.attachment, prompt.mention, tool.call

Built and type-checked against the declarations of Claude Code 2.1.296, using only events and APIs that build declares. Mods need Claude Code 2.1.287 or later; qa-gate's package manager and scope pickers need 2.1.271 or later.

What wcag-enforcer is, and isn't. It runs automated WCAG checks on the code Claude writes. That is not a WCAG compliance audit: static checks like these catch only part of WCAG (often put at around a third of issues), and nothing here tests keyboard operation, focus order, screen reader output or how content reads in context. Keep testing those by hand and with assistive technology.

Install

Mods are code that runs with your permissions. Read them first: claude plugin validate mods/<mod> lists every event a mod hooks and every call it makes.

In a Claude Code session in your terminal, add the marketplace once and install the mods you want:

/plugin marketplace add WasteOfADrumBum/one-small-mods-pack
/plugin install wcag-enforcer@guardrail-mods
/plugin install design-guardrail@guardrail-mods
/plugin install qa-gate@guardrail-mods
/plugin install secret-scrubber@guardrail-mods

Or install one mod in a single line, answering y to add the marketplace and then picking a scope:

/plugin install secret-scrubber --marketplace WasteOfADrumBum/one-small-mods-pack

From your shell instead: claude plugin marketplace add WasteOfADrumBum/one-small-mods-pack, then claude plugin install <mod>@guardrail-mods, then /reload-plugins in any open session.

To try a mod from a clone without installing it, start Claude Code with claude --plugin-dir ./mods/<mod> (repeat the flag for several).

What each mod needs in your project

ModNeeds
wcag-enforcerFor components: ESLint with eslint-plugin-jsx-a11y (or eslint-plugin-vuejs-accessibility for .vue) turned on in your ESLint config. For HTML: npm i -D axe-core jsdom. The contrast check needs nothing. A check whose tool is missing is skipped, and the status line keeps saying so (♿ eslint not running) for as long as it is, so it never reads as a pass.
design-guardrailA tokens file, design-tokens.json by default. Without one it stands aside, and the status line says so (🎨 design-guardrail idle: no tokens file at design-tokens.json).
qa-gateA package.json with any of the scripts it looks for. The status line names the checks that have no script (✅ qa-gate: 2/2 passed · no spellcheck, test script).
secret-scrubberNothing.

Configure

Every mod has two layers of settings, and the second wins:

  1. Plugin options, set in /config or when you install, stored under pluginConfigs in your settings: enabled (on/off), configFile (the path of layer 2), and each mod's own path options (tokensFile, packageJson, patternsFile, ...).
  2. A project config file, .claude/guardrail-mods/<mod>.json by default, checked in with the project so the whole team gets the same rules. It takes every option plus the lists. Each mod's config.example.json shows every key.
// .claude/guardrail-mods/design-guardrail.json
{ "tokensFile": "src/styles/tokens.css", "ignore": ["**/*.stories.tsx"] }
ModOptions (/config)Extra keys in the project file
wcag-enforcerenabled, configFile, extensions, eslintCommand, blockStopUntilFixedeslint, eslintRulePrefixes, axe, nodeCommand, contrast, watchBash, timeoutMs
design-guardrailenabled, configFile, tokensFile, extensions, ignore, allowallowSvgColors, blockBashWrites, watchBash, maxReported
qa-gateenabled, configFile, packageJson, packageManager, maxAttempts, scope, timeoutSecondschecks, scripts, codeExtensions, testArgs, watchBash
secret-scrubberenabled, configFile, patternsFile, showToastdisabledRules, patterns

Demos

wcag-enforcer

Claude writes Card.tsx with and note.css with .note { color: #aaaaaa; background: #ffffff; }. Each tool result comes back with a note Claude reads before its next step:

wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in src/Card.tsx. Fix every one before moving on:
- src/Card.tsx:3:5 [jsx-a11y/alt-text] img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.

wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in note.css. Fix every one before moving on:
- note.css:1 [wcag/contrast-minimum] text #aaaaaa on #ffffff has contrast 2.32:1; WCAG 2.2 AA (1.4.3) needs 4.5:1

The status line shows ♿ 2 WCAG failures in 2 files until they're fixed. If Claude tries to finish with failures still open, the mod sends it back with the list (twice at most, then it lets the turn end with a toast). In a test run, Claude changed the color to #767676 (4.54:1) on its own.

Files changed through the shell are checked too: the ones a command names as written (redirects, tee, sed -i, cp/mv targets, curl -o) and, in a git repository, any UI file whose change time moved while the command ran, so a code generator's output is covered. Set watchBash to false to skip that.

HTML files go through axe-core in jsdom (mods/wcag-enforcer/scripts/axe-check.mjs), reported as [axe/image-alt], [axe/html-has-lang] and so on, with line numbers. jsdom does no layout, so axe's own contrast rule is off and the mod's contrast check covers it: literal color pairs in CSS rules, style="" attributes and JSX style objects, with var(--x) resolved when the file defines --x, and 3:1 for large text.

design-guardrail

With this design-tokens.json:

{ "color": { "brand": { "$value": "#3366ff" } }, "space": { "3": "12px" } }

Claude tries to write .card { color: #3366ff; padding: 13px; }. The Write is refused, and Claude reads:

design-guardrail: this Write to src/card.css hardcodes 2 values that should be design tokens (from design-tokens.json):
- line 1: `#3366ff` → use `color.brand` (`var(--color-brand)`).
- line 1: padding `13px` is not a token value → use the nearest token, `space.3` (`var(--space-3)`) = 12px.
Replace each with the token named and retry. A value that truly must stay can be kept with a `guardrail-ignore` comment on its line.

It retries with .card { color: var(--color-brand); padding: var(--space-3); }. The tokens file can be JSON (nested, W3C $value or Style Dictionary value leaves) or a CSS, SCSS or Less file of variables. It catches hex, rgb(), hsl(), px in spacing and font properties, React style numbers (marginTop: 12) and Tailwind arbitrary values (p-[10px], text-[#f00]). An Edit is judged only on the values it adds, so legacy values already in a file don't block unrelated edits.

What it leaves alone, each covered by a test: borders and other properties outside spacing and type (border: 1px solid ..., box-shadow, width: 24px), 0 and 0px, media query breakpoints (@media (min-width: 768px)), SVG attributes (width="24", stroke-width, and paint colors like fill="#111827" unless allowSvgColors is false), var() references and their fallbacks, token definitions themselves, and any line marked guardrail-ignore. Add values to allow (such as 1px) to let them through everywhere.

The shell can't sidestep it. A command that writes a UI file directly (echo ... > a.css, tee, sed -i, perl -pi, curl -o) is refused with a note to use Write or Edit. Any other command that changes a UI file (a generator script, say) runs, and in a git repository the values it added since the last commit come back to Claude as a note to fix with Edit. blockBashWrites and watchBash turn those off.

qa-gate

Claude edits src/a.ts and tries to finish. The gate runs npm run --silent lint, typecheck, spellcheck and test (whichever exist; npm's placeholder test is skipped) with CI=true, and when one fails Claude reads:

qa-gate: 1 check failed after your code changes. Fix the errors below, then finish:

### typecheck (`npm run --silent typecheck` exited 2)

src/a.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.

The status line tracks it: 🧪 qa-gate: running typecheck…, then ❌ qa-gate: typecheck failed or ✅ qa-gate: 3/3 passed. A turn that changed no code is never held, and code changed through the shell counts (what a command names as written, and in a git repository what changed while it ran). After maxAttempts holds in a row (3 by default) it lets the turn end with a toast. pnpm, yarn and bun are picked from the lockfile.

For bigger repositories:

  • timeoutSeconds (300 by default) stops a script that runs too long and tells Claude it timed out.
  • scope: "changed" passes only the files Claude changed to the lint and spell check scripts (npm run --silent lint -- src/a.ts). Tests get them too once you set testArgs to your runner's flag, such as ["--findRelatedTests"] for Jest; otherwise the full suite runs. The type check always runs on the whole project, since it can't be judged file by file.

secret-scrubber

Claude reads .env.local (with Read, cat, or an @.env.local mention in your prompt), then runs a script that prints a token. What Claude reads is:

PORT=[REDACTED:env-value]
DATABASE_URL=[REDACTED:env-value]
Using token [REDACTED:github-token] for api.github.com

The status line keeps a running count (🔒 3 secrets redacted) and a toast says what was caught. It covers private key blocks, JWTs, GitHub, Google (API keys, OAuth tokens, client secrets), AWS (key IDs and secret keys), Vercel, Todoist, Slack, Stripe, Anthropic and OpenAI keys, Bearer tokens, credentials in URLs, every value of a .env file Claude reads, and generic password=, api_key:, "secret": "..." pairs whose value looks like a secret (code like token = getToken() is left alone). Add your own with patterns.

Where it scrubs: every tool result before it's stored (in the main conversation and in every subagent's, so a subagent never reads the secret and its report back to Claude is scrubbed too), and every attachment as a request carries it, which covers files you @-mention, files changed outside Claude that it's shown, and settings hook output. Your terminal still shows the real output; only what Claude reads is scrubbed. If the scan itself fails, the output is withheld from Claude rather than passed through.

How they fail

  • design-guardrail fails open: if the guard itself errors, the edit goes ahead.
  • secret-scrubber fails closed: if scrubbing errors, Claude gets a placeholder instead of the output.
  • wcag-enforcer and qa-gate never hold a turn forever (two and maxAttempts holds in a row).

Develop

npm run types       # copy your Claude Code build's type declarations into types/ (see below)
npm run typecheck   # tsc against them
npm run validate    # claude plugin validate on the marketplace and each mod
npm test            # claude plugin test in each mod

The type declarations (claude-code.d.ts) are Anthropic's and are not redistributed here (Claude Code's license reserves all rights), so types/ is git-ignored. Claude Code writes them for your build beside a mod it loads from a folder it watches: start claude --plugin-dir mods/qa-gate once, then run npm run types, which copies them from mods/*/.claude-plugin/types/ into types/claude-code.d.ts. You can also pass a path: node scripts/link-types.mjs /path/to/claude-code.d.ts.

Each mod's tests live in mods/<mod>/tests/ and use claude-code/testing: they raise the events the mod hooks and stub every file, process and display call beneath it, covering pass and fail cases. claude plugin validate and claude plugin test need no types.

License

MIT

Source 5 files
hooks/register.ts 224 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { bashWrites, changedFiles, porcelainPaths } from './bash-writes'
4import { asBool, asList, asNumber, asString, parseConfigText, pick } from './config'
5import type { RawConfig } from './config'
6import { explain, findViolations, newViolations } from './scan'
7import { parseTokens } from './tokens'
8import type { Token } from './tokens'
9
10type Settings = {
11  enabled: boolean
12  tokensFile: string
13  extensions: string[]
14  ignore: string[]
15  allow: string[]
16  maxReported: number
17  allowSvgColors: boolean
18  blockBashWrites: boolean
19  watchBash: boolean
20}
21
22const DEFAULT_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.tsx', '.jsx', '.vue', '.html', '.svelte', '.astro']
23
24/** Reads a JSON config file of the project; a missing or broken one is empty. */
25async function readConfigFile($: EngineInterface, path: string): Promise<RawConfig> {
26  if (path.trim() === '' || !(await $.fs.exists(path))) return {}
27  const parsed = parseConfigText(path, await $.fs.read(path))
28  if (typeof parsed === 'string') {
29    $.ui.log(parsed)
30    return {}
31  }
32  return parsed
33}
34
35async function load($: EngineInterface, options: PluginOptions): Promise<Settings> {
36  const configFile = asString(options.configFile) ?? '.claude/guardrail-mods/design-guardrail.json'
37  const file = await readConfigFile($, configFile)
38  return {
39    enabled: pick(file, options, 'enabled', true, asBool),
40    tokensFile: pick(file, options, 'tokensFile', 'design-tokens.json', asString),
41    extensions: pick(file, options, 'extensions', DEFAULT_EXTENSIONS, asList).map(x => x.toLowerCase()),
42    ignore: pick(file, options, 'ignore', [] as string[], asList),
43    allow: pick(file, options, 'allow', ['0', '0px'], asList),
44    maxReported: pick(file, options, 'maxReported', 20, asNumber),
45    allowSvgColors: pick(file, options, 'allowSvgColors', true, asBool),
46    blockBashWrites: pick(file, options, 'blockBashWrites', true, asBool),
47    watchBash: pick(file, options, 'watchBash', true, asBool),
48  }
49}
50
51/** A simple glob (`*`, `**`) as a regular expression over a path. */
52const globToRegExp = (glob: string) =>
53  new RegExp(
54    '(^|/)' +
55      glob
56        .replace(/[.+^${}()|[\]\\]/g, '\\$&')
57        .replace(/\*\*\/?/g, '\u0000')
58        .replace(/\*/g, '[^/]*')
59        .replace(/\u0000/g, '.*') +
60      '$',
61  )
62
63const normalizePath = (path: string) => path.replace(/\\/g, '/')
64
65/** Whether the guard looks at this path at all. */
66function isGuarded(settings: Settings, file: string): boolean {
67  const path = normalizePath(file)
68  const lower = path.toLowerCase()
69  if (!settings.extensions.some(ext => lower.endsWith(ext))) return false
70  if (settings.ignore.some(glob => globToRegExp(glob).test(path))) return false
71  return !path.endsWith(normalizePath(settings.tokensFile).replace(/^\.\//, ''))
72}
73
74let warnedMissingTokens = false
75
76/** The project's tokens, or undefined when the tokens file is missing or unreadable. */
77async function readTokens($: EngineInterface, path: string): Promise<Token[] | undefined> {
78  if (!(await $.fs.exists(path))) {
79    // Said in the status line for as long as it holds, so an idle guard never passes for a clean one.
80    $.ui.status(`🎨 design-guardrail idle: no tokens file at ${path}`)
81    if (!warnedMissingTokens) {
82      warnedMissingTokens = true
83      $.ui.toast(`design-guardrail: no tokens file at ${path}; set tokensFile to turn the guard on`)
84    }
85    return undefined
86  }
87  try {
88    return parseTokens(path, await $.fs.read(path))
89  } catch (error) {
90    $.ui.status(`🎨 design-guardrail idle: ${path} could not be read`)
91    $.ui.log(`could not read tokens from ${path}: ${error instanceof Error ? error.message : String(error)}`)
92    return undefined
93  }
94}
95
96/** The file's current text, or '' when it does not exist yet. */
97async function currentText($: EngineInterface, path: string): Promise<string> {
98  return (await $.fs.exists(path)) ? $.fs.read(path) : ''
99}
100
101// The repository root, once found; null outside a git repository.
102let gitRoot: string | null | undefined
103
104/** Files with uncommitted changes that pass `keep`, by absolute path, stamped with mtime and size; undefined outside git. */
105async function dirtyFiles($: EngineInterface, keep: (path: string) => boolean): Promise<Map<string, string> | undefined> {
106  if (gitRoot === undefined) {
107    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 10_000 }).catch(() => undefined)
108    gitRoot = top !== undefined && top.exitCode === 0 ? top.stdout.trim() : null
109  }
110  if (gitRoot === null) return undefined
111  const status = await $.process.run(['git', 'status', '--porcelain', '-z', '--untracked-files=all'], { timeoutMs: 10_000 }).catch(() => undefined)
112  if (status === undefined || status.exitCode !== 0) return undefined
113  const files = new Map<string, string>()
114  for (const rel of porcelainPaths(status.stdout).filter(keep).slice(0, 500)) {
115    const path = `${gitRoot}/${rel}`
116    const stat = await $.fs.stat(path).catch(() => undefined)
117    if (stat !== undefined && stat.kind === 'file') files.set(path, `${stat.mtimeMs}:${stat.size}`)
118  }
119  return files
120}
121
122/** The file as last committed, or '' when it is new; values already there are not the edit's doing. */
123async function committedText($: EngineInterface, path: string): Promise<string> {
124  if (gitRoot === undefined || gitRoot === null || !path.startsWith(gitRoot + '/')) return ''
125  const shown = await $.process.run(['git', 'show', `HEAD:${path.slice(gitRoot.length + 1)}`], { timeoutMs: 10_000 }).catch(() => undefined)
126  return shown !== undefined && shown.exitCode === 0 ? shown.stdout : ''
127}
128
129const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
130
131export const register: Register = (on, options) => {
132  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
133    if (e.tool !== 'Write' && e.tool !== 'Edit') return next(e)
134    const settings = await load($, options)
135    if (!settings.enabled || !isGuarded(settings, e.file_path)) return next(e)
136
137    const tokens = await readTokens($, settings.tokensFile)
138    if (tokens === undefined) return next(e)
139
140    const path = normalizePath(e.file_path)
141    const existing = await currentText($, e.file_path)
142    let added: string
143    let before: string
144    let lineOffset = 0
145    if (e.tool === 'Write') {
146      added = e.content
147      before = existing
148    } else {
149      added = e.new_string
150      before = e.old_string
151      const at = existing.indexOf(e.old_string)
152      if (at >= 0) lineOffset = existing.slice(0, at).split('\n').length - 1
153    }
154
155    const scan = { allow: settings.allow, allowSvgColors: settings.allowSvgColors }
156    const violations = newViolations(findViolations(added, path, scan), findViolations(before, path, scan))
157    if (violations.length === 0) {
158      $.ui.status(undefined)
159      return next(e)
160    }
161
162    const shown = violations.slice(0, settings.maxReported)
163    const more = violations.length - shown.length
164    $.ui.status(`🎨 design-guardrail blocked ${plural(violations.length, 'hardcoded value')}`)
165    return {
166      deny: [
167        `design-guardrail: this ${e.tool} to ${e.file_path} hardcodes ${plural(violations.length, 'value')} that should be design tokens (from ${settings.tokensFile}):`,
168        ...shown.map(v => explain(v, tokens, lineOffset)),
169        ...(more > 0 ? [`- …and ${more} more.`] : []),
170        'Replace each with the token named and retry. A value that truly must stay can be kept with a `guardrail-ignore` comment on its line.',
171      ].join('\n'),
172    }
173  })
174
175  // A UI file written through the shell skips the Write/Edit guard above:
176  // refuse the commands that plainly write one, and check what any other
177  // command changed once it has run.
178  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
179    const settings = await load($, options)
180    if (!settings.enabled) return next(e)
181
182    const named = bashWrites(e.command).filter(w => isGuarded(settings, w.path))
183    const writing = named.filter(w => w.via !== 'copy')
184    if (settings.blockBashWrites && writing.length > 0) {
185      $.ui.status('🎨 design-guardrail blocked a shell write to a UI file')
186      return {
187        deny:
188          `design-guardrail: this command writes ${writing.map(w => w.path).join(', ')} from the shell, which skips the design token check. ` +
189          'Make the change with the Write or Edit tool instead.',
190      }
191    }
192    if (!settings.watchBash) return next(e)
193
194    const tokens = await readTokens($, settings.tokensFile)
195    if (tokens === undefined) return next(e)
196
197    const guarded = (rel: string) => isGuarded(settings, rel)
198    const before = await dirtyFiles($, guarded)
199    const ran = await next(e)
200    if (ran.deny !== undefined || before === undefined) return ran
201    const after = await dirtyFiles($, guarded)
202    if (after === undefined) return ran
203
204    const notes: string[] = []
205    for (const path of changedFiles(before, after)) {
206      const scan = { allow: settings.allow, allowSvgColors: settings.allowSvgColors }
207      const violations = newViolations(
208        findViolations(await $.fs.read(path), path, scan),
209        findViolations(await committedText($, path), path, scan),
210      )
211      if (violations.length === 0) continue
212      notes.push(
213        [
214          `design-guardrail: that command changed ${path}, which now hardcodes ${plural(violations.length, 'value')} that should be design tokens (from ${settings.tokensFile}). Fix them with the Edit tool:`,
215          ...violations.slice(0, settings.maxReported).map(v => explain(v, tokens)),
216        ].join('\n'),
217      )
218    }
219    if (notes.length === 0) return ran
220    $.ui.status(`🎨 design-guardrail: a shell command left hardcoded values in ${plural(notes.length, 'file')}`)
221    return { ...ran, context: [...(ran.context ?? []), ...notes] }
222  })
223}
224
hooks/bash-writes.ts 172 lines
1// The files a shell command writes, read from the command itself: redirects,
2// tee, in-place sed/perl, cp/mv/install destinations, dd of=, curl -o and
3// wget -O. A best-effort reading (a script the command runs can write
4// anything), so each mod also compares the working tree before and after.
5// Shared by the guardrail mods; each plugin carries its own copy.
6
7export type BashWrite = {
8  path: string
9  /** How the command writes it: new text (`redirect`, `tee`, `in-place`, `download`) or a copy (`copy`). */
10  via: 'redirect' | 'tee' | 'in-place' | 'copy' | 'download'
11}
12
13/** Splits a command into words, honoring quotes and backslashes; operators are their own words. */
14export function shellWords(command: string): string[] {
15  const words: string[] = []
16  let word = ''
17  let hasWord = false
18  let quote: '"' | "'" | undefined
19  const push = () => {
20    if (hasWord) words.push(word)
21    word = ''
22    hasWord = false
23  }
24  for (let i = 0; i < command.length; i++) {
25    const c = command[i]!
26    if (quote !== undefined) {
27      if (c === quote) quote = undefined
28      else if (c === '\\' && quote === '"' && i + 1 < command.length) word += command[++i]
29      else word += c
30      continue
31    }
32    if (c === '"' || c === "'") {
33      quote = c
34      hasWord = true
35    } else if (c === '\\' && i + 1 < command.length) {
36      word += command[++i]
37      hasWord = true
38    } else if (/\s/.test(c)) {
39      push()
40      if (c === '\n') words.push(';')
41    } else if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')') {
42      push()
43      const pair = c + (command[i + 1] ?? '')
44      if (pair === '&&' || pair === '||' || pair === '>|') {
45        words.push(pair)
46        i++
47      } else if (c === '&' && command[i + 1] === '>') {
48        words.push('&>')
49        i++
50      } else words.push(c)
51    } else if (c === '>' || c === '<') {
52      // A leading fd number (2>) belongs to the operator, not the word before it.
53      const fd = /^\d$/.test(word) ? word : ''
54      if (fd === '') push()
55      else {
56        word = ''
57        hasWord = false
58      }
59      let op = fd + c
60      while (command[i + 1] === '>' || command[i + 1] === '&' || (c === '<' && command[i + 1] === '<')) op += command[++i]
61      words.push(op)
62    } else {
63      word += c
64      hasWord = true
65    }
66  }
67  push()
68  return words
69}
70
71const SEPARATORS = new Set([';', '&&', '||', '|', '&', '(', ')'])
72const NOT_A_FILE = /^(\/dev\/|&\d|-$)/
73
74const isRedirect = (word: string) => /^(\d|&)?>{1,2}\|?$/.test(word)
75
76/** The files the command writes, by how it writes them. */
77export function bashWrites(command: string): BashWrite[] {
78  // A heredoc's body is data, not shell: keep its opening line, drop the body.
79  const shell = command.replace(/(<<-?\s*(['"]?)([\w-]+)\2[^\n]*)\n[\s\S]*?\n\s*\3\s*(?=\n|$)/g, '$1')
80  const words = shellWords(shell)
81  const writes: BashWrite[] = []
82  const add = (path: string | undefined, via: BashWrite['via']) => {
83    if (path !== undefined && path !== '' && !NOT_A_FILE.test(path) && !SEPARATORS.has(path) && !isRedirect(path)) writes.push({ path, via })
84  }
85
86  // Redirects anywhere in the command
87  words.forEach((word, i) => {
88    if (isRedirect(word)) add(words[i + 1], 'redirect')
89  })
90
91  // Each simple command: its program and arguments, redirects left out
92  const segments: string[][] = [[]]
93  for (let i = 0; i < words.length; i++) {
94    const word = words[i]!
95    if (SEPARATORS.has(word)) segments.push([])
96    else if (isRedirect(word) || /^\d?<{1,3}$/.test(word)) i++
97    else segments.at(-1)!.push(word)
98  }
99
100  for (const segment of segments) {
101    // Skip env assignments and wrappers: FOO=1 sudo sed -i ...
102    let start = 0
103    while (start < segment.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[start]!) || ['sudo', 'command', 'env', 'xargs', 'time', 'nohup'].includes(segment[start]!))) start++
104    const [program = '', ...args] = segment.slice(start)
105    const name = program.split('/').at(-1) ?? program
106    const operands = args.filter(a => !a.startsWith('-'))
107
108    if (name === 'tee') {
109      for (const file of operands) add(file, 'tee')
110    } else if (name === 'sed' || name === 'gsed') {
111      if (args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place'))) {
112        // The first operand is the script, unless one was given with -e/-f.
113        const hasScriptOption = args.some(a => a === '-e' || a === '-f' || a.startsWith('--expression') || a.startsWith('--file'))
114        const scriptArgs = new Set<string>()
115        args.forEach((a, i) => {
116          if ((a === '-e' || a === '-f') && args[i + 1] !== undefined) scriptArgs.add(args[i + 1]!)
117        })
118        // macOS spells no backup suffix as a separate '' after -i
119        const files = operands.filter(a => a !== '' && !scriptArgs.has(a))
120        for (const file of hasScriptOption ? files : files.slice(1)) add(file, 'in-place')
121      }
122    } else if (name === 'perl') {
123      if (args.some(a => /^-[a-zA-Z]*i/.test(a))) {
124        const scriptArgs = new Set<string>()
125        args.forEach((a, i) => {
126          if (/^-[a-zA-Z]*e$/.test(a) && args[i + 1] !== undefined) scriptArgs.add(args[i + 1]!)
127        })
128        for (const file of operands.filter(a => !scriptArgs.has(a))) add(file, 'in-place')
129      }
130    } else if (name === 'cp' || name === 'mv' || name === 'install' || name === 'rsync') {
131      const target = args.findIndex(a => a === '-t' || a === '--target-directory')
132      add(target >= 0 ? args[target + 1] : operands.at(-1), 'copy')
133    } else if (name === 'dd') {
134      for (const a of args) if (a.startsWith('of=')) add(a.slice(3), 'redirect')
135    } else if (name === 'curl' || name === 'wget') {
136      args.forEach((a, i) => {
137        if ((name === 'curl' && (a === '-o' || a === '--output')) || (name === 'wget' && (a === '-O' || a === '--output-document'))) {
138          add(args[i + 1], 'download')
139        }
140      })
141    }
142  }
143
144  const seen = new Set<string>()
145  return writes.filter(w => {
146    const key = `${w.via}:${w.path}`
147    if (seen.has(key)) return false
148    seen.add(key)
149    return true
150  })
151}
152
153/** Parses `git status --porcelain -z`: the paths with changes or untracked, relative to the repository root. */
154export function porcelainPaths(output: string): string[] {
155  const entries = output.split('\0')
156  const paths: string[] = []
157  for (let i = 0; i < entries.length; i++) {
158    const entry = entries[i]!
159    if (entry.length < 4) continue
160    const status = entry.slice(0, 2)
161    paths.push(entry.slice(3))
162    // A rename or copy is followed by its old path, which is gone.
163    if (status.includes('R') || status.includes('C')) i++
164  }
165  return paths
166}
167
168/** The files whose change time moved between two snapshots, or that only the second has. */
169export function changedFiles(before: ReadonlyMap<string, string>, after: ReadonlyMap<string, string>): string[] {
170  return [...after].filter(([path, mtime]) => before.get(path) !== mtime).map(([path]) => path)
171}
172
hooks/config.ts 53 lines
1// Shared by every guardrail mod (each plugin carries its own copy, since a
2// plugin may only import its own files). Settings are layered:
3//   1. the mod's built-in defaults,
4//   2. the plugin's userConfig options (/config, or pluginConfigs in settings),
5//   3. the project's JSON config file, when it exists.
6// Reading the file needs `$`, which never crosses an import, so each
7// register.ts reads it and hands the text to `parseConfigText`.
8import type { PluginOptions } from 'claude-code'
9
10export type RawConfig = Record<string, unknown>
11
12const isRecord = (value: unknown): value is RawConfig =>
13  typeof value === 'object' && value !== null && !Array.isArray(value)
14
15/** Parses a config file's text: an object, or an error message to log. */
16export function parseConfigText(path: string, text: string): RawConfig | string {
17  try {
18    const parsed: unknown = JSON.parse(text)
19    return isRecord(parsed) ? parsed : `${path} is not a JSON object; using the defaults`
20  } catch (error) {
21    return `could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`
22  }
23}
24
25/** The first defined value among the file's, the option's and the default. */
26export function pick<T>(
27  file: RawConfig,
28  options: PluginOptions,
29  key: string,
30  fallback: T,
31  read: (value: unknown) => T | undefined,
32): T {
33  return read(file[key]) ?? read(options[key]) ?? fallback
34}
35
36export const asBool = (value: unknown): boolean | undefined =>
37  typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : undefined
38
39export const asString = (value: unknown): string | undefined =>
40  typeof value === 'string' ? value : undefined
41
42export const asNumber = (value: unknown): number | undefined =>
43  typeof value === 'number' && Number.isFinite(value) ? value : undefined
44
45/** A list from a JSON array, a `multiple` option, or a comma-separated string. */
46export const asList = (value: unknown): string[] | undefined => {
47  if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
48  if (typeof value === 'string' && value.trim() !== '') {
49    return value.split(',').map(item => item.trim()).filter(item => item !== '')
50  }
51  return undefined
52}
53
hooks/scan.ts 137 lines
1// Finds hardcoded colors, px spacing and px font values in UI source, and
2// words the message that names the token to use instead.
3import { normalizeColor, normalizeLength, suggest } from './tokens'
4import type { Token, TokenKind } from './tokens'
5
6export type Violation = {
7  /** 1-based, in the text scanned. */
8  line: number
9  value: string
10  kind: TokenKind
11  /** The CSS property or style key it was set on, when known. */
12  property?: string
13  group: Token['group']
14}
15
16const STYLESHEET = /\.(css|scss|sass|less|pcss|postcss|styl)$/i
17
18export const isStylesheet = (path: string) => STYLESHEET.test(path)
19
20const IGNORE = /(design-)?guardrail-ignore/
21
22const HEX = /(?<![\w&#])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/g
23const COLOR_FN = /\b(?:rgba?|hsla?)\([^()]*\)/g
24// An anchor, an id or a link, not a color: href="#fade", to="#add"
25const NOT_A_COLOR_BEFORE = /(?:href|to|id|for|xlink:href|url)\s*[=(]\s*["'`{]?\s*$/
26
27// An SVG paint attribute: fill="#000", stroke='rgb(0 0 0)', stopColor="#fff"
28const SVG_PAINT_BEFORE = /(?:fill|stroke|stop-color|stopColor|flood-color|floodColor|lighting-color|lightingColor)\s*=\s*\{?\s*["'`]\s*$/
29
30const FONT_PROPS = /^(font|font-size|line-height|letter-spacing|fontSize|lineHeight|letterSpacing|text|leading|tracking)$/
31const CSS_LENGTH_DECL =
32  /(?<![\w-])(margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?|gap|row-gap|column-gap|inset(?:-[a-z-]+)?|top|right|bottom|left|font|font-size|line-height|letter-spacing)\s*:\s*([^;{}]+)/g
33const PX = /(?<![\w.-])-?\d*\.?\d+px\b/g
34const JS_STYLE_LENGTH =
35  /\b(margin\w*|padding\w*|gap|rowGap|columnGap|inset\w*|top|right|bottom|left|fontSize|lineHeight|letterSpacing)\s*:\s*(?:(['"`])(-?\d*\.?\d+px)\2|(-?\d*\.?\d+)(?![\w.%'"`]))/g
36const TAILWIND_ARBITRARY =
37  /(?<![\w-])(-?(?:m|p)[trblxyse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|text|leading|tracking)-\[(-?\d*\.?\d+px)\]/g
38
39const groupOfProperty = (property: string): Token['group'] => (FONT_PROPS.test(property) ? 'font' : 'space')
40
41/** Blanks `var(...)` (fallbacks included) so a token reference is never flagged. */
42const maskTokenRefs = (line: string) => line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, m => ' '.repeat(m.length))
43
44export type ScanOptions = {
45  /** Values never flagged, compared as written (`0`, `1px`, `#fff`). */
46  allow?: readonly string[]
47  /** Leaves colors on SVG paint attributes (`fill="#000"`, `stroke`, `stop-color`) alone. */
48  allowSvgColors?: boolean
49}
50
51export function findViolations(text: string, path: string, options: ScanOptions = {}): Violation[] {
52  const { allow = ['0', '0px'], allowSvgColors = true } = options
53  const allowed = new Set(allow.map(a => a.trim().toLowerCase()))
54  const sheet = isStylesheet(path)
55  const found: Violation[] = []
56  const add = (v: Violation) => {
57    if (!allowed.has(v.value.toLowerCase())) found.push(v)
58  }
59
60  text.split('\n').forEach((original, index) => {
61    if (IGNORE.test(original)) return
62    const line = maskTokenRefs(original)
63    const lineNo = index + 1
64    // Custom property and preprocessor variable definitions are tokens themselves.
65    if (/^\s*(--[\w-]+|\$[\w-]+|@[\w-]+)\s*:/.test(line)) return
66
67    for (const match of line.matchAll(HEX)) {
68      if (NOT_A_COLOR_BEFORE.test(line.slice(0, match.index))) continue
69      if (allowSvgColors && SVG_PAINT_BEFORE.test(line.slice(0, match.index))) continue
70      add({ line: lineNo, value: match[0], kind: 'color', group: 'other' })
71    }
72    for (const match of line.matchAll(COLOR_FN)) {
73      if (allowSvgColors && SVG_PAINT_BEFORE.test(line.slice(0, match.index))) continue
74      add({ line: lineNo, value: match[0], kind: 'color', group: 'other' })
75    }
76
77    for (const decl of line.matchAll(CSS_LENGTH_DECL)) {
78      const [, property = '', value = ''] = decl
79      for (const px of value.matchAll(PX)) {
80        add({ line: lineNo, value: px[0], kind: 'length', property, group: groupOfProperty(property) })
81      }
82    }
83
84    if (!sheet) {
85      for (const match of line.matchAll(JS_STYLE_LENGTH)) {
86        const [, property = '', , quoted, bare] = match
87        // A unitless lineHeight is a ratio, not px.
88        if (bare !== undefined && property === 'lineHeight') continue
89        const value = quoted ?? `${bare}px`
90        add({ line: lineNo, value, kind: 'length', property, group: groupOfProperty(property) })
91      }
92      for (const match of line.matchAll(TAILWIND_ARBITRARY)) {
93        const [, utility = '', value = ''] = match
94        add({ line: lineNo, value, kind: 'length', property: utility, group: groupOfProperty(utility) })
95      }
96    }
97  })
98
99  // A CSS declaration in a component can be matched by both the CSS and the
100  // style-object rules: keep one per line and value.
101  const seen = new Set<string>()
102  return found.filter(v => {
103    const key = `${v.line}:${v.value}`
104    if (seen.has(key)) return false
105    seen.add(key)
106    return true
107  })
108}
109
110/** Drops the violations whose value the file already had: an edit is judged on what it adds. */
111export function newViolations(after: readonly Violation[], before: readonly Violation[]): Violation[] {
112  const budget = new Map<string, number>()
113  for (const v of before) budget.set(v.value.toLowerCase(), (budget.get(v.value.toLowerCase()) ?? 0) + 1)
114  return after.filter(v => {
115    const left = budget.get(v.value.toLowerCase()) ?? 0
116    if (left === 0) return true
117    budget.set(v.value.toLowerCase(), left - 1)
118    return false
119  })
120}
121
122const describeToken = (token: Token) =>
123  token.cssRef === token.name ? `\`${token.name}\`` : `\`${token.name}\` (\`${token.cssRef}\`)`
124
125/** One line per violation, naming the token to use. */
126export function explain(v: Violation, tokens: readonly Token[], lineOffset = 0): string {
127  const where = `line ${v.line + lineOffset}`
128  const what = v.property === undefined ? `\`${v.value}\`` : `${v.property} \`${v.value}\``
129  const normalized = v.kind === 'color' ? normalizeColor(v.value) : normalizeLength(v.value)
130  const hit = normalized === undefined ? undefined : suggest(tokens, v.kind, normalized, v.group)
131  if (hit === undefined) {
132    return `- ${where}: ${what} is hardcoded and no ${v.kind} token exists; add one to the tokens file and use it.`
133  }
134  if (hit.isExact) return `- ${where}: ${what} → use ${describeToken(hit.token)}.`
135  return `- ${where}: ${what} is not a token value → use the nearest token, ${describeToken(hit.token)} = ${hit.token.raw}.`
136}
137
hooks/tokens.ts 147 lines
1// Design tokens: read from a JSON tokens file (nested, W3C `$value` or
2// Style Dictionary `value` leaves) or a CSS/SCSS file of custom properties,
3// and matched against hardcoded values.
4
5export type TokenKind = 'color' | 'length'
6
7export type Token = {
8  /** Dotted path for JSON tokens (`color.brand.primary`), the variable for CSS (`--color-brand-primary`). */
9  name: string
10  /** How to write it in a stylesheet: `var(--color-brand-primary)` or `$color-primary`. */
11  cssRef: string
12  /** The value as written in the tokens file. */
13  raw: string
14  kind: TokenKind
15  /** `#rrggbbaa` for a color; px for a length. */
16  normalized: string | number
17  /** What the token is for, judged from its name. */
18  group: 'font' | 'space' | 'other'
19}
20
21const NAMED_COLORS: Record<string, string> = {
22  white: '#ffffffff',
23  black: '#000000ff',
24  transparent: '#00000000',
25}
26
27const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
28const hex2 = (n: number) => clamp(n).toString(16).padStart(2, '0')
29
30function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31  const a = s * Math.min(l, 1 - l)
32  const f = (n: number) => {
33    const k = (n + h / 30) % 12
34    return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
35  }
36  return [f(0) * 255, f(8) * 255, f(4) * 255]
37}
38
39/** `#rgb`, `#rrggbb`, `rgb()`, `hsl()` and a few names to `#rrggbbaa`; undefined when not a color. */
40export function normalizeColor(value: string): string | undefined {
41  const text = value.trim().toLowerCase()
42  if (NAMED_COLORS[text] !== undefined) return NAMED_COLORS[text]
43  const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)
44  if (hex?.[1] !== undefined) {
45    let digits = hex[1]
46    if (digits.length <= 4) digits = [...digits].map(d => d + d).join('')
47    return '#' + (digits.length === 6 ? digits + 'ff' : digits)
48  }
49  const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text)
50  if (fn?.[1] === undefined || fn[2] === undefined) return undefined
51  const parts = fn[2].split(/[\s,/]+/).filter(p => p !== '')
52  if (parts.length < 3) return undefined
53  const num = (p: string, scale: number) => (p.endsWith('%') ? (parseFloat(p) / 100) * scale : parseFloat(p))
54  const alpha = parts[3] === undefined ? 1 : num(parts[3], 1)
55  let rgb: [number, number, number]
56  if (fn[1].startsWith('rgb')) {
57    rgb = [num(parts[0]!, 255), num(parts[1]!, 255), num(parts[2]!, 255)]
58  } else {
59    rgb = hslToRgb(parseFloat(parts[0]!), num(parts[1]!, 1), num(parts[2]!, 1))
60  }
61  if (rgb.some(Number.isNaN) || Number.isNaN(alpha)) return undefined
62  return '#' + rgb.map(hex2).join('') + hex2(alpha * 255)
63}
64
65/** `12px`, `0.75rem` (at 16px), or a bare number to px; undefined otherwise. */
66export function normalizeLength(value: string | number): number | undefined {
67  if (typeof value === 'number') return value
68  const match = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(value.trim())
69  if (match?.[1] === undefined) return undefined
70  const n = parseFloat(match[1])
71  return match[2] === 'rem' || match[2] === 'em' ? n * 16 : n
72}
73
74const groupOf = (name: string): Token['group'] =>
75  /font|text|type|line|leading|tracking|letter/i.test(name)
76    ? 'font'
77    : /space|spacing|gap|margin|padding|inset|size|sizing|radius/i.test(name)
78      ? 'space'
79      : 'other'
80
81function makeToken(name: string, cssRef: string, raw: string | number): Token | undefined {
82  const text = String(raw)
83  const color = normalizeColor(text)
84  if (color !== undefined) return { name, cssRef, raw: text, kind: 'color', normalized: color, group: 'other' }
85  const length = normalizeLength(raw)
86  if (length !== undefined) return { name, cssRef, raw: text, kind: 'length', normalized: length, group: groupOf(name) }
87  return undefined
88}
89
90/** Tokens from a JSON tokens file; `$value`/`value` leaves and plain leaves alike. */
91export function tokensFromJson(data: unknown, path: string[] = []): Token[] {
92  if (typeof data === 'string' || typeof data === 'number') {
93    const name = path.join('.')
94    const token = makeToken(name, `var(--${path.join('-')})`, data)
95    return token === undefined ? [] : [token]
96  }
97  if (typeof data !== 'object' || data === null || Array.isArray(data)) return []
98  const record = data as Record<string, unknown>
99  const leaf = record.$value ?? record.value
100  if (leaf !== undefined && (typeof leaf === 'string' || typeof leaf === 'number')) return tokensFromJson(leaf, path)
101  return Object.entries(record)
102    .filter(([key]) => !key.startsWith('$'))
103    .flatMap(([key, value]) => tokensFromJson(value, [...path, key]))
104}
105
106/** Tokens from CSS custom properties (`--x: value`) and SCSS/Less variables (`$x: value`, `@x: value`). */
107export function tokensFromStylesheet(text: string): Token[] {
108  const tokens: Token[] = []
109  for (const match of text.matchAll(/(--[\w-]+|\$[\w-]+|@[\w-]+)\s*:\s*([^;{}]+?)\s*(?:!default\s*)?;/g)) {
110    const [, name = '', value = ''] = match
111    const cssRef = name.startsWith('--') ? `var(${name})` : name
112    const token = makeToken(name, cssRef, value)
113    if (token !== undefined) tokens.push(token)
114  }
115  return tokens
116}
117
118export function parseTokens(path: string, text: string): Token[] {
119  if (/\.json$/i.test(path)) return tokensFromJson(JSON.parse(text))
120  return tokensFromStylesheet(text)
121}
122
123const channels = (hex: string) => [1, 3, 5, 7].map(i => parseInt(hex.slice(i, i + 2), 16))
124
125/** The token for a value: an exact match, else the nearest of its kind (and group, for lengths). */
126export function suggest(
127  tokens: readonly Token[],
128  kind: TokenKind,
129  normalized: string | number,
130  group: Token['group'],
131): { token: Token; isExact: boolean } | undefined {
132  let candidates = tokens.filter(t => t.kind === kind)
133  if (kind === 'length' && group !== 'other') {
134    const inGroup = candidates.filter(t => t.group === group)
135    if (inGroup.length > 0) candidates = inGroup
136  }
137  let best: { token: Token; distance: number } | undefined
138  for (const token of candidates) {
139    const distance =
140      kind === 'color'
141        ? Math.hypot(...channels(String(token.normalized)).map((c, i) => c - channels(String(normalized))[i]!))
142        : Math.abs(Number(token.normalized) - Number(normalized))
143    if (best === undefined || distance < best.distance) best = { token, distance }
144  }
145  return best === undefined ? undefined : { token: best.token, isExact: best.distance === 0 }
146}
147