#!/usr/bin/env node // PreToolUse(Bash) — single guard for Bash commands. Blocks commands that open browser windows (headed Playwright, Vite --open) for every caller, blocks the orchestrator from mutating the review/dispatch guard state under /{reviews,breaker}, and blocks gated subagents from running validation commands (validate-on-stop.mjs runs them automatically on SubagentStop). import { readFileSync } from "node:fs"; import { createHookContext } from "./lib/context.mjs"; import { isDeveloper, isOrchestrator, isQualityReviewer, } from "./lib/teams.mjs"; import { loadConfig, validationSteps, extraForbidden, launcher, } from "./lib/config.mjs"; const input = JSON.parse(readFileSync(0, "utf8")); const ctx = createHookContext(input, "bash-guard"); const agent = input.agent_type || ""; const cmd = input.tool_input?.command || ""; if (!cmd) process.exit(0); // Browser rules — any caller: this sandbox has no display, a headed run hangs forever. const opensHeadedPlaywright = (c) => /playwright/.test(c) && /(screenshot|test|codegen)/.test(c) && !c.includes("--headless"); const opensViteBrowser = (c) => /(vite|npm run (dev|start|start-demo))/.test(c) && c.includes("--open"); const BROWSER_RULES = [ [ opensHeadedPlaywright, "Playwright must always use --headless. Add --headless to the command.", ], [ opensViteBrowser, "Vite must not use --open (opens a browser window). Remove the --open flag.", ], ]; const browserViolation = BROWSER_RULES.find(([matches]) => matches(cmd)); if (browserViolation) { ctx.block({ reason: browserViolation[1], log: `browser cmd=${cmd.slice(0, 120)}`, }); } // Guard-state rule — orchestrator only: the orchestrator must NEVER mutate the // hook state under /reviews (review-verdict flags) or // /breaker (planner/duplicate-dispatch markers). Those files ARE the // safety guards (block-merger-without-review, block-duplicate-dispatch) — an // orchestrator that touches/rm's them forges an approval or clears a debounce and // bypasses review entirely. // Match the orchestrator the same way block-nested-orchestrator does: agent_type // when present, else the CLAUDE_AGENT_NAME-derived ctx.agentType, allowing a // suffixed runtime name (orchestrator-…) and the chat- prefix. (isOrchestrator // lives in lib/teams.mjs so both gates share one predicate.) if (isOrchestrator(agent || ctx.agentType)) { // Match `reviews`/`breaker` as a path segment bounded on the left by `/` and // on the right by `/`, a quote, whitespace, or end-of-token. The trailing-slash // form alone missed the command the codebase actually teaches the reviewer to // use — `RD="$(dirname "$TICKET_FILE")/reviews" && touch "$RD/"` — whose // literal text is `…/reviews"` then `$RD/…`, never `/reviews/`. That let a // confused orchestrator forge a verdict flag through the documented form. const guardPath = /\/(reviews|breaker)(\/|["'\s]|$)/; const mutatingVerb = /(^|[;&|]|\bsudo\b|\bxargs\b)\s*(rm|touch|mkdir|mv|cp|truncate|ln)\b/.test( cmd, ) || /\bsed\s+(-[a-zA-Z]*i\b|--in-place)/.test(cmd) || /\|\s*tee\b/.test(cmd); const redirectToGuard = />>?\s*\S*\/(reviews|breaker)(\/|["'\s]|$)/.test(cmd); if ((mutatingVerb && guardPath.test(cmd)) || redirectToGuard) { ctx.block({ reason: "Refusing this command: the orchestrator must not write to or delete files under /reviews or /breaker — those ARE the review/dispatch guards. " + "If a merger dispatch was blocked for 'no APPROVED verdict', do NOT fabricate the flag and do NOT re-dispatch the reviewer: the reviewer writes its own flag on APPROVED (quality-reviewer.md). A missing flag means the reviewer did NOT approve — read its output file and act on the real verdict. " + "If a dispatch was blocked as 'still in flight', wait for its task-notification instead of clearing the marker.", log: `BLOCK orchestrator guard-state mutation cmd=${cmd.slice(0, 120)}`, }); } } // Validation rules — gated subagents only: the validation hooks already run // these; manual runs burn budget and can hang (vitest headed without CI=true). // Resolve identity the same robust way as the guard-state rule above: prefer the // payload agent_type, fall back to the CLAUDE_AGENT_NAME-derived ctx.agentType, // and match via the suffix-aware predicates — so a `developer-TASK-001` runtime // name (or an empty agent_type) is still gated, not silently waved through. const who = agent || ctx.agentType || ""; if (!isDeveloper(who) && !isQualityReviewer(who)) process.exit(0); const runsTypecheck = (c) => /(make\s+typecheck|npm\s+run\s+typecheck|npx\s+tsc(\s|$)|tsc\s+--noEmit)/.test( c, ); const runsPrettier = (c) => /(npm\s+run\s+prettier(:apply)?|npx\s+prettier(\s|$)|make\s+prettier)/.test( c, ); const runsUnitTests = (c) => /(npm\s+run\s+test(:unit)?(:[a-z]+)?|npm\s+test\b|npx\s+vitest|make\s+test(-unit)?(-[a-z]+)?)/.test( c, ); const runsE2eTests = (c) => /(npx\s+playwright\s+test|make\s+test-e2e)/.test(c); const runsLint = (c) => /(make\s+lint\b|npm\s+run\s+lint\b)/.test(c); const runsBuild = (c) => /(npx\s+vite\s+build|npm\s+run\s+build\b|make\s+build\b)/.test(c); const CATEGORY_RULES = { typecheck: [ runsTypecheck, "typecheck: validate-on-stop.mjs runs this automatically after you stop; read its stderr output instead.", ], prettier: [ runsPrettier, "prettier: the validation hooks auto-apply prettier and commit the result; don't run it manually.", ], unit: [ runsUnitTests, "unit tests: the validation hooks run vitest automatically. In this sandbox vitest browser mode HANGS without CI=true (chromium headed waits for display). Trust the hooks.", ], e2e: [ runsE2eTests, "e2e tests: the validation hooks run playwright in full mode only; in demo mode they're skipped. Don't run them manually.", ], lint: [ runsLint, "lint: validate-on-stop.mjs runs eslint on your changed files automatically after you stop; read its stderr output instead.", ], build: [ runsBuild, "build: don't run production builds during tickets; typecheck (run by the validation hooks) catches type errors.", ], }; // Which categories to guard comes from the SAME config the validation chain runs // (kills the triple-encoding: runner, guard, and doc no longer drift). Each // validation step's kind maps to a category (lint included — it is now a chain // step), plus validation.extraForbidden (build — never run during tickets, not // part of the chain). Fail-open to ALL categories if the config can't be read, // so a malformed config never weakens the guard. const KIND_TO_CATEGORY = { format: "prettier", typecheck: "typecheck", lint: "lint", unit: "unit", e2e: "e2e", }; let activeCategories; try { const cfg = loadConfig(); activeCategories = new Set([ ...validationSteps(cfg) .map((s) => KIND_TO_CATEGORY[s.kind]) .filter(Boolean), ...extraForbidden(cfg), ]); } catch { activeCategories = new Set(Object.keys(CATEGORY_RULES)); } const VALIDATION_RULES = [...activeCategories] .map((c) => CATEGORY_RULES[c]) .filter(Boolean); const violation = VALIDATION_RULES.find(([matches]) => matches(cmd)); if (violation) { ctx.block({ reason: `Validation command forbidden: ${violation[1]} See .claude/rules/validation-commands.md.`, log: `cmd=${cmd.slice(0, 120)}`, }); } // File-write rules — gated subagents only: code-writing agents must use the // Write/Edit tools, never Bash redirection/in-place edits. Bash writes bypass the // Write|Edit-only block-migration-writes guard (a developer could otherwise write a migration in // bash). // The managed-launcher log dir (e.g. CRM Builder's /chat-service/logs) is a // launcher extension point: config.launcher.logsDir. A redirect into it is // exempt (agents legitimately append their logs there). When unset, only // /dev/null is exempt and no chat-service path is hardcoded. let logsDir = ""; try { logsDir = launcher(loadConfig()).logsDir || ""; } catch { logsDir = ""; } const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const logsAlt = logsDir ? `${escapeRe(logsDir)}\\/\\S*|` : ""; const REDIRECT_TARGET_RE = new RegExp( `(^|[^0-9&])>>?\\s*(\\/dev\\/null\\b|${logsAlt}\\/|\\.\\.?\\/|~\\/|[a-zA-Z0-9._-]+\\/|[a-zA-Z0-9._-]+\\.[a-zA-Z0-9]+)`, "g", ); const writesRedirect = (c) => { // Evaluate the /dev/null and logs exemptions PER redirect, so an unrelated // `cmd 2>/dev/null` can't disarm detection of a real `> file` write in the // same command. for (const m of c.matchAll(REDIRECT_TARGET_RE)) { const target = m[2]; if (target === "/dev/null") continue; if (logsDir && target.startsWith(logsDir + "/")) continue; return true; } return false; }; const writesSedInPlace = (c) => /sed\s+(-[a-zA-Z]*i\b|--in-place)/.test(c); const writesAwkInPlace = (c) => /awk\s+-i\s+inplace/.test(c); const writesTee = (c) => { // tee writes to its file argument(s); skip leading flags (e.g. -a) to reach // the target, and exempt the /dev/null sink. Bare `| tee` (no file) only // duplicates to stdout — not a write. const m = c.match(/\|\s*tee\s+((?:-\S+\s+)*)(\S+)/); return !!m && m[2] !== "/dev/null"; }; const writesScript = (c) => /(node|python3?)\s+-[ecp].*(writeFileSync|writeFile|write_text|os\.write|fs\.write)/.test( c, ); const FILE_WRITE_RULES = [ [writesRedirect, "bash redirection to a file (> or >>)"], [writesSedInPlace, "sed -i (in-place edit)"], [writesAwkInPlace, "awk -i inplace"], [writesTee, "pipe to tee (file write)"], [writesScript, "scripted file write via node/python"], ]; const writeViolation = FILE_WRITE_RULES.find(([matches]) => matches(cmd)); if (writeViolation) { ctx.block({ reason: `File editing via Bash is forbidden: ${writeViolation[1]}. Use the Write or Edit tool instead — Bash writes bypass prettier/typecheck and the migration-write guard. See developer.md.`, log: `file-write cmd=${cmd.slice(0, 120)}`, }); } process.exit(0);