Development
Hook
Hooks
What rules-for-claude runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
$ npx -y skills add chancegraff/rules-for-claude --agent claude-codeShips with rules-for-claude. Installing the plugin gets these hooks.
Where it lives
- hooks/deny-rule-redirect.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // deny-rule-redirect.js — PreToolUse hook (matcher: Bash) // Reads Bash(...) deny rules from ~/.work/settings.json, matches the // incoming command against each pattern, and on a hit emits a structured // block response naming the exact rule + a redirect to the right tool. // // Why this exists: Claude Code's built-in deny enforcement blocks the call // but does not always tell the agent WHICH rule fired or what to do // instead. This hook surfaces both, so the agent self-corrects rather than // guessing. // // Precedence: per Claude Code IAM docs, deny > allow always. So matching // a deny rule is sufficient to block — no need to inspect allow rules. const fs = require('fs'); const os = require('os'); const path = require('path'); const SETTINGS_PATH = path.join(os.homedir(), '.work', 'settings.json'); // ── Redirect map: rule "subject" → suggested alternative ────────────────── // Subject is the cleaned form of the pattern (see describeRule below). // Order matters for substring fallback — list more specific keys first. const REDIRECTS = { // search tools 'grep': 'Do not use Grep. For code symbols, use the LSP tool (operation: workspaceSymbol / findReferences / goToDefinition).', 'grep -r': 'Do not use Grep. For code symbols, use LSP({operation: "workspaceSymbol", ...}).', 'rg': 'Do not use Ripgrep. For code symbols, use LSP({operation: ..., filePath, line, character}).', 'egrep': 'Do not use Egrep. For code symbols, use LSP({operation: ..., filePath, line, character}).', 'fgrep': 'Do not use Fgrep. For code symbols, use LSP({operation: ..., filePath, line, character}).', 'ggrep': 'Do not use Ggrep. For code symbols, use LSP({operation: ..., filePath, line, character}).', // text mutation 'sed': 'Do not use Sed. Use the Edit tool for in-file text changes.', 'awk': 'Do not use Awk. Use the Edit tool, or Read + analyze inline.', // language runtimes 'python3': 'Avoid ad-hoc python — use Read/Edit/Write or a project script defined in package.json.', 'node': 'Inline scripts are banned. Write the code to a file in the source tree and run that file with node.', 'NODE_OPTIONS=': 'Inline scripts are banned. Write the code to a file in the source tree and run that file with node.', 'deno': 'Inline deno eval, repl, and stdin scripts are banned. Write the code to a file and run that file.', 'npx': 'Avoid npx — use pnpm scripts defined in package.json.', 'sh': 'Run the underlying command directly instead of piping through sh.', 'jest': 'Use pnpm test (or pnpm --filter <pkg> test variants) — never invoke jest directly.', // git destructive / state-altering 'git stash': 'Avoid git stash — commit WIP or leave changes uncommitted.', 'git checkout': 'git checkout can overwrite work. Use git switch for branches; ask before touching files.', 'git reset': 'git reset can lose work. Confirm with the user before any reset.', 'git clean': 'git clean deletes untracked files. Confirm with the user first.', 'git revert': 'Confirm with the user before reverting.', 'git merge': 'Confirm with the user before merging.', 'git rebase': 'Confirm with the user before rebasing.', 'git mv': 'Use a regular mv + git add instead of git mv.', // shell control flow '&&': 'Run commands separately in distinct Bash calls — do not chain with &&.', '||': 'Run commands separately in distinct Bash calls — do not chain with ||.', ';': 'Run commands separately in distinct Bash calls — do not chain with ;.', '|': 'Pipes are denied. Run commands separately. Redirecting output to files is denied.', '2>&1': 'Redirecting stderr to stdout is denied. Run commands in the session instead.', '>': 'Redirecting output to files is denied. Run commands in the session instead.', 'for': 'Run commands individually instead of shell for-loops.', 'pushd': 'Use absolute paths instead of pushd/popd.', // hashing / misc 'md5': 'Use shasum (or another hash) if you genuinely need a digest.', // path-prefix denies 'node_modules/':'Do not operate inside node_modules — let the package manager own it.', '/opt/homebrew/':'Use the command name directly (PATH resolves it). Do not hardcode /opt/homebrew/.', '/usr/bin/': 'Use the command name directly (PATH resolves it). Do not hardcode /usr/bin/.', '/bin/': 'Use the command name directly (PATH resolves it). Do not hardcode /bin/.', '-exec': 'Avoid find -exec. Run a targeted Read/Edit/Grep instead.', }; // ── Helpers ─────────────────────────────────────────────────────────────── function readSettingsSilent() { try { const raw = fs.readFileSync(SETTINGS_PATH, 'utf8'); return JSON.parse(raw); } catch { return null; } } // Extract "Bash(<pattern>)" → <pattern>. Returns null for non-Bash rules. function unwrapBashRule(rule) { if (typeof rule !== 'string') return null; const m = rule.match(/^Bash\((.*)\)$/s); return m ? m[1] : null; } // Convert a Claude Code Bash permission pattern to a regex matching the // full command string. // // Pattern semantics (matching Claude Code's documented behavior): // `cmd:*` → `cmd` alone OR `cmd` followed by whitespace + args // `*` → glob wildcard (any chars, including spaces) // anything else → literal // // Regex specials are escaped first; `*` is then replaced with `.*` so it // keeps glob semantics. Anchored on both ends so partial-string matches // don't sneak through. function patternToRegex(pat) { const trailingColonStar = /:\*$/.test(pat); let body = trailingColonStar ? pat.slice(0, -2) : pat; // Escape every regex metachar except `*` (which we handle next). body = body.replace(/[.+?^${}()[\]\\|]/g, '\\$&'); body = body.replace(/\*/g, '.*'); const tail = trailingCo - hooks/git-no-verify-block.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // git-no-verify-block.js — PreToolUse hook (matcher: Bash) // Blocks all known forms of pre-commit/pre-push hook bypass when the // command is `git commit` or `git push`. Mirrors the zsh guard in // ~/.zshenv but tightens it to also catch: // - `git -C path commit/push --no-verify` (zsh guard misses this) // - `git -c core.hooksPath=/dev/null commit/push` (stealth bypass) // - `GIT_CONFIG_PARAMETERS="'core.hooksPath=...'" git commit/push` // - `GIT_CONFIG_KEY_*=core.hooksPath ... git commit/push` // // `-n` is only blocked on `commit` (where it == `--no-verify`). // On `push`, `-n` means `--dry-run`, which is harmless and allowed. // MAX_PAYLOAD_BYTES: the lead's budget, set 2026-09-10 (well above GitHub's 65,536-byte body cap plus flags); a larger payload is not a tool call this hook can act on. const MAX_PAYLOAD_BYTES = 1048576; let raw = ''; let overflow = false; let bytes = 0; process.stdin.setEncoding('utf8'); process.stdin.on('data', d => { if (overflow) return; bytes += Buffer.byteLength(d, 'utf8'); if (bytes > MAX_PAYLOAD_BYTES) { overflow = true; raw = ''; return; } raw += d; }); process.stdin.on('end', () => { if (overflow) process.exit(0); let data; try { data = JSON.parse(raw); } catch { process.exit(0); } if (data.tool_name !== 'Bash') process.exit(0); const cmd = String(data.tool_input?.command ?? ''); // Detect `git ... push` or `git ... commit`, allowing prefix flags // (-C path, --git-dir=path, -c key=val) between `git` and the subcommand. const sub = cmd.match( /\bgit\b(?:\s+(?:-C\s+\S+|--git-dir(?:=|\s+)\S+|-c\s+\S+))*\s+(push|commit)\b/ ); if (!sub) process.exit(0); const subcmd = sub[1]; const reasons = []; if (/(?:^|\s)--no-verify(?=\s|$)/.test(cmd)) { reasons.push('--no-verify'); } if (subcmd === 'commit' && /(?:^|\s)-n(?=\s|$)/.test(cmd)) { reasons.push('-n (== --no-verify for git commit)'); } if (/core\.hooksPath/i.test(cmd)) { reasons.push('core.hooksPath override'); } if (/\bGIT_CONFIG_PARAMETERS\b/.test(cmd)) { reasons.push('GIT_CONFIG_PARAMETERS env override'); } if (reasons.length === 0) process.exit(0); process.stderr.write( 'ERROR: --no-verify is forbidden. Fix the underlying issue instead of bypassing hooks.\n' + `Detected bypass: ${reasons.join(', ')}\n` ); process.exit(2); }); - hooks/inline-script-block.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // inline-script-block.js: PreToolUse hook (matcher: Bash) // Blocks every inline-script form of a JavaScript runtime (node, nodejs, // ts-node, tsx, bun, deno) while letting file-based invocations run. // // Blocked forms: eval, print, and interactive flags in any spelling (quoted, // combined short flags such as -pe, --eval=...), --input-type, stdin scripts // (bare "-", /dev/stdin, /dev/fd/N), data: URL preloads in flags or in // NODE_OPTIONS, input redirects with no script file (heredocs, here-strings, // "< file"), unresolved expansions ($F, "$@") in the flag window, in // NODE_OPTIONS, or as the command word itself ($CMD, "$@"), a bare runtime // with no arguments (a REPL, or a script piped in on stdin) whether alone or // under a wrapper (`pnpm node`, `timeout 5 node`, `sudo node`), bun repl, // and deno eval / repl / stdin / data: specifiers. Command strings handed to // a shell or wrapper (`bash -c`, `su -c`, `npm exec -c`, `bun exec`, `eval`) // get the same analysis, and so do the tokens after `bun x`. // // Allowed: `node file.mjs --flag -e value`, `node hook.js < payload.json`, // `node --version`, `node -r ./setup.js file.js`, `node $DIR/run.mjs`, // `deno run script.ts`, `bash -c 'ls -la'`, `bash script.sh`, // `$HOME/bin/tool args`, `which node`, `command -v node`. // // The deny rules in settings.json cover the plain spellings. This hook is the // backstop for disguised ones: wrappers such as `pnpm node`, `timeout 5 node`, // `npx tsx`; env-var preloads; redirects; absolute runtime paths. Analysis // failures fail open so the hook never wedges a Bash call. // Zero-width / formatting chars that would split tokens invisibly and // bypass ASCII detection. const ZERO_WIDTH = /[\u00AD\u200B-\u200F\u2060-\u2064\uFEFF]/g; const RUNTIMES = new Set(['node', 'nodejs', 'ts-node', 'tsx', 'bun', 'deno']); // Node-family flags that turn the invocation into an inline script or a REPL. const NODE_INLINE_FLAGS = new Set([ '-e', '--eval', '-p', '--print', '-i', '--interactive', '--input-type', ]); // Node-family flags that consume the next token as their value when written // without "=". const NODE_VALUE_FLAGS = new Set([ '-r', '--require', '--import', '--loader', '--experimental-loader', '--env-file', '--input-type', '--conditions', '-C', '--title', '--stack-size', '--max-old-space-size', '--max-semi-space-size', '--inspect-port', ]); // Deno flags that consume the next token as their value when written // without "=". Consuming them keeps a value from being mistaken for the // script specifier. const DENO_VALUE_FLAGS = new Set([ '-c', '--config', '--import-map', '--lock', '--cert', '--seed', '--location', '-L', '--log-level', '--ext', ]); // Shells whose "-c" option runs the next non-flag argument as a command // string. const SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'ksh', 'fish']); // Words that pass their remaining arguments on as the command to run, so a // runtime after them is still the segment's command: `pnpm node`, // `timeout 5 node`, `sudo node`, `pnpm exec node`. Flags, bare numbers (a // nice level), and durations (`timeout 5s`) may sit between them and the // runtime. const COMMAND_WRAPPERS = new Set([ 'env', 'command', 'builtin', 'exec', 'nohup', 'nice', 'time', 'timeout', 'stdbuf', 'sudo', 'xargs', 'pnpm', 'yarn', 'npm', 'npx', 'bunx', 'corepack', 'dlx', 'x', ]); // How many levels of wrapper command strings (`bash -c "bash -c ..."`) get // analyzed before the hook stops recursing. const MAX_WRAPPER_DEPTH = 4; const TAIL = 'Inline scripts are banned. Write the code to a file in the source tree and run that file.'; // Tokenize one command string, quote-aware, into segments. Each segment is an // array of tokens with quotes stripped, so `'-e'` and `"-e"` both become `-e`. // Unquoted shell operators (&&, ||, ;, |&, |, &, newline) end the current // segment. Unquoted redirects (<, <<, <<-, <<<, <&, <>, >, >>, >&, >|, &>, // &>>) and process substitutions (<( ... ), >( ... )) become their own tokens // so the analysis can tell a redirect target from a script path. Unquoted // parentheses and backticks are token boundaries, so `$(node -e x)` and // `(node -e x)` still expose the runtime token. function splitSegments(cmd) { const segments = []; let tokens = []; let current = ''; let hasToken = false; let i = 0; const endToken = () => { if (hasToken) tokens.push(current); current = ''; hasToken = false; }; const endSegment = () => { endToken(); if (tokens.length > 0) segments.push(tokens); tokens = []; }; const pushOperator = (op) => { endToken(); tokens.push(op); }; while (i < cmd.length) { const ch = cmd[i]; const next = cmd[i + 1]; if (ch === '\\') { if (next === undefined) { i += 1; continue; } // backslash-newline is a line continuation if (next === '\n') { i += 2; continue; } current += next; hasToken = true; i += 2; continue; } // $'...' and $"..." quote like '...' and "..." for our purposes if (ch === '$' && (next === "'" || next === '"')) { i += 1; continue; } if (ch === "'") { const close = cmd.indexOf("'", i + 1); hasToken = true; if (close === -1) { current += cmd.slice(i + 1); i = cmd.length; continue; } current += cmd.slice(i + 1, close); i = close + 1; continue; } if (ch === '"') { let j = i + 1; hasToken = true; while (j < cmd.length && cmd[j] !== '"') { if (cmd[j] === '\\' && j + 1 < cmd.length && '"\\$`\n'.includes(cmd[j + 1])) { if (cmd[j + 1] !== '\n') current += cmd[j + 1]; j += 2; continue; } current += cmd[j]; j += 1; } i = j + 1; continue; } if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '(' || ch === ')' || ch === '`') { endToken(); i += 1; continue; } if (ch === '\n' || ch = - hooks/inline-script-block.test.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // inline-script-block.test.js: tests for the inline-script-block PreToolUse // hook. Each case spawns the hook with a Bash tool_input on stdin and checks // the permission decision. // // Run with: node --test /Users/cgraff/.work/hooks/inline-script-block.test.js const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const hookPath = path.join(__dirname, 'inline-script-block.js'); const BAN_SENTENCE = 'Inline scripts are banned. Write the code to a file in the source tree and run that file.'; function runHook(input) { return spawnSync(process.execPath, [hookPath], { input, encoding: 'utf8' }); } function decision(command) { const result = runHook(JSON.stringify({ tool_name: 'Bash', tool_input: { command } })); if (result.stdout === '' && result.status === 0) return 'allow'; const parsed = JSON.parse(result.stdout); if (parsed.hookSpecificOutput.permissionDecision === 'deny') return 'deny'; return `unexpected output: ${result.stdout}`; } const DENY = [ // eval / print / interactive flags in any spelling 'node -e "console.log(1)"', "node '-e' 'x'", 'node "-e" x', 'node --eval x', 'node --eval=x', 'node -p 1', 'node -pe 1', 'node -ep 1', 'node -ie', 'node -i', 'node --interactive', 'node --print 1', 'node --print=1', 'node --input-type=module -e x', 'node --input-type module', 'node --no-warnings -e x', "node $'-e' x", 'node -e x file.js', // stdin scripts 'node -', 'node - --flag', 'node --input-type=module -', 'node -- -', 'node /dev/stdin', 'node /dev/fd/0', 'node <<EOF\nconsole.log(1)\nEOF', 'node <<-EOF\nconsole.log(1)\nEOF', 'node < script.js', "node <<< 'x'", 'node --no-warnings < script.js', 'node <(echo x)', 'node 0< script.js', // data: URL preloads "node --import 'data:text/javascript,console.log(1)'", 'node --import=data:text/javascript,x', 'node --loader data:text/javascript,x file.js', 'node -r data:text/javascript,x file.js', "NODE_OPTIONS='--import=data:text/javascript,x' node --version", 'export NODE_OPTIONS=--import=data:text/javascript,x', 'env NODE_OPTIONS="--import=data:text/javascript,x" node file.js', // wrappers, env prefixes, absolute runtime paths 'pnpm node -e x', 'yarn node -e x', 'npx tsx -e x', 'timeout 5 node -e x', 'FOO=1 node -e x', '/Users/cgraff/.nodenv/shims/node -e x', './node_modules/.bin/tsx -e x', 'echo $(node -e x)', '(node -e x)', 'xargs node -e x', // other runtimes 'nodejs -e x', 'ts-node -e x', 'ts-node -p 1', 'tsx -e x', 'bun -e x', 'bun -p 1', 'bun -', // deno "deno eval 'x'", 'deno repl', 'deno run -', 'deno run -A -', 'deno run /dev/stdin', "deno run 'data:application/typescript,x'", 'deno run --import-map map.json -', 'deno <<EOF\nx\nEOF', 'deno run < script.ts', // chained segments 'git status && node -e x', 'ls; node -p 1', 'ls || node -e x', 'echo x | node -e x', 'node -e x &', 'ls\nnode -e x', // line continuation and zero-width evasion (U+200B zero-width space, U+FEFF BOM) 'node \\\n -e x', `node ${String.fromCharCode(0x200b)}-e x`, `node -${String.fromCharCode(0x200b)}e x`, `node${String.fromCharCode(0xfeff)} -e x`, // command strings run by a shell or wrapper "bash -c 'node -e x'", 'zsh -lc "node -p 1"', "sh -c 'node - <<EOF\nx\nEOF'", '/bin/bash -c "node -e x"', 'bash -o pipefail -c "node -e x"', "timeout 5 bash -c 'node -e x'", "su -c 'node -e x'", "su - root -c 'node -e x'", "npm exec -c 'node -e x'", "npm x -c 'node -e x'", 'npx -c "node -e x"', "bun exec 'node -e x'", 'eval "node -e x"', 'eval node -e x', `bash -c "bash -c 'node -e x'"`, // unresolved expansions in the flag window and NODE_OPTIONS 'F=-e\nnode $F x', 'node $F x', 'node ${F} x', 'node "$@"', 'node --import "$U" file.js', 'node --max-old-space-size=$MEM build.js', 'NODE_OPTIONS="--import=$U" node file.js', 'deno run $S', 'deno run --config $C script.ts', // bun subcommands 'bun repl', 'bun run -', 'bun run --watch -', // bare runtime: a REPL, or a script piped in on stdin 'node', 'bun', 'tsx', 'deno', 'node 2>&1', 'echo x | node', // bare runtime under a wrapper 'pnpm node', 'yarn node', 'timeout 5 node', 'timeout 5s node', 'timeout 1.5s node', 'timeout 2m node', 'timeout --signal=KILL 5s node', 'sudo node', 'env node', 'command node', 'exec node', 'nohup node', 'stdbuf -oL node', 'xargs node', 'npx node', 'pnpm exec node', // bun x runs the tokens after it as a fresh command 'bun x tsx -e x', 'bun x tsx -p 1', 'bunx tsx -e x', // a command word that is an unresolved expansion 'bash -c "$CMD"', "zsh -c '$CMD'", 'eval "$CMD"', 'eval $CMD', 'CMD="node -e x"\n$CMD', '"$@"', '$(which node) -e x', ]; const ALLOW = [ // file-based invocations 'node file.mjs', 'node file.mjs --flag -e value', 'node script.js -p 3000', 'node script.js -', 'node -- file.js', 'node --version', 'node -v', 'node --env-file=.env server.js', 'node -r ./setup.js file.js', 'node --require ./setup.js file.js', 'node --import ./register.mjs file.js', 'node --import=./register.mjs file.js', 'node --test hooks/inline-script-block.test.js', 'node hook.js < payload.json', 'node hook.js <<EOF\n{"a":1}\nEOF', 'node hook.js 2>&1', 'node --max-old-space-size=4096 build.js', 'node --max-old-space-size 4096 build.js', 'node -C development file.js', 'node --title myapp file.js', 'node ~/.work/skills/archify/bin/archify.mjs deliver architecture spec.json out.html --quality showcase --json', 'node build.js --import=data:text/javascript,x', 'pnpm node file.js', 'timeout 5 node file.js', 'timeout 5s node file.js', 'FOO=1 node file.js', 'NODE_OPTIONS=--max-old-space-size=4096 node build.js', 'NODE_OPTI - hooks/pnpm-root-command-block.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // pnpm-root-command-block.js — PreToolUse hook (matcher: Bash) // Blocks running `test`, `lint`, or `check-types`/`tsc` across the // frontend-code monorepo ROOT (pnpm, or the legacy yarn form) — including any // git worktree of it. Detection is based on the package.json at the session // cwd having name === "@attentive/frontend-code". // // Allowed: package-scoped runs (`--filter`, `-F`, `--dir`, `-C`), any command // with a `cd` before the run, script variants like `test:vitest`, and runs // from inside a subpackage. Explicit workspace-wide runs (`-r`/`--recursive`/ // `-w`/`--workspace-root`) are blocked from anywhere. const fs = require('fs'); const path = require('path'); const ROOT_PKG_NAME = '@attentive/frontend-code'; // A gated script target invoked via pnpm or yarn: `pnpm test`, `pnpm run lint`, // `yarn check-types`, `pnpm tsc`, etc. const GATED = /(^|[;&|\s])(pnpm|yarn)(\s+run)?\s+(test|lint|check-types|tsc)(\s|$)/; // Direct type-check via the package runner: `pnpm exec tsc`. const EXEC_TSC = /(^|[;&|\s])(pnpm|yarn)\s+exec\s+tsc(\s|$)/; // A `cd <dir>` segment before the run — trust that the user changed directory. const CD_BEFORE = /(^|[;&|\s])cd\s+\S+.*?(?:&&|\|\||;|\|)\s*(pnpm|yarn)\b/; // Package-scoping flags — the run targets a specific package, so allow it. const SCOPED = /(^|\s)(--filter|-F|--dir|-C)(\s|=)/; // Explicit workspace-wide flags — block from anywhere. const RECURSIVE = /(^|\s)(-r|--recursive|-w|--workspace-root)(\s|$)/; // MAX_PAYLOAD_BYTES: the lead's budget, set 2026-09-10 (well above GitHub's 65,536-byte body cap plus flags); a larger payload is not a tool call this hook can act on. const MAX_PAYLOAD_BYTES = 1048576; let raw = ''; let overflow = false; let bytes = 0; process.stdin.setEncoding('utf8'); process.stdin.on('data', d => { if (overflow) return; bytes += Buffer.byteLength(d, 'utf8'); if (bytes > MAX_PAYLOAD_BYTES) { overflow = true; raw = ''; return; } raw += d; }); process.stdin.on('end', () => { if (overflow) process.exit(0); let data; try { data = JSON.parse(raw); } catch { process.exit(0); } if (data.tool_name !== 'Bash') process.exit(0); const cmd = String(data.tool_input?.command ?? '').trim(); if (!cmd) process.exit(0); if (!GATED.test(cmd) && !EXEC_TSC.test(cmd)) process.exit(0); if (CD_BEFORE.test(cmd)) process.exit(0); // Explicit workspace-wide runs are blocked regardless of cwd. Otherwise a // package-scoped run is fine from anywhere, and a bare run is only blocked // when the session cwd is the monorepo root. if (!RECURSIVE.test(cmd)) { if (SCOPED.test(cmd)) process.exit(0); const cwd = data.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); const pkgPath = path.join(cwd, 'package.json'); let pkg; try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { process.exit(0); } if (pkg?.name !== ROOT_PKG_NAME) process.exit(0); } const reason = `Blocked: running \`test\`/\`lint\`/\`check-types\` across the frontend-code monorepo root.\n` + `These fan out across the entire workspace. Scope to the affected package:\n` + ` pnpm --filter @attentive/<pkg> test <path>\n` + ` pnpm --filter @attentive/<pkg> lint <path>\n` + ` pnpm --filter @attentive/<pkg> check-types\n` + `Or cd into the package dir first (libs/<pkg>, mfes/<pkg>, apps/<pkg>).`; process.stderr.write(`\n⛔ ${reason}\n\n`); console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, }, })); }); - hooks/policy-helper.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; /** * policy-helper.js: managed-settings policy helper (the profile gatekeeper). * * Claude Code runs this executable at startup because the drop-in * /Library/Application Support/ClaudeCode/managed-settings.d/50-profile-gate.json * names it under `policyHelper`. The JSON envelope it prints to stdout, * {"managedSettings": {...}}, becomes the only managed settings for the * session. It receives no arguments and reads no stdin. * * Two profiles, chosen by CLAUDE_CONFIG_DIR: * - work: CLAUDE_CONFIG_DIR resolves (realpath) to ~/.work. IT's * managed-settings.json passes through verbatim, minus its `policyHelper` * key, so a future IT helper entry cannot recurse through this one. * - personal: anything else, including unset or unresolvable. Only IT's * `permissions.deny` list survives, plus an `env` block that pins every * variable IT's `env` block defines to a neutral value. Claude Code merges * `env` per variable across every admin source, including the file this * helper supersedes, so a variable the personal output left unset would * fill in from IT's file. No org allow rules, no org plugins. * * On every run, in both profiles, the helper also captures IT's managed * CLAUDE.md and quiets the original. That file loads by path in every session * and no setting can exclude it, and the Jamf "AI Guardrails" policy rewrites * it on its own schedule, so the capture repeats on every start. When the text * of /Library/Application Support/ClaudeCode/CLAUDE.md is anything other than * the quiet marker, the helper copies it to ~/.work/org-CLAUDE.md (only when * the copy differs) and rewrites IT's file as the marker, one block HTML * comment naming where the text went. Claude Code strips block HTML comments * before injection, so the quieted file loads as nothing; ~/.work/CLAUDE.md * imports the copy, so work sessions still get IT's text. The copy is written * first, and the original is quieted only when that write succeeded or was * not needed. Every read and write is wrapped, and the whole capture runs in * its own try/catch, so it can never affect the envelope or the exit code. * * A non-zero exit or invalid JSON makes Claude Code refuse to start, so every * failure path degrades to {"managedSettings":{}} with exit 0. Nothing is * written to stderr. The managed file path is fixed; tests exercise the * exported functions directly, and the capture takes its two paths as * parameters so tests can point it at a temp directory. */ const fs = require('fs'); const os = require('os'); const path = require('path'); const MANAGED_SETTINGS_PATH = '/Library/Application Support/ClaudeCode/managed-settings.json'; const ORG_CLAUDE_MD_PATH = '/Library/Application Support/ClaudeCode/CLAUDE.md'; const WORK_DIR = path.join(os.homedir(), '.work'); const ORG_SNAPSHOT_PATH = path.join(WORK_DIR, 'org-CLAUDE.md'); const QUIET_MARKER = '<!-- Managed by the Jamf "AI Guardrails" policy. This file is kept empty on purpose. Its text is copied to /Users/cgraff/.work/org-CLAUDE.md by /Users/cgraff/.work/hooks/policy-helper.js and loads only in work sessions. -->\n'; function isPlainObject(value) { if (value === null) return false; if (typeof value !== 'object') return false; if (Array.isArray(value)) return false; return true; } // Resolves a path with realpath. Returns null for a non-string, an empty // string, or any path realpath cannot resolve (missing, unreadable, loop). function resolveRealPath(candidate) { if (typeof candidate !== 'string') return null; if (candidate === '') return null; try { return fs.realpathSync(candidate); } catch { return null; } } function isWorkProfile(configDir, workDir) { const resolvedConfigDir = resolveRealPath(configDir); if (resolvedConfigDir === null) return false; const resolvedWorkDir = resolveRealPath(workDir); if (resolvedWorkDir === null) return false; return resolvedConfigDir === resolvedWorkDir; } // Parses IT's managed-settings.json text. Anything that is not a JSON object // (invalid JSON, empty text, an array, null, a scalar) is an empty policy. function parsePolicy(managedText) { if (typeof managedText !== 'string') return {}; try { const parsed = JSON.parse(managedText); if (!isPlainObject(parsed)) return {}; return parsed; } catch { return {}; } } // Work profile: the policy verbatim, minus `policyHelper`. Builds a new // object; the parsed input is never mutated. function withoutPolicyHelper(policy) { return Object.keys(policy).reduce((acc, key) => { if (key === 'policyHelper') return acc; return { ...acc, [key]: policy[key] }; }, {}); } // Personal profile, part one: only a non-empty `permissions.deny` array of // strings survives; anything else is an empty policy. function denyOnly(policy) { const permissions = policy.permissions; if (!isPlainObject(permissions)) return {}; const deny = permissions.deny; if (!Array.isArray(deny)) return {}; if (deny.length === 0) return {}; if (!deny.every(rule => typeof rule === 'string')) return {}; return { permissions: { deny: [...deny] } }; } // Personal profile, part two: every variable IT's `env` object defines is // pinned to a neutral value. CLAUDE_CODE_ENABLE_TELEMETRY becomes '0', a key // ending in _EXPORTER becomes 'none', every other key becomes ''. A missing, // non-object, or empty env is an empty policy. Builds a new object; the // parsed input is never mutated. function neutralEnvOnly(policy) { const env = policy.env; if (!isPlainObject(env)) return {}; const keys = Object.keys(env); if (keys.length === 0) return {}; return { env: keys.reduce((acc, key) => { if (key === 'CLAUDE_CODE_ENABLE_TELEMETRY') return { ...acc, [key]: '0' }; if (key.endsWith('_EXPORTER')) return { ...acc, [key]: 'none' }; return { ...acc, [key]: '' }; }, {}), }; } // The whole decision, free of process globals so - hooks/policy-helper.test.jsGitHub
- hooks/pr-body-limits-block.jsGitHub
- hooks/pr-body-limits-block.test.jsGitHub
All 9 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withrules-for-claude
My global Claude Code setup, mirrored from ~/.claude: the rules, the hook scripts that enforce them, and the global CLAUDE.md.
Get the whole plugin
Stats
4
Stars
1
Forks
Active
Maintenance
HTML
Language
10d ago
Last commit
2mo ago
Created
Repo: chancegraff/rules-for-claude

