Files
upstream-atomic-crm/.claude/hooks/bash-guard.mjs
upstream-mirror 93ecc1a6f8
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:48:50 +05:00

243 lines
9.9 KiB
JavaScript
Executable File

#!/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 <sessionDir>/{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 <sessionDir>/reviews (review-verdict flags) or
// <sessionDir>/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/<flag>"` — 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 <session_dir>/reviews or <session_dir>/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);