dotfiles/.agents/hooks/pre-tool-use.sh
Brydon DeWitt 88435d6b51 fix(hooks): add bash tool name to pre-tool-use hook for OpenCode support
OpenCode uses tool name 'bash' (not 'run_in_terminal') for shell execution.
The hook was early-exiting for 'bash' tool calls, leaving banned commands
unchecked. Added 'bash' to both the inspect allowlist and COMMAND extraction.
2026-05-22 16:42:04 -04:00

218 lines
13 KiB
Bash
Executable File

#!/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
bash|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
bash|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 '"<encode-error>"')}" >> /tmp/pre-tool-hook-debug.jsonl
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "$reason"
}
}
EOF
exit 0
}
# ── Policy 1: No npx ─────────────────────────────────────────────────────────
if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)npx\s'; then
deny "BLOCKED: Do not use npx directly. Use npm run scripts instead. If no script exists, recommend adding one. See AGENTS.md."
fi
# ── Policy 2: No direct node_modules/.bin invocations ────────────────────────
if echo "$COMMAND" | grep -qE 'node_modules/\.bin/|node_modules\\\.bin\\'; then
deny "BLOCKED: Do not invoke tools from node_modules/.bin/. Use npm run scripts instead. See AGENTS.md."
fi
# ── Policy 3: No direct node invocations of node_modules packages ────────────
if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)node\s+(\./)?node_modules/'; then
deny "BLOCKED: Do not invoke node_modules packages directly with node. Use npm run scripts instead. See AGENTS.md."
fi
# ── Policy 4: No python — use node for scripting ─────────────────────────────
if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)(python3?|pip3?)\s'; then
deny "BLOCKED: Do not use python in this project. Use node for scripting instead."
fi
# ── Policy 5: No npm run build while dev server is running ───────────────────
# The dev server (npm run dev) uses tsc --watch and writes to dist/.
# npm run build also writes to dist/, causing crashes when both run.
# Detect dev server by checking if port 3000 (app) or 3001 (Vite HMR) is bound.
if echo "$COMMAND" | grep -qE '(^|\s|&&|\||\;)npm\s+run\s+build(\s|$|:)'; then
if ss -tlnp 2>/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 -- <file>' 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