A lint gate for Claude's edits: your project's rules, a baseline of existing violations, and a PreToolUse check that refuses only edits adding new ones.

A Claude Code plugin that holds Claude's edits to your project's own lint rules. You write the rules; the plugin supplies the runner, a baseline ratchet and a gate in front of every edit.
.claude/guardrails.toml (or .json): regex rules with an id, a message, path globs and a severity, and command rules that run your own script or linter over the files.guard.py baseline records today's violations. From then on only NEW ones fail, so you can adopt a rule on a codebase that breaks it today and burn the old ones down at your own pace. Violations are matched by rule, file and the line's text, not the line number, so moving code around is not "new".Python 3.9+ with the standard library only (TOML rules need 3.11+; JSON works everywhere).
/plugin install guardrails --marketplace azoof-ahmed/claude-mods
Then, in your project:
cp <plugin>/examples/guardrails.toml .claude/guardrails.toml # edit the rules
python <plugin>/scripts/guard.py check --all # see what they catch today
python <plugin>/scripts/guard.py baseline # record it; only new violations fail from now on
Commit the rules file and the baseline so everyone (and every agent) is held to the same line.
Set them in /config, or in settings under pluginConfigs["guardrails"].options.
| Option | Default | What it does |
|---|---|---|
mode | block | block refuses an edit that adds a violation; warn lets it through with a toast. |
paths | ** | Comma-separated globs of the files the gate covers. Each rule has its own paths too. |
rulesFile | (empty) | The rules file. Empty: .claude/guardrails.toml, else .claude/guardrails.json. |
baselineFile | .claude/guardrails.baseline.json | Where the baseline is kept. |
python | python | The Python executable (python3, py, or a full path). |
The options reach the runner as environment variables (GUARDRAILS_PATHS, GUARDRAILS_RULES, GUARDRAILS_BASELINE, GUARDRAILS_PYTHON, plus GUARDRAILS_CLI, the runner's path), so a project's own file beats them. Order, highest first: a flag typed on the command line > the project's .claude/claude-mods.json section "guardrails" (rulesFile, baselineFile, paths) > environment > defaults. The project root is the nearest folder holding .git or .claude.
When the runner itself breaks (Python missing, a rules file that does not parse, a command rule that crashes) the gate fails open: the edit goes through, a toast says it was not checked, and the reason is logged. A broken rules file should not lock every edit; run guard.py check to see the error.
# .claude/guardrails.toml
[[rules]]
id = "no-console-log"
pattern = 'console\.(log|debug)\('
message = "console.log in src. Use the project's logger."
paths = ["src/**/*.{ts,tsx}"]
exclude = ["**/*.test.ts"]
[[rules]]
id = "flake8"
type = "command" # prints path:line: rule: message
command = ["python", "-m", "flake8", "--format=%(path)s:%(row)d: %(code)s: %(text)s"]
paths = ["**/*.py"]
python <plugin>/scripts/guard.py check # whole project, exit 1 on new errors (use it in CI too)
python <plugin>/scripts/guard.py check src/api # some files or folders
python <plugin>/scripts/guard.py check --all # everything, baseline ignored
python <plugin>/scripts/guard.py baseline # re-record after fixing violations
python <plugin>/scripts/guard.py rules # what is loaded
examples/guardrails.toml and examples/guardrails.json hold generic starting rules: no TODO/FIXME in src, no console.log in src, no hardcoded secrets, and a warning for leftover debugger statements.
A line may carry guardrails-ok: <reason> to exempt it from regex rules; a bare guardrails-ok does not count. The bundled skill tells Claude to use it only with the owner's agreement.
claude plugin validate .
claude plugin test .
python -m unittest discover -s scripts/tests
claude --plugin-dir .
MIT
hooks/register.ts 108 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3/** The file-editing tools the gate judges. MultiEdit is kept for hosts that still offer it. */
4const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
5const SHOWN_VIOLATIONS = 20
6
7const DEFAULTS: Readonly<Record<string, string>> = {
8 baselineFile: '.claude/guardrails.baseline.json',
9 paths: '**',
10 python: 'python',
11}
12
13function option(options: PluginOptions, key: string): string {
14 const value = options[key]
15
16 return value === undefined || value === '' ? (DEFAULTS[key] ?? '') : String(value)
17}
18
19function cli($: EngineInterface): string {
20 return `${$.plugin.root.replaceAll('\\', '/')}/scripts/guard.py`
21}
22
23/** The person's options as the runner's environment variables: below a project's own
24 * `.claude/claude-mods.json`, above the runner's defaults. An unset rules file stays unset, so the
25 * runner looks for .claude/guardrails.toml, then .json. */
26export function environment($: EngineInterface, options: PluginOptions): Record<string, string> {
27 const rules = option(options, 'rulesFile')
28
29 return {
30 ...(rules === '' ? {} : { GUARDRAILS_RULES: rules }),
31 GUARDRAILS_BASELINE: option(options, 'baselineFile'),
32 GUARDRAILS_PATHS: option(options, 'paths'),
33 GUARDRAILS_PYTHON: option(options, 'python'),
34 GUARDRAILS_CLI: cli($),
35 }
36}
37
38export function denial(name: string, stdout: string): string {
39 const lines = stdout.split('\n').filter(line => line.trim() !== '' && !line.endsWith('(warn)'))
40 const shown = lines.slice(0, SHOWN_VIOLATIONS)
41 const more = lines.length > shown.length ? [` ...and ${lines.length - shown.length} more`] : []
42
43 return [
44 `${name}: this edit adds rule violations that are not in the baseline:`,
45 ...shown.map(line => ` ${line}`),
46 ...more,
47 'Fix the code, not the rules: rewrite the change so it no longer violates them. Do not edit the rules ' +
48 'file or the baseline to get past this gate; rule changes need the project owner\'s OK.',
49 ].join('\n')
50}
51
52export const register: Register = (on, options) => {
53 const python = option(options, 'python')
54
55 on('session.start', async ($, e, next) => {
56 // Bash commands (guard.py check, baseline) inherit these. env.set takes string-literal names.
57 const env = environment($, options)
58 await $.env.set('GUARDRAILS_RULES', env.GUARDRAILS_RULES)
59 await $.env.set('GUARDRAILS_BASELINE', env.GUARDRAILS_BASELINE)
60 await $.env.set('GUARDRAILS_PATHS', env.GUARDRAILS_PATHS)
61 await $.env.set('GUARDRAILS_PYTHON', env.GUARDRAILS_PYTHON)
62 await $.env.set('GUARDRAILS_CLI', env.GUARDRAILS_CLI)
63
64 return next(e)
65 })
66
67 on('tool.call', async ($, e, next) => {
68 if (!EDIT_TOOLS.has(e.tool)) {
69 return next(e)
70 }
71
72 // The runner decides scope (paths, the rules' own globs) and judges only what the edit adds.
73 const ran = await $.process.run([python, cli($), 'check-edit'], {
74 env: environment($, options),
75 stdin: JSON.stringify({ tool_name: e.tool, tool_input: e }),
76 timeoutMs: 60_000,
77 })
78
79 if (ran.exitCode === 0) {
80 return next(e)
81 }
82
83 if (ran.exitCode !== 1) {
84 // Fail open: a broken runner or rules file must not lock every edit. Say so, loudly.
85 $.ui.toast(`${$.plugin.name}: the rule runner failed (exit ${ran.exitCode}); edit allowed unchecked`)
86 $.ui.log(`${$.plugin.name}: guard.py check-edit exited ${ran.exitCode}: ${(ran.stderr || ran.stdout).trim()}`)
87
88 return next(e)
89 }
90
91 if (option(options, 'mode') === 'warn') {
92 $.ui.toast(`${$.plugin.name}: this edit adds rule violations (warn mode, edit allowed)`)
93
94 return next(e)
95 }
96
97 return { deny: denial($.plugin.name, ran.stdout) }
98 }).catch(($, e, next) => {
99 if (next.called) {
100 return next(e)
101 }
102 // Fail open when the runner cannot start at all (python missing, timeout).
103 $.ui.toast(`${$.plugin.name}: could not run the rule runner; edit allowed unchecked`)
104
105 return next(e)
106 })
107}
108