Stops pushes, PRs and raw commits you did not ask for, steers Bash to the right tools, and runs each project's own formatter on the file Claude just edited.

Keep Claude's hands off the things you want to decide, steer its shell commands to the tools you use, and tidy each file it edits with the project's own formatter.
guardrails is a Claude Code mod. It works in any project: every rule switches on from what the project contains (a lockfile, vendor/bin/pint, go.mod, a CLAUDE.md line), never from its name.
| Command | Runs when |
|---|---|
git push | Your last message asked for it: it says push or ship, and not after a negation such as "don't push". |
gh pr create, gh pr ready | Your last message says PR, pull request or ship. |
gh pr merge | Your last message says merge or ship. |
git commit | A commit skill (any skill named commit, such as mella:commit) is running: Claude called it, or you typed /mella:commit. The window closes when the turn ends. With no commit skill installed, commits are left alone. |
git merge --no-ff | Never, where the project's CLAUDE.md or AGENTS.md mentions --no-ff. |
| A commit or PR whose text holds a Claude session link | Never. |
"Your last message" is the last prompt you typed, at the terminal, through Remote Control or as a claude -p prompt. Background task notifications and messages from other sessions or plugins do not count. The next prompt you type closes the door again.
Refused, with a hint that names the right tool:
gh run watch, gh pr checks --watch, and while/until loops around sleep. Use the Monitor tool.sed -i (use sd or the Edit tool), grep (use rg), find (use fd).gh run view --log-failed that is not written to a file.perl -pi and ruby -i, cat or tee heredocs into a file, and Python, Node, Ruby, Perl or PHP scripts, inline or saved to a file, that write to a path inside the project. Scripts that only read, or write outside the project (such as /tmp), still run.After each Write or Edit, the formatter the project has set up runs on that one file. Claude is told what it changed, so it re-reads the file before its next edit.
| File | Formatter | When |
|---|---|---|
*.php | Pint (vendor/bin/pint --format agent) | vendor/bin/pint exists. Blade files only with Pint/laravel_blade. notPath in pint.json is respected. |
*.blade.php | sheath (artisan sheath:lint --fix) | config/sheath.php exists. Runs before Pint, so Pint formats last. Only safe fixes are applied; the rest is passed to Claude. |
| JS, TS, CSS, JSON, Markdown | vp fmt | vite.config.* has a fmt block and node_modules/.bin/vp exists. ignorePatterns is respected. |
| JS, TS, CSS, JSON | Biome | biome.json(c) and node_modules/.bin/biome exist, and there is no vp fmt. |
| JS, TS, CSS, Blade, Markdown | Prettier | A Prettier config and node_modules/.bin/prettier exist, and there is no vp fmt or Biome. |
*.go | gofmt | go.mod exists and gofmt is on the PATH. |
*.rs | rustfmt, with the edition from Cargo.toml | Cargo.toml exists and rustfmt is on the PATH. |
*.py | Ruff, or Black | Ruff or Black is configured, and installed in .venv or on the PATH. |
*.swift | swift-format | .swift-format exists and swift-format is on the PATH. |
Files in vendor, node_modules, target, .venv, build and dist are never formatted. Linters, static analysis and tests never run per edit: they are too slow and work on the whole project.
Commands:
packageManager in package.json, then the lockfile. The others are refused (npm in a bun project, bunx in a pnpm project).pint --test is refused where Pint is installed. Run pint --dirty --format agent to fix instead.--coverage together with --tia is refused.artisan command gets --no-interaction./opt/homebrew/bin/valet exists, bare valet is refused, since it resolves to the Composer copy and asks for a password.Each part has its own switch in /config, all on by default.
| Option | What it covers |
|---|---|
| Git guard | The Git table. |
| Shell guard | The Shell list. |
| Project tools | Formatters and the project command rules. |
git push itself, is not caught.The rules are pure functions in hooks/git.ts, hooks/shell.ts and hooks/project.ts. The hooks, project detection and fixer runs are in hooks/register.ts.
claude plugin validate plugins/guardrails
claude plugin test plugins/guardrails
MIT
hooks/register.ts 278 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import { gitDenial } from './git'
5import { cargoEdition, fixersFor, hasFmtBlock, packageManagerFrom, pintBlade, projectDenial, pythonFormatter, stringArray, withNoInteraction } from './project'
6import type { Fixer, Project } from './project'
7import { SCRIPT_EDIT_HINT, inProject, inlineScript, scriptFiles, shellDenial, writtenPaths } from './shell'
8
9const PLUGIN = 'guardrails'
10const FIXER_MS = 60_000
11const REPORT_CHARS = 1_500
12const SCRIPT_READ_LIMIT = 200_000
13const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
14const COMMIT_SKILL = /(?:^|:)commit$/
15const COMMIT_COMMAND = /^\s*\/(?:[\w-]+:)?commit(?:\s|$)/
16
17const lastPrompt = atom({ plugin: 'guardrails', key: 'lastPrompt' } as const, '')
18const committing = atom({ plugin: 'guardrails', key: 'committing' } as const, false)
19
20type Outcome = { exitCode: number; stdout: string; stderr: string }
21
22async function run($: EngineInterface, argv: string[], cwd?: string, timeoutMs?: number): Promise<Outcome> {
23 try {
24 return await $.process.run(argv, { ...(cwd ? { cwd } : {}), ...(timeoutMs ? { timeoutMs } : {}) })
25 } catch (error) {
26 return { exitCode: 127, stdout: '', stderr: String(error) }
27 }
28}
29
30const dirname = (path: string) => path.replace(/\/[^/]*$/, '') || '/'
31
32const readText = async ($: EngineInterface, path: string) => ((await $.fs.exists(path)) ? String(await $.fs.read(path)) : '')
33
34async function firstExisting($: EngineInterface, paths: string[]): Promise<string | undefined> {
35 for (const path of paths) {
36 if (await $.fs.exists(path)) {
37 return path
38 }
39 }
40
41 return undefined
42}
43
44async function rootOf($: EngineInterface, dir: string): Promise<string> {
45 const outcome = await run($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'])
46
47 return outcome.exitCode === 0 && outcome.stdout.trim() !== '' ? outcome.stdout.trim() : dir
48}
49
50const onPath = new Map<string, string | null>()
51
52async function which($: EngineInterface, name: string): Promise<string | null> {
53 if (!onPath.has(name)) {
54 const outcome = await run($, ['sh', '-c', `command -v ${name}`])
55
56 onPath.set(name, outcome.exitCode === 0 && outcome.stdout.trim() !== '' ? outcome.stdout.trim() : null)
57 }
58
59 return onPath.get(name) ?? null
60}
61
62const PRETTIER_CONFIGS = [
63 '.prettierrc',
64 '.prettierrc.json',
65 '.prettierrc.yaml',
66 '.prettierrc.yml',
67 '.prettierrc.js',
68 '.prettierrc.cjs',
69 '.prettierrc.mjs',
70 'prettier.config.js',
71 'prettier.config.cjs',
72 'prettier.config.mjs',
73]
74
75async function inspect($: EngineInterface, dir: string): Promise<Project> {
76 const root = await rootOf($, dir)
77 const at = (rel: string) => `${root}/${rel}`
78 const top = new Set((await $.fs.list(root).catch(() => [])).map(entry => entry.name))
79 const text = (rel: string) => (top.has(rel) ? readText($, at(rel)) : Promise.resolve(''))
80 const instructions = `${await text('CLAUDE.md')}\n${await text('AGENTS.md')}`
81 const packageJson = await text('package.json')
82 const pintJson = await text('pint.json')
83 const viteFile = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'].find(name => top.has(name))
84 const viteConfig = viteFile ? await text(viteFile) : ''
85 const local = async (bin: string) => ((await $.fs.exists(at(`node_modules/.bin/${bin}`))) ? at(`node_modules/.bin/${bin}`) : null)
86 const pythonTool = pythonFormatter(await text('pyproject.toml'), top.has('ruff.toml') || top.has('.ruff.toml'))
87 const pythonBin = pythonTool ? ((await firstExisting($, [at(`.venv/bin/${pythonTool}`), at(`venv/bin/${pythonTool}`)])) ?? (await which($, pythonTool))) : null
88 const vpBin = viteFile && hasFmtBlock(viteConfig) ? await local('vp') : null
89 const biomeBin = top.has('biome.json') || top.has('biome.jsonc') ? await local('biome') : null
90 const prettierBin = PRETTIER_CONFIGS.some(name => top.has(name)) || /"prettier"\s*:\s*\{/.test(packageJson) ? await local('prettier') : null
91 const gofmtBin = top.has('go.mod') ? await which($, 'gofmt') : null
92 const rustfmtBin = top.has('Cargo.toml') ? await which($, 'rustfmt') : null
93 const swiftFormatBin = top.has('.swift-format') ? await which($, 'swift-format') : null
94
95 return {
96 root,
97 laravel: top.has('artisan'),
98 packageManager: packageManagerFrom(top, packageJson),
99 homebrewValet: await $.fs.exists('/opt/homebrew/bin/valet'),
100 forbidsNoFf: /--no-ff\b/.test(instructions),
101 pint: (await $.fs.exists(at('vendor/bin/pint'))) ? { notPaths: stringArray(pintJson, 'notPath'), blade: pintBlade(pintJson) } : null,
102 sheath: top.has('artisan') && (await $.fs.exists(at('config/sheath.php'))),
103 vp: vpBin ? { bin: vpBin, ignore: stringArray(viteConfig, 'ignorePatterns') } : null,
104 biome: biomeBin ? { bin: biomeBin } : null,
105 prettier: prettierBin ? { bin: prettierBin } : null,
106 gofmt: gofmtBin ? { bin: gofmtBin } : null,
107 rustfmt: rustfmtBin ? { bin: rustfmtBin, edition: cargoEdition(await text('Cargo.toml')) } : null,
108 python: pythonTool && pythonBin ? { bin: pythonBin, tool: pythonTool } : null,
109 swiftFormat: swiftFormatBin ? { bin: swiftFormatBin } : null,
110 }
111}
112
113let commitSkill: string | null | undefined
114
115async function findCommitSkill($: EngineInterface): Promise<string | null> {
116 if (commitSkill === undefined) {
117 const commands = await $.command.list().catch(() => [])
118
119 commitSkill = commands.map(command => command.name).find(name => COMMIT_SKILL.test(name)) ?? null
120 }
121
122 return commitSkill
123}
124
125const deniedOrFailed = (result: unknown): boolean => {
126 const shape = result as { deny?: unknown; isError?: unknown } | undefined
127
128 return shape?.deny !== undefined || shape?.isError === true
129}
130
131const clip = (text: string) => (text.length > REPORT_CHARS ? `${text.slice(0, REPORT_CHARS)}…` : text)
132
133async function applyFixer($: EngineInterface, fixer: Fixer, root: string): Promise<string | undefined> {
134 const outcome = await run($, fixer.argv, root, FIXER_MS)
135 const output = `${outcome.stdout}\n${outcome.stderr}`.trim()
136
137 if (outcome.exitCode === 0 && output === '') {
138 return undefined
139 }
140
141 return `${PLUGIN}: ${fixer.name} ran on the file you just changed (exit ${outcome.exitCode}). Re-read the file before you edit it again.\n${clip(output)}`
142}
143
144async function scriptDenial($: EngineInterface, command: string, cwd: string, root: string): Promise<string | undefined> {
145 const sources: [string, string][] = []
146 const inline = inlineScript(command)
147
148 if (inline) {
149 sources.push(['This script', inline])
150 }
151
152 for (const path of scriptFiles(command)) {
153 const source = await readText($, path.startsWith('/') ? path : `${cwd}/${path}`)
154
155 if (source.length <= SCRIPT_READ_LIMIT) {
156 sources.push([path, source])
157 }
158 }
159
160 const candidates = sources.map(([label, source]) => [label, writtenPaths(source)] as const).filter(([, paths]) => paths.length > 0)
161
162 if (candidates.length === 0) {
163 return undefined
164 }
165
166 const topLevel = new Set((await $.fs.list(cwd).catch(() => [])).map(entry => entry.name))
167
168 for (const [label, paths] of candidates) {
169 const target = paths.find(path => inProject(path, root, topLevel))
170
171 if (target) {
172 return `${label} writes ${target}. ${SCRIPT_EDIT_HINT}`
173 }
174 }
175
176 return undefined
177}
178
179async function fix<T>($: EngineInterface, file: string, result: T): Promise<T> {
180 if (deniedOrFailed(result)) {
181 return result
182 }
183
184 const project = await inspect($, dirname(file))
185 const reports: string[] = []
186
187 for (const fixer of fixersFor(file, project)) {
188 const report = await applyFixer($, fixer, project.root)
189
190 if (report) {
191 reports.push(report)
192 }
193 }
194
195 if (reports.length === 0) {
196 return result
197 }
198
199 return { ...result, context: [...((result as { context?: readonly string[] }).context ?? []), ...reports] }
200}
201
202export const register: Register = (on, options) => {
203 const enabled = {
204 git: options.git !== false,
205 shell: options.shell !== false,
206 project: options.project !== false,
207 }
208
209 on('prompt.submit', async ($, e, next) => {
210 if (HUMAN_ORIGINS.has(e.origin.kind)) {
211 await update($, lastPrompt, () => e.text)
212
213 if (COMMIT_COMMAND.test(e.text)) {
214 await update($, committing, () => true)
215 }
216 }
217
218 return next(e)
219 })
220
221 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
222 if (e.tool === 'Skill' && COMMIT_SKILL.test(e.skill)) {
223 await update($, committing, () => true)
224 }
225
226 return next(e)
227 })
228
229 on('turn.complete', async ($, e, next) => {
230 if (e.agentId === undefined) {
231 await update($, committing, () => false)
232 }
233
234 return next(e)
235 })
236
237 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
238 if (e.tool !== 'Bash') {
239 return next(e)
240 }
241
242 const cwd = await $.session.cwd()
243 const project = enabled.project || enabled.git ? await inspect($, cwd) : null
244
245 const denial =
246 (enabled.git && project
247 ? gitDenial(e.command, {
248 lastPrompt: await read($, lastPrompt),
249 committing: await read($, committing),
250 commitSkill: await findCommitSkill($),
251 forbidsNoFf: project.forbidsNoFf,
252 })
253 : undefined) ??
254 (enabled.shell ? (shellDenial(e.command) ?? (await scriptDenial($, e.command, cwd, project?.root ?? (await rootOf($, cwd))))) : undefined) ??
255 (enabled.project && project ? projectDenial(e.command, project) : undefined)
256
257 if (denial) {
258 return { deny: `${PLUGIN}: ${denial}` }
259 }
260
261 const rewritten = enabled.project && project ? withNoInteraction(e.command, project) : undefined
262
263 return next(rewritten ? { ...e, command: rewritten } : e)
264 }).catch(($, e, next) => (next.called ? next(e) : { deny: `${PLUGIN}: the guard failed on this command, so it did not run. Ask the user.` }))
265
266 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
267 const result = await next(e)
268
269 return enabled.project && e.tool === 'Edit' ? fix($, e.file_path, result) : result
270 })
271
272 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
273 const result = await next(e)
274
275 return enabled.project && e.tool === 'Write' ? fix($, e.file_path, result) : result
276 })
277}
278hooks/git.ts 60 lines1export type GitContext = {
2 lastPrompt: string
3 committing: boolean
4 commitSkill: string | null
5 forbidsNoFf: boolean
6}
7
8const STARTS = String.raw`(?:^|[|;&(\n]\s*)`
9const GIT = String.raw`${STARTS}git(?:\s+-[Cc]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+`
10const GH = String.raw`${STARTS}gh\s+pr\s+`
11
12const PUSH = new RegExp(`${GIT}push\\b`)
13const COMMIT = new RegExp(`${GIT}commit\\b`)
14const NO_FF_MERGE = new RegExp(`${GIT}merge\\b[^|;&\\n]*--no-ff\\b`)
15const PR_OPEN = new RegExp(`${GH}(?:create|ready)\\b`)
16const PR_MERGE = new RegExp(`${GH}merge\\b`)
17const PUBLISHES = /\bgit\b[^|;&\n]*\bcommit\b|\bgh\s+(?:pr|issue)\b/
18const SESSION_LINK = /claude\.ai\/code\/session_|Claude-Session:/
19
20const PUSH_WORDS = String.raw`push(?:es|ed|ing)?|ship(?:s|ped|ping)?`
21const PR_WORDS = String.raw`prs?|pull[- ]requests?|ship(?:s|ped|ping)?`
22const MERGE_WORDS = String.raw`merg(?:e|es|ed|ing)|ship(?:s|ped|ping)?`
23
24const NEGATION = String.raw`\b(?:don'?t|do\s+not|never|no|not|stop|without|avoid|hold\s+off(?:\s+on)?)\b(?:\s+[\w'-]+){0,3}\s+`
25
26export function asked(prompt: string, words: string): boolean {
27 const mentions = prompt.match(new RegExp(`\\b(?:${words})\\b`, 'gi')) ?? []
28 const negated = prompt.match(new RegExp(`${NEGATION}(?:${words})\\b`, 'gi')) ?? []
29
30 return mentions.length > negated.length
31}
32
33export function gitDenial(command: string, context: GitContext): string | undefined {
34 if (SESSION_LINK.test(command) && PUBLISHES.test(command)) {
35 return 'Remove the Claude session link from the commit or PR text.'
36 }
37
38 if (PUSH.test(command) && !asked(context.lastPrompt, PUSH_WORDS)) {
39 return 'The user did not ask for a push in their last message. Ask them first.'
40 }
41
42 if (PR_OPEN.test(command) && !asked(context.lastPrompt, PR_WORDS)) {
43 return 'The user did not ask for a pull request in their last message. Ask them first.'
44 }
45
46 if (PR_MERGE.test(command) && !asked(context.lastPrompt, MERGE_WORDS)) {
47 return 'The user did not ask for a merge in their last message. Ask them first.'
48 }
49
50 if (context.commitSkill && COMMIT.test(command) && !context.committing) {
51 return `Commit through the ${context.commitSkill} skill, not a raw git commit.`
52 }
53
54 if (context.forbidsNoFf && NO_FF_MERGE.test(command)) {
55 return 'This project merges fast-forward only. Drop --no-ff.'
56 }
57
58 return undefined
59}
60hooks/project.ts 199 lines1export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm'
2
3export type Project = {
4 root: string
5 laravel: boolean
6 packageManager: PackageManager | null
7 homebrewValet: boolean
8 forbidsNoFf: boolean
9 pint: { notPaths: string[]; blade: boolean } | null
10 sheath: boolean
11 vp: { bin: string; ignore: string[] } | null
12 biome: { bin: string } | null
13 prettier: { bin: string } | null
14 gofmt: { bin: string } | null
15 rustfmt: { bin: string; edition: string | null } | null
16 python: { bin: string; tool: 'ruff' | 'black' } | null
17 swiftFormat: { bin: string } | null
18}
19
20export type Fixer = { name: string; argv: string[] }
21
22export const LOCKFILES: readonly [string, PackageManager][] = [
23 ['bun.lock', 'bun'],
24 ['bun.lockb', 'bun'],
25 ['pnpm-lock.yaml', 'pnpm'],
26 ['yarn.lock', 'yarn'],
27 ['package-lock.json', 'npm'],
28]
29
30const RUNNERS: Record<PackageManager, RegExp> = {
31 bun: /\bbunx?\b/,
32 pnpm: /\bpnpx?\b/,
33 yarn: /\byarn\b/,
34 npm: /\bnp[mx]\b/,
35}
36
37const USE: Record<PackageManager, string> = {
38 bun: 'bun / bun run / bunx',
39 pnpm: 'pnpm / pnpm run / pnpm dlx',
40 yarn: 'yarn / yarn run / yarn dlx',
41 npm: 'npm / npm run / npx',
42}
43
44const STARTS = String.raw`(?:^|[|;&(]\s*)`
45
46const PHP = /\.php$/
47const BLADE = /\.blade\.php$/
48const WEB_FILES = /\.(?:[cm]?[jt]sx?|css|scss|json|jsonc|vue|svelte|md|html?)$/
49const BIOME_FILES = /\.(?:[cm]?[jt]sx?|css|json|jsonc)$/
50const PRETTIER_FILES = /\.(?:blade\.php|[cm]?[jt]sx?|css|scss|json|vue|svelte|md|html?|ya?ml)$/
51
52const COMPOUND = /&&|\|\||[;|\n`]|\$\(|<<|>/
53const ARTISAN = /^\s*(?:\S*php\s+(?:-d\s+\S+\s+)*)?(?:\.\/)?artisan\s+\S+/
54
55export function projectDenial(command: string, project: Project): string | undefined {
56 if (/--tia\b[^|;&\n]*--coverage\b|--coverage\b[^|;&\n]*--tia\b/.test(command)) {
57 return 'Never combine --coverage with --tia. Re-record with --parallel --tia --fresh.'
58 }
59
60 if (project.pint && /\bpint\b[^|;&\n]*\s--test\b/.test(command)) {
61 return 'Do not run pint --test. Run vendor/bin/pint --dirty --format agent to fix the style.'
62 }
63
64 const manager = project.packageManager
65
66 if (manager) {
67 const other = (Object.keys(RUNNERS) as PackageManager[]).find(name => name !== manager && new RegExp(`${STARTS}${RUNNERS[name].source}`).test(command))
68
69 if (other && !(manager === 'bun' && other === 'npm' && /^\s*npm\s+(?:view|info|search)\b/.test(command))) {
70 return `This project uses ${manager} (its lockfile says so). Use ${USE[manager]} instead of ${other}.`
71 }
72 }
73
74 if (project.homebrewValet && new RegExp(`${STARTS}valet\\b`).test(command)) {
75 return 'Run Valet as /opt/homebrew/bin/valet. Bare valet resolves to the Composer copy and asks for a password.'
76 }
77
78 return undefined
79}
80
81export function withNoInteraction(command: string, project: Project): string | undefined {
82 if (!project.laravel || COMPOUND.test(command) || !ARTISAN.test(command)) {
83 return undefined
84 }
85
86 if (/(?:^|\s)(?:--no-interaction|-n)(?:\s|$)/.test(command)) {
87 return undefined
88 }
89
90 return `${command.trimEnd()} --no-interaction`
91}
92
93const relative = (root: string, file: string) => (file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file)
94
95export function ignoredBy(patterns: string[], rel: string): boolean {
96 return patterns.some(pattern => {
97 const clean = pattern.replace(/\/+$/, '')
98
99 if (clean.startsWith('/')) {
100 const anchored = clean.slice(1)
101
102 return rel === anchored || rel.startsWith(`${anchored}/`)
103 }
104
105 return rel === clean || rel.startsWith(`${clean}/`) || rel.includes(`/${clean}/`) || rel.endsWith(`/${clean}`)
106 })
107}
108
109function webFormatter(file: string, project: Project): Fixer | undefined {
110 const rel = relative(project.root, file)
111
112 if (project.vp && WEB_FILES.test(file) && !BLADE.test(file)) {
113 return ignoredBy(project.vp.ignore, rel) ? undefined : { name: 'vp fmt', argv: [project.vp.bin, 'fmt', file] }
114 }
115
116 if (project.biome && BIOME_FILES.test(file)) {
117 return { name: 'biome', argv: [project.biome.bin, 'format', '--write', file] }
118 }
119
120 if (!project.vp && !project.biome && project.prettier && PRETTIER_FILES.test(file) && !(BLADE.test(file) && project.pint?.blade)) {
121 return { name: 'prettier', argv: [project.prettier.bin, '--write', file] }
122 }
123
124 return undefined
125}
126
127export function fixersFor(file: string, project: Project): Fixer[] {
128 const rel = relative(project.root, file)
129 const fixers: Fixer[] = []
130
131 if (rel === file || /^(?:vendor|node_modules|target|\.venv|venv|build|dist)\//.test(rel)) {
132 return fixers
133 }
134
135 if (project.sheath && BLADE.test(file)) {
136 fixers.push({ name: 'sheath', argv: ['php', '-d', 'memory_limit=-1', 'artisan', 'sheath:lint', '--fix', file] })
137 }
138
139 if (project.pint && PHP.test(file) && (!BLADE.test(file) || project.pint.blade) && !ignoredBy(project.pint.notPaths, rel)) {
140 fixers.push({ name: 'pint', argv: [`${project.root}/vendor/bin/pint`, '--format', 'agent', file] })
141 }
142
143 const web = webFormatter(file, project)
144
145 if (web) {
146 fixers.push(web)
147 }
148
149 if (project.gofmt && file.endsWith('.go')) {
150 fixers.push({ name: 'gofmt', argv: [project.gofmt.bin, '-w', file] })
151 }
152
153 if (project.rustfmt && file.endsWith('.rs')) {
154 fixers.push({ name: 'rustfmt', argv: [project.rustfmt.bin, ...(project.rustfmt.edition ? ['--edition', project.rustfmt.edition] : []), file] })
155 }
156
157 if (project.python && /\.pyi?$/.test(file)) {
158 fixers.push({ name: project.python.tool, argv: project.python.tool === 'ruff' ? [project.python.bin, 'format', file] : [project.python.bin, '--quiet', file] })
159 }
160
161 if (project.swiftFormat && file.endsWith('.swift')) {
162 fixers.push({ name: 'swift-format', argv: [project.swiftFormat.bin, 'format', '--in-place', file] })
163 }
164
165 return fixers
166}
167
168export function stringArray(source: string, key: string): string[] {
169 const list = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*\\[([^\\]]*)\\]`))?.[1] ?? ''
170
171 return [...list.matchAll(/["']([^"']+)["']/g)].map(match => match[1] ?? '').filter(Boolean)
172}
173
174export function hasFmtBlock(viteConfig: string): boolean {
175 return /\bfmt\s*:\s*\{/.test(viteConfig)
176}
177
178export function pintBlade(pintJson: string): boolean {
179 return /"Pint\/laravel_blade"\s*:\s*true/.test(pintJson)
180}
181
182export function cargoEdition(cargoToml: string): string | null {
183 return cargoToml.match(/^\s*edition\s*=\s*["'](\d{4})["']/m)?.[1] ?? null
184}
185
186export function pythonFormatter(pyproject: string, hasRuffConfig: boolean): 'ruff' | 'black' | null {
187 if (hasRuffConfig || /^\[tool\.ruff(?:\.format)?\]/m.test(pyproject)) {
188 return 'ruff'
189 }
190
191 return /^\[tool\.black\]/m.test(pyproject) ? 'black' : null
192}
193
194export function packageManagerFrom(present: ReadonlySet<string>, packageJson: string): PackageManager | null {
195 const declared = packageJson.match(/"packageManager"\s*:\s*"(bun|pnpm|yarn|npm)@/)?.[1] as PackageManager | undefined
196
197 return declared ?? LOCKFILES.find(([file]) => present.has(file))?.[1] ?? null
198}
199hooks/shell.ts 78 lines1type Rule = { when: RegExp; hint: string }
2
3const STARTS = String.raw`(?:^|[|;&(]\s*|\bxargs\s+(?:-\S+\s+)*)`
4
5const SCRIPT = /\b(?:python3?|node|ruby|perl|php)\b[^\n]*(?:\s-\s|\s-\s*$|<<|\s-[cer]\s)/m
6const SCRIPT_WRITES = /\.write(?:_text|_bytes)?\(|\bopen\([^)]*,\s*['"](?:w|a|x|r\+)b?['"]|(?:writeFileSync|appendFileSync|writeFile)\(|File\.write\(|file_put_contents\(/
7const IN_PLACE = /(?:^|[|;&(]\s*)(?:perl|ruby)\b[^|;&\n]*\s-\w*i\w*\b/
8const HEREDOC_TO_FILE = /\bcat\s*<<-?\s*['"]?\w+['"]?\s*>{1,2}\s*[^\s&]|\bcat\s*>{1,2}\s*\S+\s*<<|\btee\s+(?:-a\s+)?[^\s<|;&-]\S*\s*<</
9const PATH_LITERAL = /['"`]((?:\.\/|\/)?[^\s'"`/()<>:;,=*?|\\]+(?:\/[^\s'"`()<>:;,=*?|\\]+)*\/?)['"`]/g
10const LOOKS_LIKE_PATH = /\/|\.(?:php|[cm]?[jt]sx?|vue|css|scss|html?|md|json|ya?ml|py|rb|swift|txt|xml|neon|sql|env)$/
11const SCRIPT_FILE = /(?:^|[|;&(]\s*)(?:python3?|node|ruby|perl|php)(?:\s+(?:-\S+|[\w.]+=\S*))*\s+([^\s|;&<>'"-][^\s|;&<>'"]*\.(?:py|[cm]?js|ts|rb|pl|php))(?=\s|$|[|;&)])/g
12
13export const RULES: readonly Rule[] = [
14 { when: /\bgh\s+run\s+watch\b/, hint: 'Do not poll CI from Bash. Use the Monitor tool (load it with ToolSearch).' },
15 { when: /\bgh\s+pr\s+checks\b[^|;&\n]*--watch\b/, hint: 'Do not poll CI from Bash. Use the Monitor tool (load it with ToolSearch).' },
16 { when: /\b(?:while|until)\b[\s\S]*\bsleep\b/, hint: 'Do not poll with sleep loops. Use the Monitor tool with an until-loop command.' },
17 { when: /\bsed\b[^|;&\n]*\s(?:-[a-zA-Z]*i\S*|--in-place\S*)(?:\s|$)/, hint: 'Use sd for find/replace, or the Edit tool.' },
18 { when: new RegExp(`${STARTS}grep\\b`), hint: 'Use rg instead of grep.' },
19 { when: new RegExp(`${STARTS}find\\s`), hint: 'Use fd instead of find.' },
20 { when: /\bgh\s+run\s+view\b(?![^|;&\n]*>)[^|;&\n]*--log-failed\b(?![^|;&\n]*>)/, hint: 'Write the failed log to a file and read the part you need.' },
21]
22
23export const SCRIPT_EDIT_HINT = 'Do not edit files from a Bash script or heredoc. Use the Edit or Write tool.'
24
25export function writesInPlace(command: string): boolean {
26 return IN_PLACE.test(command) || HEREDOC_TO_FILE.test(command)
27}
28
29export function inlineScript(command: string): string | undefined {
30 return SCRIPT.test(command) ? command : undefined
31}
32
33export function scriptFiles(command: string): string[] {
34 return [...command.matchAll(SCRIPT_FILE)].map(match => match[1] ?? '').filter(path => path !== '' && !/(?:^|\/)vendor\/bin\//.test(path))
35}
36
37export function writtenPaths(source: string): string[] {
38 if (!SCRIPT_WRITES.test(source)) {
39 return []
40 }
41
42 return [...new Set([...source.matchAll(PATH_LITERAL)].map(match => match[1] ?? '').filter(path => LOOKS_LIKE_PATH.test(path)))]
43}
44
45function normalise(path: string): string {
46 const parts: string[] = []
47
48 for (const part of path.split('/')) {
49 if (part === '..') {
50 parts.pop()
51 } else if (part !== '.' && part !== '') {
52 parts.push(part)
53 }
54 }
55
56 return `/${parts.join('/')}`
57}
58
59export function inProject(path: string, root: string, topLevel: ReadonlySet<string>): boolean {
60 if (path.startsWith('/')) {
61 const resolved = normalise(path)
62
63 return resolved.startsWith(`${root}/`)
64 }
65
66 const first = path.replace(/^\.\//, '').split('/')[0] ?? ''
67
68 return topLevel.has(first)
69}
70
71export function shellDenial(command: string): string | undefined {
72 if (writesInPlace(command)) {
73 return SCRIPT_EDIT_HINT
74 }
75
76 return RULES.find(rule => rule.when.test(command))?.hint
77}
78types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 guardrails: {
4 lastPrompt: string
5 committing: boolean
6 }
7 }
8}
9