#!/usr/bin/env bash # PreToolUse hook: enforce project policies before tool execution. # # Policies enforced: # 1. No npx — use npm run scripts only # 2. No node_modules/.bin invocations — use npm run scripts only # 3. No direct node invocations of node_modules packages # 4. No python — use node for scripting # 5. No npm run build while dev server is running (port conflict) # 6. No sed -i / awk rewrites on code files — use replace_string_in_file # 7. No npm install without user confirmation — ask first # 8. No editing *.generated.ts files — edit the generator source instead # 9. No deleting .wireit — fix the underlying build config issue instead # 10. No -- --force with npm run scripts — wireit cache busting masks real problems # 11. No npm run format with specific file args — propagates to all workspaces # 12. No editing eslint.config.js files — ESLint config changes require human review # 13. No read_file with range >50 lines (enforced hard block) — except docs/ files and all .md/.txt files (limit 500) set -euo pipefail INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"/\1/' || true) # DEBUG: log every hook invocation with full input echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"hook\":\"pre-tool-use\",\"tool\":\"$TOOL_NAME\",\"raw\":$(echo "$INPUT" | node -e 'process.stdout.write(JSON.stringify(require("fs").readFileSync("/dev/stdin","utf8")))' 2>/dev/null || echo '""')}" >> /tmp/pre-tool-hook-debug.jsonl # Only inspect terminal/execution tools and file-editing tools case "$TOOL_NAME" in run_in_terminal|execution_subagent|send_to_terminal|\ replace_string_in_file|multi_replace_string_in_file|create_file|\ read_file|read|edit) ;; *) echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"hook\":\"pre-tool-use\",\"action\":\"early-exit\",\"tool\":\"$TOOL_NAME\"}" >> /tmp/pre-tool-hook-debug.jsonl exit 0 ;; esac # Extract command (terminal tools) or file path (file-editing tools) COMMAND="" FILE_PATH="" case "$TOOL_NAME" in run_in_terminal|execution_subagent|send_to_terminal) COMMAND=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(i.command || i.query || ''); " 2>/dev/null || true) ;; replace_string_in_file|multi_replace_string_in_file|create_file|edit) FILE_PATH=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; const p = i.filePath || (i.replacements && i.replacements[0] && i.replacements[0].filePath) || ''; process.stdout.write(p); " 2>/dev/null || true) ;; read_file|read) FILE_PATH=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(i.filePath || ''); " 2>/dev/null || true) ;; esac if [[ -z "$COMMAND" && -z "$FILE_PATH" ]]; then exit 0 fi # ── Helper: emit deny response ─────────────────────────────────────────────── deny() { local reason="$1" echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"hook\":\"pre-tool-use\",\"action\":\"DENY\",\"tool\":\"$TOOL_NAME\",\"reason\":$(echo "$reason" | node -e 'process.stdout.write(JSON.stringify(require("fs").readFileSync("/dev/stdin","utf8").trim()))' 2>/dev/null || echo '""')}" >> /tmp/pre-tool-hook-debug.jsonl cat </dev/null | grep -qE ':300[01]\s'; then deny "BLOCKED: npm run build conflicts with the running dev server (port 3000/3001 in use). Both write to dist/ and will crash. Stop the dev server first, or use npm run lint and npm test for verification instead." fi fi # ── Policy 6: No sed -i or awk in-place editing of code files ──────────────── # These tools corrupt structured code — use replace_string_in_file instead. if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)sed\s+[^|>]*-[a-zA-Z]*i'; then deny "BLOCKED: Do not use 'sed -i' to edit code files. Use replace_string_in_file for precise, context-aware edits. sed pattern matching frequently corrupts structured code with unintended replacements." fi if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)awk\s+.*>\s*[^/dev].*\.(ts|tsx|js|json|md)'; then deny "BLOCKED: Do not use awk to rewrite code files. Use replace_string_in_file for precise edits instead." fi # ── Policy 7: No npm install without user confirmation ─────────────────────── # Dependencies must be kept minimal. Always ask the user before adding packages. if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)npm\s+(install|i)(\s|$)'; then deny "BLOCKED: Do not run npm install without user confirmation. This project keeps dependencies minimal — always ask first. If a package is genuinely needed, propose it and let the user decide." fi # ── Policy 9: No deleting .wireit cache to paper over stale-cache issues ───── # Deleting .wireit forces a full cold rebuild which is very slow and masks the # real problem (bad cache key, fingerprint mismatch, etc.). # If wireit is returning cached results incorrectly, investigate and fix the # underlying issue in the affected package.json wireit configuration instead. if echo "$COMMAND" | grep -qE 'rm\s+.*\.wireit|rm\s+-[a-zA-Z]*rf?\s+.*\.wireit'; then deny "BLOCKED: Do not delete .wireit to force a cold rebuild. This masks a real wireit configuration problem. Investigate which script has a stale fingerprint or incorrect 'files' / 'output' declaration in package.json, then fix that instead. See wireit docs for cache invalidation." fi # ── Policy 10: No --force with npm run (wireit cache bust) ─────────────────── # Passing --force to wireit-backed scripts bypasses the cache and triggers a # full cold rebuild. This masks fingerprint/config bugs and slows CI. # If a script is using a stale cache, diagnose the wireit 'files'/'output' # config instead of forcing a rebuild. if echo "$COMMAND" | grep -qE 'npm\s+run\s+[a-zA-Z:_-]+\s+--\s+--force'; then deny "BLOCKED: Do not use -- --force with npm run scripts. This bypasses the wireit cache and masks configuration bugs. If a script returns stale results, check the 'files'/'output' declarations in its wireit config instead." fi # ── Policy 11: No npm run format with specific file args ───────────────────── # Running 'npm run format -- ' from the workspace root propagates the # extra argument to every workspace package's format script, causing each to # fail with 'No files matching the pattern'. Format runs on the whole package # directory by default — either run it without args or cd into the right # package first. if echo "$COMMAND" | grep -qE 'npm\s+run\s+format\s+--\s+\S'; then deny "BLOCKED: Do not pass file arguments to 'npm run format'. The extra arg propagates to every workspace package and causes failures. Run 'npm run format' without args to format all files, or cd into the specific package directory first." fi # ── Policy 14: No shell reads of workspace package.json files ──────────────── # Mirrors the OpenCode read tool guard: reading apps/*/package.json or # packages/*/package.json via cat/head/tail/jq bypasses the read block and # auto-injects every AGENTS.md in that subtree, exhausting the 32K context. # Reading root package.json is fine — only workspace sub-packages are blocked. if echo "$COMMAND" | grep -qE '(cat|head|tail|jq\s+-[a-zA-Z]*r?)\s+[^|>]*(apps|packages)/[^/[:space:]]+/package\.json'; then deny "BLOCKED: Do not use cat/head/tail/jq to read workspace package.json files (apps/*/package.json, packages/*/package.json). These files auto-inject AGENTS.md context that exhausts the model's 32K context window. Use 'npm run' scripts for dependency info, or read root package.json." fi if echo "$COMMAND" | grep -qE '(apps|packages)/[^/[:space:]]+/package\.json.*\|\s*(jq|cat|head|tail)'; then deny "BLOCKED: Do not pipe workspace package.json files (apps/*/package.json, packages/*/package.json) through jq or other readers. These files auto-inject AGENTS.md context that exhausts the model's 32K context window." fi # ── File path checks (replace_string_in_file / create_file / read_file tools) ─ # (Policy 8 is a file-path check — see below) if [[ -n "$FILE_PATH" ]]; then # ── Policy 8: No editing *.generated.ts files ────────────────────────────── if echo "$FILE_PATH" | grep -qE '\.generated\.ts$'; then deny "BLOCKED: Do not edit *.generated.ts files directly. These are auto-generated and will be overwritten on the next build. Edit the source files (controller.ts, routes.ts, business-logic.ts) instead and run 'npm run build:core' to regenerate." fi # ── Policy 12: No editing eslint.config.js files ─────────────────────────── if echo "$FILE_PATH" | grep -qE '(^|/)eslint\.config\.[cm]?[jt]s$'; then deny "BLOCKED: Do not edit eslint.config.js files directly. ESLint configuration changes require human review — describe the change needed and let the user decide or consider a method that leads to higher code quality, if available." fi # ── Policy 13: No read_file ranges >50 lines (docs/ exempt, limit 500) ───── # Prevents context exhaustion on 32K models from large sequential reads. # docs/ files (documentation) are exempt: they are meant to be read whole # and may use ranges up to 500 lines per call. if [[ "$TOOL_NAME" == "read_file" || "$TOOL_NAME" == "read" || "$TOOL_NAME" == "edit" ]]; then START_LINE=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(String(i.startLine ?? 1)); " 2>/dev/null || echo "1") END_LINE=$(echo "$INPUT" | node -e " const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const i = d.tool_input || {}; process.stdout.write(String(i.endLine ?? 0)); " 2>/dev/null || echo "0") if [[ "$END_LINE" -gt 0 ]]; then RANGE=$(( END_LINE - START_LINE + 1 )) if echo "$FILE_PATH" | grep -qE '(^|/)docs/|\.md$|\.txt$'; then # docs/ files and all .md/.txt files — allow up to 500 lines if [[ "$RANGE" -gt 500 ]]; then deny "BLOCKED: Read more than 500 lines at once is prohibited for docs/ and .md/.txt files. Use startLine/endLine to paginate in ≤500-line chunks." fi else # All other files — 50-line limit if [[ "$RANGE" -gt 50 ]]; then deny "BLOCKED: Read more than 50 lines at once is prohibited. Use startLine/endLine to paginate in ≤50-line chunks. For docs/ and .md/.txt files the limit is 500 lines. Use grep_search first to find the right offset." fi fi fi fi fi