#!/usr/bin/env node // PreToolUse:Bash guard — mechanically enforces the hard constraints in CLAUDE.md. // 1. mutating git commands -> denied, with the jj equivalent handed back // 2. Claude attribution -> denied in commit / PR text // 3. push or tag -> build + test + lint gate, secret scan, workflow lint // // Denials return permissionDecision:"deny"; its reason reaches the model, so every // block teaches the correct command instead of just failing. import { readFileSync, existsSync, readdirSync, appendFileSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname, basename } from 'path'; // NOTE: blocking works only when Claude Code runs WITHOUT --dangerously-skip-permissions. // Under that flag the hook still executes but every decision below is discarded. const deny = (reason) => { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, }, })); process.exit(0); }; // Read fd 0 directly. Claude Code delivers the payload on a socket, and opening it by path // ('/dev/stdin' -> /proc/self/fd/0) fails ENXIO. readFileSync(0) is read() with no open(). let input; try { input = JSON.parse(readFileSync(0, 'utf8')); } catch (e) { // Never swallow this silently: a guard that dies here is indistinguishable from one that // never ran, which is exactly what hid the ENXIO bug for three sessions. try { appendFileSync('/tmp/bash-guard-mode.log', `stdin-FAILED err=${e?.message ?? e}\n`); } catch {} process.exit(0); } const command = input?.tool_input?.command ?? ''; if (!command.trim()) process.exit(0); // TEMP diagnostic — remove once permission-mode behaviour is settled. try { appendFileSync('/tmp/bash-guard-mode.log', `mode=${input?.permission_mode ?? ''} cmd=${command.slice(0, 50)}\n`); } catch {} // ---------------------------------------------------------------- segmenting // Quote-aware split on shell separators, so a separator inside a commit message // cannot produce a bogus segment and a false denial. function segments(cmd) { const out = []; let cur = '', quote = null; for (let i = 0; i < cmd.length; i++) { const c = cmd[i]; if (quote) { if (c === '\\' && quote === '"') { cur += c + (cmd[++i] ?? ''); continue; } if (c === quote) quote = null; cur += c; continue; } if (c === '"' || c === "'") { quote = c; cur += c; continue; } if (c === '\n' || c === ';' || c === '|' || c === '&') { if ((c === '|' || c === '&') && cmd[i + 1] === c) i++; out.push(cur); cur = ''; continue; } cur += c; } out.push(cur); return out.map((s) => s.trim()).filter(Boolean); } // First real word, skipping `env`, leading VAR=... assignments and sudo. function head(seg) { const toks = seg.split(/\s+/).filter(Boolean); let i = 0; while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || toks[i] === 'env' || toks[i] === 'sudo')) i++; return { cmd: toks[i] ? basename(toks[i]) : '', args: toks.slice(i + 1) }; } const segs = segments(command); // ------------------------------------------------- 1. mutating git -> jj map const GIT_TO_JJ = { commit: 'jj describe -m "msg" (then `jj new` to begin the next change)', add: 'nothing — jj tracks working-copy changes automatically', rm: 'just delete the file — jj picks it up automatically', mv: 'just move the file — jj picks it up automatically', push: 'jj git push', pull: 'jj git fetch (then `jj rebase -d `)', fetch: 'jj git fetch', clone: 'jj git clone ', init: 'jj git init --colocate', checkout: 'jj new (start new change) or `jj edit ` (edit existing)', switch: 'jj new or `jj edit `', reset: 'jj restore (discard working-copy changes) or `jj abandon `', restore: 'jj restore ', revert: 'jj backout -r ', merge: 'jj new (creates a merge change)', rebase: 'jj rebase -d ', 'cherry-pick': 'jj duplicate then `jj rebase -d `', stash: 'nothing — jj auto-snapshots; `jj new` sets work aside', branch: 'jj bookmark set (jj calls branches "bookmarks")', clean: 'jj restore (or delete the untracked files directly)', apply: 'apply the patch to the working copy, then `jj describe`', am: 'apply the patch to the working copy, then `jj describe`', // `list` is read-only but blocked with the rest: it cannot see jj workspaces, so its // empty output reads as "no isolated checkouts exist" when several do. worktree: 'jj workspace add ../ (jj calls worktrees "workspaces"; `jj --no-pager workspace list` to list, `jj workspace forget ` then delete the directory to remove)', }; for (const seg of segs) { const { cmd, args } = head(seg); if (cmd !== 'git') continue; // `jj git push` heads on `jj`, so it passes through const verb = args.find((a) => !a.startsWith('-')); const fix = GIT_TO_JJ[verb]; if (fix) { deny( `BLOCKED: \`git ${verb}\` — this repo is managed with jj (CLAUDE.md: version control is jj only; raw git can corrupt jj state).\n\n` + `Use instead:\n ${fix}\n\n` + `Read-only git (status, log, diff, show, rev-parse) is still allowed. New bookmarks follow \`malcolm/change-being-made\`.` ); } } // ------------------------------------------- 2. no Claude attribution, ever const ATTRIBUTION = [ /co-?authored-?by:\s*claude/i, /generated with \[?claude/i, /🤖\s*generated with/i, /created by claude/i, /claude(-code)?\s*/i, ]; const AUTHORS_TEXT = /\b(jj\s+(describe|commit|new|split)|gh\s+pr\s+(create|edit|comment)|gh\s+release\s+create)\b/; if (AUTHORS_TEXT.test(command)) { const hit = ATTRIBUTION.find((re) => re.test(command)); if (hit) { deny( 'BLOCKED: Claude attribution in a commit message or PR body (CLAUDE.md: no Claude attribution anywhere).\n\n' + 'Remove the "Generated with Claude Code" line and any `Co-Authored-By: Claude` trailer, then retry.\n' + 'Malcolm adds attribution manually when it is warranted — never add it unprompted.' ); } } // -------------------------------------------------- 3. push / tag gate const isPush = /\bjj\s+git\s+push\b/.test(command); const isTag = segs.some((s) => { const { cmd, args } = head(s); return (cmd === 'git' && args[0] === 'tag') || (cmd === 'jj' && args[0] === 'tag'); }); if (!isPush && !isTag) process.exit(0); // Repo root: CLAUDE_PROJECT_DIR is authoritative; otherwise walk up for a workspace marker. function repoRoot() { if (process.env.CLAUDE_PROJECT_DIR && existsSync(process.env.CLAUDE_PROJECT_DIR)) { return process.env.CLAUDE_PROJECT_DIR; } let dir = input?.cwd || process.cwd(); for (;;) { if (existsSync(join(dir, '.jj')) || existsSync(join(dir, '.git'))) return dir; const up = dirname(dir); if (up === dir) return input?.cwd || process.cwd(); dir = up; } } const ROOT = repoRoot(); const SKIP = new Set([ 'node_modules', '.git', '.jj', 'target', 'bin', 'obj', 'dist', 'build', '.venv', 'venv', '__pycache__', 'vendor', '.next', 'out', 'packages', ]); // Marker files are searched to depth 4 so monorepo layouts (back-end/, front-end/, // services/*) and plugin trees (plugins//skills//) are found without // hardcoding any particular directory naming. SKIP keeps the walk cheap. function findProjects(root, depth = 0) { let found = []; let entries; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return found; } const names = entries.filter((e) => e.isFile()).map((e) => e.name); const has = (re) => names.some((n) => re.test(n)); if (has(/\.(sln|slnx)$/)) found.push({ kind: 'dotnet', dir: root }); else if (has(/\.(csproj|fsproj|vbproj)$/)) found.push({ kind: 'dotnet', dir: root }); if (names.includes('package.json')) found.push({ kind: 'node', dir: root }); if (names.includes('go.mod')) found.push({ kind: 'go', dir: root }); if (names.includes('Cargo.toml')) found.push({ kind: 'rust', dir: root }); if (names.includes('pyproject.toml') || names.includes('setup.py')) found.push({ kind: 'python', dir: root }); if (names.includes('CMakeLists.txt')) found.push({ kind: 'cmake', dir: root }); else if (names.includes('Makefile') || names.includes('makefile')) found.push({ kind: 'make', dir: root }); if (depth < 4) { for (const e of entries) { if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) { found = found.concat(findProjects(join(root, e.name), depth + 1)); } } } return found; } // `dotnet test` exits non-zero in a directory that holds no test project, so the test // step is gated on finding one rather than assumed present for every .csproj. const TEST_MARKER = /IsTestProject|Microsoft\.NET\.Test\.Sdk|TUnit|xunit|NUnit|MSTest/i; function hasTestProject(dir, depth = 0) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; } for (const e of entries) { if (e.isFile() && /\.(csproj|fsproj|vbproj)$/.test(e.name)) { try { if (TEST_MARKER.test(readFileSync(join(dir, e.name), 'utf8'))) return true; } catch { /* unreadable */ } } } if (depth < 4) { for (const e of entries) { if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) { if (hasTestProject(join(dir, e.name), depth + 1)) return true; } } } return false; } function npmScripts(dir) { try { return Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts ?? {}); } catch { return []; } } // A bare `ruff`/`pytest` is usually absent from PATH, and run() treats ENOENT as a pass — // so an unresolved tool would silently skip the gate. Resolve against the project's own // venv first, then uv (which needs a [project] table), then the interpreter's -m fallback. // An absent tool is reported rather than blocking: it is a gap in coverage, not a defect // in the change being pushed. Silence is the one outcome that is never acceptable. const skipped = []; function pyTool(dir, tool, args) { const venv = join(dir, '.venv', 'bin', tool); if (existsSync(venv)) return [venv, args]; if (existsSync(join(dir, 'uv.lock')) && pyprojectHasProject(dir)) return ['uv', ['run', tool, ...args]]; // PATH before `python -m`: ruff ships as a standalone binary and is never importable, // so probing for a module would declare an installed ruff missing. if (onPath(tool)) return [tool, args]; if (pyModuleAvailable(tool)) return ['python', ['-m', tool, ...args]]; skipped.push({ dir, tool }); return null; } function onPath(tool) { try { execFileSync(tool, ['--version'], { stdio: 'ignore' }); return true; } catch (e) { // A non-zero exit still proves the binary exists; only ENOENT means absent. return e.code !== 'ENOENT'; } } function pyprojectHasProject(dir) { try { return /^\[project\]/m.test(readFileSync(join(dir, 'pyproject.toml'), 'utf8')); } catch { return false; } } function pyModuleAvailable(tool) { try { execFileSync('python', ['-c', `import ${tool}`], { stdio: 'ignore' }); return true; } catch { return false; } } // `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors. function checksFor(p) { switch (p.kind) { case 'dotnet': { const checks = [['dotnet', ['build', '-warnaserror']]]; if (hasTestProject(p.dir)) checks.push(['dotnet', ['test', '--no-build']]); return checks; } case 'node': { const s = npmScripts(p.dir); return [ s.includes('lint') && ['npm', ['run', 'lint']], s.includes('build') && ['npm', ['run', 'build']], s.includes('test') && ['npm', ['test', '--if-present']], ].filter(Boolean); } case 'go': return [['go', ['build', './...']], ['go', ['vet', './...']], ['go', ['test', './...']]]; case 'rust': return [['cargo', ['clippy', '--all-targets', '--', '-D', 'warnings']], ['cargo', ['test']]]; case 'python': return [pyTool(p.dir, 'ruff', ['check', '.']), pyTool(p.dir, 'pytest', ['-q'])].filter(Boolean); case 'cmake': // cmake needs a configured build dir; without one there is nothing safe to drive. return existsSync(join(p.dir, 'build', 'CMakeCache.txt')) ? [['cmake', ['--build', 'build']]] : []; case 'make': return [['make']]; default: return []; } } function run(bin, args, cwd) { try { execFileSync(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); return null; } catch (e) { if (e.code === 'ENOENT') return null; // toolchain absent: not a policy failure // Strip ANSI: this text is handed to the model, and escape codes are noise to it. const out = `${e.stdout ?? ''}${e.stderr ?? ''}` .replace(/\[[0-9;]*m/g, '') .trim() .split('\n'); return out.slice(-40).join('\n'); } } // Only projects containing changed files are gated. Checking every project in a monorepo // lets an untouched project's cloud-dependent suite block every unrelated push. When the // change set cannot be determined, fall back to checking everything. function changedPaths(cwd) { const tries = [ // Merge base, not trunk() itself: diffing against a trunk that has moved on reports // trunk's own newer commits as changes and drags untouched projects into the gate. ['jj', ['diff', '--from', 'latest(::@ & ::trunk())', '--to', '@', '--name-only']], ['jj', ['diff', '--name-only']], ['git', ['diff', '--name-only', 'origin/HEAD...HEAD']], ['git', ['diff', '--name-only', 'HEAD']], ]; for (const [bin, args] of tries) { try { const out = execFileSync(bin, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); if (out) return out.split('\n').map((l) => l.trim()).filter(Boolean); } catch { /* try next strategy */ } } return null; } // Escape hatch: directories listed one per line in .guardignore at the repo root are never // gated. For suites that genuinely cannot pass locally (deployed-env integration tests). function ignoredDirs(root) { try { return readFileSync(join(root, '.guardignore'), 'utf8') .split('\n') .map((l) => l.replace(/#.*/, '').trim().replace(/\/+$/, '')) .filter(Boolean); } catch { return []; } } function scopeToChanges(all, root, cwd) { const ignored = ignoredDirs(root); const kept = all.filter((p) => { const rel = p.dir === root ? '.' : p.dir.slice(root.length + 1); return !ignored.some((i) => rel === i || rel.startsWith(`${i}/`)); }); const changed = changedPaths(cwd); // An empty list means "could not determine", not "nothing changed" — gate everything // rather than silently gating nothing. if (!changed || changed.length === 0) return kept; return kept.filter((p) => { if (p.dir === root) return true; const rel = `${p.dir.slice(root.length + 1)}/`; return changed.some((f) => f.startsWith(rel)); }); } const action = isPush ? 'Push' : 'Tag'; const projects = scopeToChanges(findProjects(ROOT), ROOT, input?.cwd || ROOT); // Advisory findings: reported to the model without blocking the call. const notes = []; for (const p of projects) { const rel = p.dir === ROOT ? '.' : p.dir.slice(ROOT.length + 1); for (const [bin, args] of checksFor(p)) { const fail = run(bin, args, p.dir); if (fail !== null) { deny( `${action} BLOCKED: \`${bin} ${args.join(' ')}\` failed in ${rel} (${p.kind}).\n\n` + `${fail}\n\n` + `CLAUDE.md: build and test must pass — zero errors, zero warnings — before any push or tag. Fix, then retry.` ); } } } for (const s of skipped) { const rel = s.dir === ROOT ? '.' : s.dir.slice(ROOT.length + 1); notes.push(`${s.tool} is not installed for ${rel}, so that check did not run.`); } // Secrets: assume every repo is public. // Scan '.' rather than an absolute path: gitleaks builds fingerprints from the path it is // given, and absolute fingerprints cannot be committed to a shared .gitleaksignore. const leaks = run('gitleaks', ['dir', '.', '--no-banner', '--redact', '-v'], ROOT); if (leaks !== null) { deny( `${action} BLOCKED: gitleaks found candidate secrets.\n\n${leaks}\n\n` + 'CLAUDE.md: never commit secrets, credentials, tokens, or .env files. Remove them, rotate anything exposed, then retry.' ); } // GitHub Actions are a supply-chain surface; lint and audit them before they ship. const wfDir = join(ROOT, '.github', 'workflows'); if (existsSync(wfDir)) { // Workflow files are passed explicitly: actionlint otherwise demands a .git // directory, which a non-colocated jj repo does not have. let wfFiles = []; try { wfFiles = readdirSync(wfDir) .filter((n) => /\.ya?ml$/.test(n)) .map((n) => join(wfDir, n)); } catch { wfFiles = []; } if (wfFiles.length) { const al = run('actionlint', wfFiles, ROOT); if (al !== null) { deny(`${action} BLOCKED: actionlint found workflow errors.\n\n${al}\n\nFix the workflow errors, then retry.`); } // Advisory, not blocking: zizmor's high-severity set includes unpinned-uses, // which fires on ordinary `@v4` tags. Surfaced to the model to raise with Malcolm. const zz = run('zizmor', ['--offline', '--min-severity', 'high', '--min-confidence', 'high', ...wfFiles], ROOT); if (zz !== null) { notes.push( `zizmor found high-severity GitHub Actions findings (advisory — the ${action.toLowerCase()} was NOT blocked):\n\n${zz}\n\n` + 'Tell Malcolm what was found and ask whether to fix now, suppress with `# zizmor: ignore[rule-name]`, or leave it.' ); } } } if (projects.length === 0) { // Fail open, but loudly — silence here would look identical to "everything passed". notes.push( `bash-guard found no recognized project layout under ${ROOT}, so NO build or test ran before this ${action.toLowerCase()}. ` + 'Only the secret and workflow scans were applied. Verify the change some other way before treating it as validated.' ); } // additionalContext without permissionDecision: the call proceeds through the normal // permission flow, and the model still receives these findings. if (notes.length) { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: notes.join('\n\n---\n\n').slice(0, 9500), }, })); } process.exit(0);