Files
claude-plugin/plugins/guards/hooks/bash-guard.mjs
T
mroberts d3258b224a Route isolated checkouts to jj workspaces
bash-guard mapped 20 mutating git verbs to their jj equivalents but not
`worktree`, so `git worktree add` passed the hook untouched. Claude Code's
built-in EnterWorktree/ExitWorktree tools were a second hole: they create a
git worktree directly, never going through Bash, so the guard never saw them.

A git worktree in a jj repo is not a jj workspace. jj does not manage it, it
never appears in `jj workspace list`, and none of jj's workspace bookkeeping
applies to it -- the isolated checkout ends up outside the VCS that owns the
repo.

Add the `worktree` entry to the git->jj map and a PreToolUse matcher on
EnterWorktree|ExitWorktree that exits 2 with the jj workspace commands on
stderr. The tool matcher replaces a `permissions.deny` entry in user
settings.json: it travels with the plugin and names the replacement command
instead of failing silently.

Read-only `git worktree list` is blocked along with the rest of the verb.
It cannot see jj workspaces, so its empty output reads as "no isolated
checkouts exist" when several do -- worse than a denial.

Verified by running the guard against `git worktree add ../feature` over
socket stdin and confirming both the denial and that the reason names
`jj workspace add`. The new checks fail against the 1.1.1 map.

Tests: 12 passing (bash-guard).
2026-07-28 13:07:00 -05:00

460 lines
18 KiB
JavaScript

#!/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 ?? '<absent>'} 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 <bookmark>`)',
fetch: 'jj git fetch',
clone: 'jj git clone <url>',
init: 'jj git init --colocate',
checkout: 'jj new <rev> (start new change) or `jj edit <rev>` (edit existing)',
switch: 'jj new <rev> or `jj edit <rev>`',
reset: 'jj restore (discard working-copy changes) or `jj abandon <rev>`',
restore: 'jj restore <path>',
revert: 'jj backout -r <rev>',
merge: 'jj new <rev1> <rev2> (creates a merge change)',
rebase: 'jj rebase -d <dest>',
'cherry-pick': 'jj duplicate <rev> then `jj rebase -d <dest>`',
stash: 'nothing — jj auto-snapshots; `jj new` sets work aside',
branch: 'jj bookmark set <name> (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 ../<name> (jj calls worktrees "workspaces"; `jj --no-pager workspace list` to list, `jj workspace forget <name>` 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*<noreply@anthropic\.com>/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/<name>/skills/<skill>/) 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);