Hooks
What agent-starter runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add sneg55/agent-starter > /plugin install agent-starter@agent-starter
Ships with agent-starter. Installing the plugin gets these hooks.
What fires, and when
PostToolUse
- Matches
Write|Edit${CLAUDE_PLUGIN_ROOT}/hooks/check-file-size.sh - Matches
Write|Edit${CLAUDE_PLUGIN_ROOT}/hooks/lint-on-edit.sh - Matches
Write|Edit${CLAUDE_PLUGIN_ROOT}/hooks/check-silent-errors.sh
PreToolUse
- Matches
Bash${CLAUDE_PLUGIN_ROOT}/hooks/block-dangerous-commands.sh - Matches
Bashpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/rm-scope-guard.py"
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
${CLAUDE_PLUGIN_ROOT}/hooks/check-codebase-health.sh .${CLAUDE_PLUGIN_ROOT}/hooks/worktree-session-prompt.sh
Stop
${CLAUDE_PLUGIN_ROOT}/hooks/worktree-exit-offer.sh
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
${CLAUDE_PLUGIN_ROOT}/hooks/suggest-loop-improvements.sh
Where it lives
- hooks/block-dangerous-commands.shRunsGitHub
Read the script
#!/bin/bash # Claude Code hook: block destructive shell commands before they run. # PreToolUse on Bash - exit 2 blocks with stderr, exit 0 allows. # # Blocks: # - git push --force / -f (use --force-with-lease instead) # - git reset --hard / --merge # - git clean -f... # - git checkout -- . / git restore . (discards all uncommitted work) # - recursive rm on /, /*, ~ or $HOME # - chmod -R 777 / # # Escape hatch: CLAUDE_ALLOW_DANGEROUS=1 disables the hook. # # Install: copy to ~/.claude/hooks/, chmod +x, wire in settings.json (see README), # or run install.sh from the repo root. set -u [ "${CLAUDE_ALLOW_DANGEROUS:-0}" = "1" ] && exit 0 LIB="$(dirname "$0")/lib" . "$LIB/hook-input.sh" . "$LIB/redact.sh" hook_input_init "${1:-}" CMD=$(hook_input_command) # No command in the payload means there is nothing to police. That is not a # malformed event (hook_input_init already rejected those, loudly); it is an # event shape this hook does not apply to. [ -z "$CMD" ] && exit 0 check() { echo "$CMD" | grep -qE "$1"; } # REASON is prose for the agent; CODE is the stable machine-readable token that # goes to the ledger. /reflect should branch on the code, not match the prose, # so wording can be improved without invalidating past events. REASON="" CODE="" if check 'git[[:space:]]+push[[:space:]]+[^|;&]*--force([[:space:]]|$)'; then CODE="force_push" REASON="'git push --force' rewrites remote history - use --force-with-lease" elif check 'git[[:space:]]+push[[:space:]]+([^|;&]*[[:space:]])?-f([[:space:]]|$)'; then CODE="force_push" REASON="'git push -f' rewrites remote history - use --force-with-lease" elif check 'git[[:space:]]+reset[[:space:]]+[^|;&]*--(hard|merge)'; then CODE="destructive_reset" REASON="'git reset --hard/--merge' destroys uncommitted work - stash first, or restore specific paths" elif check 'git[[:space:]]+clean[[:space:]]+[^|;&]*-[A-Za-z]*f'; then CODE="untracked_delete" REASON="'git clean -f' permanently deletes untracked files" elif check 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.([[:space:]]|$)'; then CODE="worktree_discard" REASON="'git checkout -- .' discards every uncommitted change in the working tree" elif check 'git[[:space:]]+restore[[:space:]]+\.([[:space:]]|$)'; then CODE="worktree_discard" REASON="'git restore .' discards every uncommitted change in the working tree" elif check 'rm[[:space:]]+(-[A-Za-z]+[[:space:]]+)+(/|/\*|~|~/|\$HOME|\$HOME/)([[:space:]]|$)'; then CODE="dangerous_recursive_delete" REASON="recursive rm on /, ~ or \$HOME is unrecoverable" elif check 'chmod[[:space:]]+-R[[:space:]]+777[[:space:]]+/([[:space:]]|$)'; then CODE="permission_destruction" REASON="'chmod -R 777 /' destroys system permissions" fi if [ -n "$REASON" ]; then cat >&2 <<EOF Blocked dangerous command: $CMD Why: $REASON. If this is genuinely intended, ask the developer to run it themselves, or re-run with CLAUDE_ALLOW_DANGEROUS=1 after they approve. EOF # Ledger gets the reason code plus the command's SHAPE, never the command. # The blocked command is the one most likely to be carrying a token, a signed # URL, or customer data, and the ledger is durable on-disk state. [ -x "$LIB/log-event.sh" ] && "$LIB/log-event.sh" dangerous-command block "" "$CODE: $(redact_command "$CMD")" exit 2 fi exit 0 - hooks/check-codebase-health.shRunsGitHub
Read the script
#!/bin/bash # Claude Code hook: codebase health check on session start # SessionStart - reports file size distribution and flags violations # # Install: add to settings.json under SessionStart event SRC_DIR="${1:-.}" # Find code files (skip node_modules, dist, .git, etc.) CODE_FILES=$(find "$SRC_DIR" -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.py" -o -name "*.rs" -o -name "*.go" \) \ ! -path "*/node_modules/*" ! -path "*/.git/*" ! -path "*/dist/*" ! -path "*/build/*" ! -path "*/__pycache__/*" ! -path "*/target/*" 2>/dev/null) if [ -z "$CODE_FILES" ]; then exit 0 fi TOTAL=0 UNDER_50=0 UNDER_100=0 UNDER_200=0 UNDER_300=0 UNDER_500=0 OVER_500=0 VIOLATIONS="" while IFS= read -r file; do [ -z "$file" ] && continue lines=$(wc -l < "$file" | tr -d ' ') TOTAL=$((TOTAL + 1)) if [ "$lines" -le 50 ]; then UNDER_50=$((UNDER_50 + 1)) elif [ "$lines" -le 100 ]; then UNDER_100=$((UNDER_100 + 1)) elif [ "$lines" -le 200 ]; then UNDER_200=$((UNDER_200 + 1)) elif [ "$lines" -le 300 ]; then UNDER_300=$((UNDER_300 + 1)) elif [ "$lines" -le 500 ]; then UNDER_500=$((UNDER_500 + 1)) else OVER_500=$((OVER_500 + 1)) VIOLATIONS="$VIOLATIONS\n ⛔ $file ($lines lines)" fi if [ "$lines" -gt 300 ]; then VIOLATIONS="$VIOLATIONS" fi done <<< "$CODE_FILES" # Calculate percentage under 200 UNDER_200_TOTAL=$((UNDER_50 + UNDER_100 + UNDER_200)) if [ "$TOTAL" -gt 0 ]; then PCT=$((UNDER_200_TOTAL * 100 / TOTAL)) else PCT=100 fi # Only output if there are issues if [ "$PCT" -lt 64 ] || [ -n "$VIOLATIONS" ]; then cat >&2 <<EOF 📊 Codebase Health Report ━━━━━━━━━━━━━━━━━━━━━━━━ Files under 200 lines: $PCT% (target: 64%) Total code files: $TOTAL Distribution: ≤50 lines: $UNDER_50 51-100: $UNDER_100 101-200: $UNDER_200 201-300: $UNDER_300 301-500: $UNDER_500 500+: $OVER_500 EOF if [ -n "$VIOLATIONS" ]; then echo -e "\nFiles over 500 lines (need splitting):$VIOLATIONS" >&2 fi if [ "$PCT" -lt 64 ]; then echo -e "\n⚠️ Below 64% target. Prioritize splitting large files." >&2 fi fi exit 0 - hooks/check-em-dash.pyGitHub
- hooks/check-file-size.shRunsGitHub
Read the script
#!/bin/bash # Claude Code hook: enforce file size limits # PostToolUse on Write|Edit - exit 2 to block, exit 0 to pass # (wire to both: files can grow past the limit through repeated Edits) # # Install: copy to ~/.claude/hooks/ and add to settings.json # The hook receives the tool payload as JSON on stdin . "$(dirname "$0")/lib/hook-input.sh" hook_input_init "${1:-}" FILE_PATH=$(hook_input_file) # Skip if we can't determine the file if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi case "$FILE_PATH" in *.md|*.mdx|*.markdown|*.json|*.yaml|*.yml|*.toml|*.lock|*.svg|*.png|*.jpg|*.csv|*.txt) exit 0 ;; esac LINE_COUNT=$(wc -l < "$FILE_PATH" | tr -d ' ') # Stylesheets are not modules. They hold no types, constants, or helper functions to # extract, so the module advice below is noise for them, and 200 lines is tight for a # language whose unit is one declaration per line. They split by layer instead, and # they get their own, looser thresholds. case "$FILE_PATH" in *.css|*.scss|*.sass|*.less) WARN_THRESHOLD=250 BLOCK_THRESHOLD=400 SPLIT_ADVICE="Split by layer - extract into separate stylesheets, imported in order: - tokens.css - custom properties only (color, space, type, elevation) - base.css - reset, document rhythm, app shell - components.css - one block per component - states.css - empty / error / loading states and their keyframes Keep each layer under the warn threshold. Do NOT split in the middle of a component." ;; *.astro|*.vue|*.svelte) WARN_THRESHOLD=250 BLOCK_THRESHOLD=400 SPLIT_ADVICE="Split by concern, keeping the single-file-component unit intact: - extract a child component (with its own scoped <style>) for a distinct region - move a long client <script> into src/scripts/*.ts and import it - lift genuinely shared rules into src/styles/*.css; keep component-scoped CSS in place - move data arrays and helpers into src/lib/*.ts Do NOT flatten scoped <style> into a global stylesheet to win back lines." ;; *) WARN_THRESHOLD=200 BLOCK_THRESHOLD=300 SPLIT_ADVICE="Split by concern - extract into separate files: - types.ts / types.py - type definitions and interfaces - constants.ts / constants.py - named constants and config values - validation.ts / validation.py - input validation logic - utils.ts / utils.py - pure helper functions - [Name].test.ts - tests (always separate) Each extracted file should handle a single responsibility. Do NOT just move code around - ensure clean imports and no circular dependencies." ;; esac CONF_DIR=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null) CONF="${CONF_DIR}/.harness/file-size.conf" if [ -n "$CONF_DIR" ] && [ -f "$CONF" ]; then PROJECT_WARN=$(sed -n 's/^WARN_THRESHOLD=\([0-9][0-9]*\).*/\1/p' "$CONF" | tail -1) PROJECT_BLOCK=$(sed -n 's/^BLOCK_THRESHOLD=\([0-9][0-9]*\).*/\1/p' "$CONF" | tail -1) [ -n "$PROJECT_WARN" ] && WARN_THRESHOLD="$PROJECT_WARN" [ -n "$PROJECT_BLOCK" ] && BLOCK_THRESHOLD="$PROJECT_BLOCK" fi if [ "$LINE_COUNT" -gt "$BLOCK_THRESHOLD" ]; then cat >&2 <<EOF ⛔ FILE TOO LARGE: $FILE_PATH has $LINE_COUNT lines (limit: $BLOCK_THRESHOLD) This file exceeds the maximum size. You MUST split it before proceeding. $SPLIT_ADVICE EOF [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" file-size block "$FILE_PATH" "$LINE_COUNT lines (limit $BLOCK_THRESHOLD)" exit 2 elif [ "$LINE_COUNT" -gt "$WARN_THRESHOLD" ]; then cat >&2 <<EOF ⚠️ FILE GETTING LARGE: $FILE_PATH has $LINE_COUNT lines (target: <$WARN_THRESHOLD) Consider splitting soon. $SPLIT_ADVICE EOF [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" file-size warn "$FILE_PATH" "$LINE_COUNT lines (target <$WARN_THRESHOLD)" exit 0 fi exit 0 - hooks/check-new-comments.pyGitHub
- hooks/check-silent-errors.shRunsGitHub
Read the script
#!/bin/bash # Claude Code hook: block writes that introduce silent error handling. # PostToolUse on Write|Edit - exit 2 to block with stderr, exit 0 to pass. # # Catches: bare `except:`, `except: pass`, `except: ...`, empty `catch {}`, # and `catch` blocks whose only body is console.log (swallows errors with # a low-severity log). # # Rationale: LLMs routinely wrap code in try/except to make a test go green. # The code still breaks in prod - just silently. Every handler must either # re-raise, return a sentinel, or log with context via console.error/warn. # # Exempt a single site with an inline comment: # Python: `# silent-ok` # JS/TS: `// silent-ok` # # Install: copy to ~/.claude/hooks/, chmod +x, wire in settings.json (see README). set -u . "$(dirname "$0")/lib/hook-input.sh" hook_input_init "${1:-}" FILE_PATH=$(hook_input_file) [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0 case "$FILE_PATH" in */scratchpad/*) exit 0 ;; esac VIOLATIONS="" case "$FILE_PATH" in *.py) # Bare `except:` (no type). Portable ERE - BSD grep has no -P (PCRE). if grep -nE '^[[:space:]]*except[[:space:]]*:' "$FILE_PATH" | grep -v 'silent-ok' > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then VIOLATIONS="${VIOLATIONS} Bare except: $(cat /tmp/silerr.$$) " fi # except/pass, except/continue, except/... - heuristic two-line scan. # POSIX classes only - BSD/one-true awk doesn't grok \s or \b. # Exempt via '# silent-ok' on either the except or the body line. if awk ' /^[[:space:]]*except([[:space:]]|:|$)/ { e=NR; eline=$0; next } e && NR==e+1 && /^[[:space:]]*(pass|continue|\.\.\.)[[:space:]]*$/ { if (eline !~ /silent-ok/ && $0 !~ /silent-ok/) { print e":"eline; print NR":"$0 } e=0; next } { e=0 } ' "$FILE_PATH" > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then VIOLATIONS="${VIOLATIONS} except/pass or except/continue or except/...: $(cat /tmp/silerr.$$) " fi rm -f /tmp/silerr.$$ ;; *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) # Empty catch block: catch {} or catch (e) {}. Portable ERE - no -P on BSD grep. if grep -nE 'catch[[:space:]]*(\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' "$FILE_PATH" | grep -v 'silent-ok' > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then VIOLATIONS="${VIOLATIONS} Empty catch block: $(cat /tmp/silerr.$$) " fi # catch block whose next non-empty line is only console.log(...) # POSIX classes only - BSD/one-true awk doesn't grok \s or \S. # Exempt via '// silent-ok' on either the catch or the console.log line. if awk ' /catch[[:space:]]*(\([^)]*\))?[[:space:]]*\{/ { c=NR; cline=$0; next } c && /^[[:space:]]*console\.log\(/ { if (cline !~ /silent-ok/ && $0 !~ /silent-ok/) { print c":"cline; print NR":"$0 } c=0; next } c && /^[[:space:]]*[^[:space:]]/ { c=0 } ' "$FILE_PATH" > /tmp/silerr.$$ 2>/dev/null && [ -s /tmp/silerr.$$ ]; then VIOLATIONS="${VIOLATIONS} catch with only console.log (use console.error): $(cat /tmp/silerr.$$) " fi rm -f /tmp/silerr.$$ ;; *) exit 0 ;; esac if [ -n "$VIOLATIONS" ]; then cat >&2 <<EOF Blocked: silent error handling in ${FILE_PATH}: ${VIOLATIONS} Fix: log with context via console.error/logger, then re-raise or return a sentinel. Exempt a single site with '// silent-ok' (JS/TS) or '# silent-ok' (Py). See guides/hooks-reference.md § "Block silent error patterns". EOF [ -x "$(dirname "$0")/lib/log-event.sh" ] && "$(dirname "$0")/lib/log-event.sh" silent-error block "$FILE_PATH" "silent error handler" exit 2 fi exit 0 - hooks/comment_syntax.pyGitHub
- hooks/harness-ledger-stats.shGitHub
- hooks/lint-on-edit.shRunsGitHub
Read the script
#!/bin/bash # Claude Code hook: lint + typecheck files the agent just wrote. # PostToolUse on Write|Edit - exit 2 to block with stderr, exit 0 to pass. # # Philosophy: rules only shape agent behavior if the agent sees failures. # JS/TS: runs Biome (format + fast rules, with --write), then ESLint # (type-aware + plugin rules), then optionally tsc --noEmit. Python: runs # ruff check --fix, then ruff format, when a ruff binary is available # (.venv/bin/ruff or PATH). # The agent gets structured errors back in its next turn and self-corrects. # # Install: copy to ~/.claude/hooks/ and add to settings.json (see README). set -u . "$(dirname "$0")/lib/hook-input.sh" hook_input_init "${1:-}" FILE_PATH=$(hook_input_file) if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then exit 0 fi # Resolve to an absolute path. The branches below `cd` into the project root, # after which a repo-relative path (how CI invokes this hook, e.g. # "app/src/foo.ts") would no longer resolve and the linters would report # "no files matching". An absolute path resolves regardless of cwd. FILE_PATH="$(cd "$(dirname "$FILE_PATH")" && pwd)/$(basename "$FILE_PATH")" case "$FILE_PATH" in *.py) # Python path: ruff check --fix, when a project root and ruff binary exist. PROJECT_ROOT="" DIR=$(cd "$(dirname "$FILE_PATH")" && pwd) while [ "$DIR" != "/" ]; do if [ -f "$DIR/pyproject.toml" ] || [ -f "$DIR/setup.py" ] || [ -f "$DIR/requirements.txt" ] || [ -d "$DIR/.git" ]; then PROJECT_ROOT="$DIR" break fi DIR=$(dirname "$DIR") done [ -z "$PROJECT_ROOT" ] && exit 0 HOOK_DIR=$(cd "$(dirname "$0")" && pwd) cd "$PROJECT_ROOT" || exit 0 RUFF="" if [ -x .venv/bin/ruff ]; then RUFF=.venv/bin/ruff elif command -v ruff >/dev/null 2>&1; then RUFF=ruff fi # Ruff is best-effort: run it when present, but don't exit early if it's # missing - the mypy type-check below must still run for mypy-only projects. if [ -n "$RUFF" ]; then if ! RUFF_OUT=$("$RUFF" check --fix "$FILE_PATH" 2>&1); then printf 'Ruff errors in %s:\n%s\n' "$FILE_PATH" "$RUFF_OUT" >&2 [ -x "$HOOK_DIR/lib/log-event.sh" ] && "$HOOK_DIR/lib/log-event.sh" lint block "$FILE_PATH" "ruff check failed" exit 2 fi # Formatting parity with Biome's --write. Best-effort: format only fails on # syntax errors, which check already reported, so never block on it. "$RUFF" format --quiet "$FILE_PATH" >/dev/null 2>&1 || true fi # Type-check with mypy (the type-aware step, like tsc on the TS path). # Whole-file mypy resolves imports and can be slow, so gate it behind the # same opt-in marker as tsc: touch .claude/enable-typecheck-on-edit. if [ -f .claude/enable-typecheck-on-edit ]; then MYPY="" if [ -x .venv/bin/mypy ]; then MYPY=.venv/bin/mypy elif command -v mypy >/dev/null 2>&1; then MYPY=mypy else # The marker is the user's consent to type-check, so install mypy when # it's missing rather than silently skipping. Prefer the project venv. if [ -x .venv/bin/pip ]; then .venv/bin/pip install --quiet mypy >/dev/null 2>&1 [ -x .venv/bin/mypy ] && MYPY=.venv/bin/mypy elif command -v pip3 >/dev/null 2>&1; then pip3 install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy elif command -v pip >/dev/null 2>&1; then pip install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --quiet mypy >/dev/null 2>&1 && command -v mypy >/dev/null 2>&1 && MYPY=mypy fi # Install can fail (no network, no pip). Warn without blocking the edit. [ -z "$MYPY" ] && printf 'mypy not installed and auto-install failed; skipping type-check for %s. Install mypy to enable (e.g. `pip install mypy`).\n' "$FILE_PATH" >&2 fi if [ -n "$MYPY" ]; then if ! MYPY_OUT=$("$MYPY" "$FILE_PATH" 2>&1); then printf 'mypy errors in %s:\n%s\n' "$FILE_PATH" "$MYPY_OUT" >&2 [ -x "$HOOK_DIR/lib/log-event.sh" ] && "$HOOK_DIR/lib/log-event.sh" lint block "$FILE_PATH" "mypy typecheck failed" exit 2 fi fi fi exit 0 ;; *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) ;; *) exit 0 ;; esac # Walk up to find the project root (nearest package.json). PROJECT_ROOT="" DIR=$(cd "$(dirname "$FILE_PATH")" && pwd) while [ "$DIR" != "/" ]; do if [ -f "$DIR/package.json" ]; then PROJECT_ROOT="$DIR" break fi DIR=$(dirname "$DIR") done if [ -z "$PROJECT_ROOT" ]; then exit 0 fi cd "$PROJECT_ROOT" || exit 0 HAS_ESLINT_CONFIG=0 for cfg in eslint.config.mjs eslint.config.js eslint.config.cjs .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yaml .eslintrc.yml; do if [ -f "$cfg" ]; then HAS_ESLINT_CONFIG=1; break; fi done HAS_BIOME_CONFIG=0 for cfg in biome.json biome.jsonc; do if [ -f "$cfg" ]; then HAS_BIOME_CONFIG=1; break; fi done HAS_TSCONFIG=0 [ -f tsconfig.json ] && HAS_TSCONFIG=1 FAIL=0 OUT="" # Biome first: fast, autofixes formatting + syntactic rules. if [ "$HAS_BIOME_CONFIG" -eq 1 ] && [ -x node_modules/.bin/biome ]; then if ! BIOME_OUT=$(node_modules/.bin/biome check --write --no-errors-on-unmatched "$FILE_PATH" 2>&1); then OUT="${OUT}Biome errors in ${FILE_PATH}: ${BIOME_OUT} " FAIL=1 fi fi # ESLint second: type-aware + plugin rules (import resolution, sonarjs, security). if [ "$HAS_ESLINT_CONFIG" -eq 1 ] && [ -x node_modules/.bin/eslint ]; then if ! LINT_OUT=$(node_modules/.bin/eslint --fix --cache --cache-location node_modules/.cache/eslint/ --max-warnings 0 "$FILE_PATH" 2>&1); then OUT="${OUT}ESLint errors in ${FILE_PATH}: ${LINT_OUT} " FAIL=1 fi fi # Type-check only when the file is TS and tsconfig exists. --noEmit is whole-project, # which is slow on huge repos; gate - hooks/require-read-before-edit.shGitHub
- hooks/rm-scope-guard.pyRunsGitHub
Read the script
#!/usr/bin/env python3 import json import os import re import shlex import subprocess import sys SEPARATORS = {";", "&", "&&", "|", "||", "\n"} REDIRECTIONS = {">", ">>", "<", "<<", "<<<", "&>", ">&", "2>", "2>>", "1>", "1>>"} WRAPPERS = {"sudo", "command", "env", "builtin", "exec", "time", "nice", "ionice", "doas"} RM_NAMES = {"rm", "\\rm", "/bin/rm", "/usr/bin/rm"} RM_PATTERN = re.compile(r"(^|[^\w./-])\\?/?(?:usr/)?(?:bin/)?rm(\s|$|;|&|\|)") MAX_REPORTED = 8 def log_event(target, detail): script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib", "log-event.sh") if not os.access(script, os.X_OK): return try: subprocess.run( [script, "rm-scope", "block", target, detail], timeout=5, check=False, capture_output=True, ) except (OSError, subprocess.SubprocessError): return def tokenize(command): lexer = shlex.shlex(command, posix=True, punctuation_chars=True) lexer.whitespace_split = True try: return list(lexer) except ValueError: return command.split() def strip_redirections(tokens): out = [] skip_next = False for token in tokens: if skip_next: skip_next = False continue if token in REDIRECTIONS: skip_next = True continue if len(token) > 1 and token[0] in "<>" or ( len(token) > 2 and token[0].isdigit() and token[1] in "<>" ): continue out.append(token) return out def rm_arguments(segment): for index, token in enumerate(segment): if token in RM_NAMES: return segment[index + 1 :] return None def next_directory(segment, cwd): if not segment or segment[0] != "cd": return cwd if "-" in segment[1:]: return None targets = [t for t in segment[1:] if not t.startswith("-")] if not targets: return os.path.realpath(os.path.expanduser("~")) if cwd is None: return None return os.path.realpath(os.path.join(cwd, os.path.expanduser(targets[0]))) def rm_invocations(command, cwd): segment = [] for token in tokenize(command) + [";"]: if token in SEPARATORS: clean = strip_redirections(segment) arguments = rm_arguments(clean) if arguments is not None: yield arguments, cwd cwd = next_directory(clean, cwd) segment = [] continue segment.append(token) def escapes(argument, resolve_dir, boundary): expanded = os.path.expanduser(argument) for form in ("${HOME}", "$HOME"): expanded = expanded.replace(form, os.path.expanduser("~")) if resolve_dir is None and not os.path.isabs(expanded): return True if os.path.isabs(expanded): target = expanded else: target = os.path.join(resolve_dir, expanded) target = os.path.realpath(target) if target == boundary: return False return not target.startswith(boundary + os.sep) def main(): if os.environ.get("CLAUDE_ALLOW_DANGEROUS") == "1": return 0 try: payload = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): return 0 command = (payload.get("tool_input", {}) or {}).get("command", "") or "" if not command or not RM_PATTERN.search(command): return 0 boundary = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() boundary = os.path.realpath(boundary) saw_rm = False escaping = [] for arguments, resolve_dir in rm_invocations(command, boundary): saw_rm = True for argument in arguments: if argument == "--" or argument.startswith("-"): continue if escapes(argument, resolve_dir, boundary): escaping.append(argument) if not saw_rm or not escaping: return 0 log_event(escaping[0], f"{len(escaping)} target(s) outside cwd") print( f"Blocked: rm target outside the working directory ({boundary}).", file=sys.stderr, ) for argument in escaping[:MAX_REPORTED]: print(f" {argument}", file=sys.stderr) if len(escaping) > MAX_REPORTED: print(f" ... and {len(escaping) - MAX_REPORTED} more", file=sys.stderr) print( "\nDeleting outside the project is unrecoverable and is almost never what the task " "asked for. Move the path into a project-local .trash/ directory instead, re-run the " "rm from a shell outside Claude, or set CLAUDE_ALLOW_DANGEROUS=1 for a session that " "genuinely needs it.", file=sys.stderr, ) return 2 if __name__ == "__main__": sys.exit(main()) - hooks/suggest-loop-improvements.shRunsGitHub
- hooks/track-reads.shGitHub
- hooks/worktree-exit-offer.shRunsGitHub
- hooks/worktree-session-prompt.shRunsGitHub
All 15 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Skills, hooks, templates, and engineering guides for bootstrapping AI-agent-friendly projects, with a per-project self-improvement loop.
Repo: sneg55/agent-starter

