- AGENTS.md: design principles, enforcement hierarchy, deferred loading - agents/: brainstorm, build, orchestrator, research (auto-discovered by MCP server) - skills/: research methodology (auto-discovered by MCP server) - hooks/: pre-tool-use, post-tool-use (BFF block removed), session-start, stop, pre-compact, user-prompt-submit - frameworks/: opencode/plugin.ts (resolves hooks via import.meta.url — works as project-local or global plugin), github/hooks.json - mcp/index.ts: auto-discovers agents/*.md and skills/*.md from frontmatter (replaces hand-maintained registry); server renamed all-agents - docs/: agent-infrastructure.md (generalized), research docs (7 files), ai_architectures.md, llama-server-cuda-wsl2.md - install.sh: idempotent setup — Copilot global hooks, OpenCode global plugin + AGENTS.md + MCP entry, VS Code global MCP config
218 lines
13 KiB
Bash
Executable File
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
|
|
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 '"<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
|