Claude opencode Skill

cc-hooks

Configure default Claude Code enforcement hooks and opt-in guard recipes. Triggers: "cc-hooks", "configure Claude Code hooks", "install hooks".

LLM Mart · 0 points · 1 views 4 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download boshu2-agentops-skills-codex_cc-hooks-c3fe161.zip · 60 KB
boshu2/agentops 445 41 forks Apache-2.0 Updated 12h ago
Part of boshu2/agentops — 73 skills

Install

skills CLI npx skills add https://github.com/boshu2/agentops/tree/main/skills-codex/cc-hooks
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install boshu2-agentops@llmmart
Git git clone https://github.com/boshu2/agentops.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole boshu2/agentops collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Claude Code Hooks

Shell commands that fire at specific points in Claude Code's lifecycle.

Hooks enforce mechanically what prose cannot: a model can reason its way past an instruction, but it cannot reason its way past an exit 2 — which is exactly why every hook must be narrow, silent, and reversible.

Named failure mode — chatty happy path: a hook that emits stdout on exit 0 corrupts the tool call it was guarding; silence on success is part of the contract, not a style preference.

Prompt

Add a PreToolUse hook to fleet-router/.claude/settings.json that blocks `git push --force` on the main branch. Keep it silent on exit 0, exit 2 with a message on block, and confirm it fires with a manual test invocation before committing the change.

It's working if

  • The hook script exits 2 with a stderr message when it blocks git push --force, and exit 0 with no stdout on the allowed path.
  • .claude/settings.json gains one matcher entry for the new hook, alongside the existing hooks list rather than replacing it.
  • A manual test invocation against the new matcher shows the block firing in the transcript, with exit 2 visible, before the change gets committed.
  • The hook inspects only the PreToolUse call it guards, keeping every other file untouched.

Constraints

  • Enforcement hooks (the PreToolUse policy dispatcher) ship by DEFAULT: plugin installs auto-wire hooks/hooks.json; skill copies and checkouts wire with one command (scripts/install-hooks.sh). Operators can disable per host (/plugin disable, or remove the settings matchers).
  • Injection hooks (SessionStart/UserPromptSubmit context stuffing) stay dead — the #511 teardown proved delta=0 at 10.35M resident tokens. Never ship one; the hookless-cold-start gate still enforces this.
  • Keep the happy path silent and block only with the event's documented exit/JSON contract because stray stdout can corrupt a tool call.
  • Bound Stop hooks with stop_hook_active and scope matchers narrowly to prevent recursion and unrelated-command interception.

Quick Start

Add to ~/.claude/settings.json (user) or .claude/settings.json (project):

{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-validator.sh"}]}]}}

Hook Events

Event When Blocks? Common Use
PreToolUse Before tool runs Yes Block/modify commands
PostToolUse After tool succeeds Feedback Auto-format, lint
PermissionRequest Permission dialog Yes Auto-approve/deny
UserPromptSubmit Prompt submitted Yes Add context, validate
Stop Claude finishes Yes Force continue
SessionStart Session begins No Load context, set env
Notification Notifications No Desktop alerts

Full schemas: HOOK-EVENTS.md

Matchers

"Bash"              → exact match
"Edit|Write"        → regex OR
"mcp__.*__write"    → MCP tools
"*" or ""           → all tools

Tools: Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch

Exit Codes

Code Effect
0 Success - JSON parsed from stdout
2 Block - stderr fed to Claude
Other Non-blocking error

Blocking a Tool

Simple (exit 2):

echo "Blocked: reason" >&2 && exit 2

JSON (exit 0):

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}}

Decisions: "allow" (auto-approve), "deny" (block), "ask" (show dialog)

Modifying Input

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
  "updatedInput":{"command":"modified-command"}}}

Real-World: DCG + RCH

{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
  {"type":"command","command":"dcg"},
  {"type":"command","command":"rch"}
]}]}}
  • DCG: Blocks git reset --hard, rm -rf, git push --force
  • RCH: Routes builds to remote workers

Details: DCG-RCH.md

Skill-First Coordination Guard (opt-in)

A copy-paste PreToolUse recipe that nudges agents to load the coordination skill before hand-rolling the am/atm/ntm/tmux send-keys CLI. This recipe auto-installs nothing; you opt in per host (unlike the policy dispatcher, which ships by default).

Context-budget doctrine for hooks: hooks are the most powerful enforcement (mechanical, can't be reasoned past) but they pollute context — use sparingly. A hook must be SILENT on the happy path (exit 0, no stdout/stderr), fire ONLY on a real violation (ideally once per session, sentinel-gated), prefer PreToolUse violation-guards over UserPromptSubmit/SessionStart per-turn injectors, and NEVER emit stray stdout on an exit-0 PreToolUse path (it is parsed as JSON and breaks the tool call). Block via exit 2 + stderr.

The recipe ships both scripts verbatim, a precise head-only matcher (so a br create --body "...am/atm/ntm..." never false-fires), the two-matcher opt-in settings.json snippet, and a bats test proving every fire/silent case.

Recipe: SKILL-FIRST-COORDINATION-GUARD.md

Installed-Skill-Edit Guard (opt-in)

A PreToolUse Edit|Write guard that routes an edit of an installed skill copy (*/.claude/skills/**, .codex, .gemini) back to the repo source of truth skills/<name>/. This is a TRUE mistake-token — editing an installed/symlinked copy has no legitimate form (overwritten on install, or symlinks through to the factory checkout). Zero false-positive surface: it matches tool_input.file_path only, so a doc that merely mentions claude/skills in its body never fires. Reversible → it ROUTES (exit 2 + one-line redirect), not hard-blocks. Silent on every other path; fires once per session. Ships INERT — opt-in installer:

scripts/install-installed-skill-edit-guard.sh   # user scope; --project for project

Recipe: INSTALLED-SKILL-EDIT-GUARD.md

Value-proof (why this guard survives the hookless teardown)

The keystone guard ships gate-blind per-fire telemetry: on each fire it appends exactly one JSONL line — {ts, session, token_class, path_sha256} — to ${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl (override with AGENTOPS_GUARDRAIL_TELEMETRY). The path is SHA-256 hashed, never raw (privacy); nothing is written on the happy path; the sensor is inert until the guard is installed and fires. The pre-registered methodology — metric = declining fire-ATTEMPT rate over time (a signal the redirect cannot fake, NOT the circular hand-roll rate), minimum N, noise floor, and null-at-small-N is an acceptable outcome — satisfies ADR-0002 l.58 ("test or eval evidence showing positive value"), the criterion whose absence killed 2.x hooks (#511).

Methodology: GUARDRAIL-VALUE-PROOF.md

Read-Budget Guard (opt-in)

A PreToolUse Read|Bash guard that DENIES an unbounded read over the line budget (AOP_READ_BUDGET_LINES, default 350): a Read with no limit, or a cat/head/tail whose effective line count exceeds it. The Spotify finding: the same rule in CLAUDE.md was advisory and ignored, and an over-budget read re-sends its lines on every later turn. The predicate is a LOOKUP (wc -l on the exact argument), so it is a standalone guard, never a registry policy. A limit-bounded slice, a file at/below budget, a pipe, a redirect or quoted text that merely mentions cat passes. Literal quoted/escaped paths are preserved; unsupported shell syntax and directory-changing chains fail open. Negative head counts use the actual number of retained lines for GNU head; rejected Darwin system utility flags pass after checking executable identity. Positive signed head counts are bounded by their numeric value. Nothing un-reads bytes once in context → every attempt blocks (exit 2 + stderr): full message once per session naming the two correct moves (slice it, or delegate to the plugin's agentops:bulk-reader subagent / agentops:bulk-read workflow), one short line after. Use bare names only for standalone definitions or links when the runtime lists them. Waive once with AOP_WAIVE=core.context:unbounded-read; hashed telemetry adds tool, lines, budget plus the dispatcher's mode/decision pair. Ships INERT — opt-in installer:

scripts/install-read-budget-guard.sh   # user scope; --project for project

Recipe: READ-BUDGET-GUARD.md

Policy Dispatch Engine (ships by default)

The admission-control layer (epic age-4qw1): one PreToolUse dispatcher — hooks/policy-dispatch.sh — evaluating a policies-as-data registry (policies/policies.json, contract schemas/hooks-manifest.v2.schema.json) instead of N hand-wired settings entries. This is the membrane at tool-call altitude: same vocabulary, lower altitude than the pawl/gate at push time.

Per policy: dcg-style id (domain.object:token), mode: deny | route | audit, matchers (tool + command/file_path regex), a route_message that names THE correct tool, a rationale, and a pre-registered value_proof (the ADR-0002 lease-on-life: no proof accruing → retire the policy).

Predicate discipline, schema-enforced (the #511 anti-lesson): only predicate_class: pure — syntactic mistake-tokens over the command or file path — may deny/route. Lookup/stateful predicates ship audit-only until promoted with reviewed fires. scripts/lint-policies.sh enforces this mechanically (jq-only; runs in bats and CI).

Accepted false-positive surface: because a pure predicate matches its token anywhere in the raw command string, a protected token quoted as data (a commit message body, a dcg test "..." probe, a here-doc payload) can still fire even though nothing harmful would run. This is the deliberate cost of the pure-only-may-deny rule — the alternative (repo/context lookups) is exactly the stateful predicate the discipline bars from deny. Every fire is reversible: a one-shot AOP_WAIVE=<policy-id> or a policy-waivers line clears it.

Semantics: happy path = exit 0, zero output. deny = exit 2 + one stderr route line (full message once per session, short line after — every attempt still blocks). route = exit 0 + permissionDecision:"ask" JSON. audit = allow + record. Every fire appends one hashed guardrail-telemetry line (token_class = policy id, plus mode/decision). Waive once with AOP_WAIVE=<policy-id>, or a policy-waivers file line <policy-id> <expiry-epoch>. Missing registry or jq fails OPEN.

Enforce cohort (all pure-regex, high-pain). The first four are the day-1 maintainer cohort (age-wnyt) — they guard this repository's artifacts. The fifth guards the product's own invariant and therefore fires on every consumer repo, not just this one:

Policy Blocks Routes to
core.git:add-beads-ledger git add naming _beads/ (private ledger leak is one-way) push the ledger repo itself — never git add _beads in the public tree
core.provenance:ledger-hand-append redirect/tee/Edit/Write onto docs/provenance/ledger.jsonl (hash-chained, sealed) ao provenance add
core.skills:copy-into-installed cp/rsync/mv INTO ~/.claude|.codex|.gemini/skills (dest-position enforced) ao skills link
core.skills:edit-installed-copy Edit/Write of an installed skill copy (file_path only — prose can never fire it) edit repo skills/<name>/
core.verdicts:hand-edit Edit/Write, or Bash >/>>/tee/cp/rsync/mv INTO .agents/ao/verdicts/ (dest-position enforced) — the filename IS the SHA-256 of the content, so a hand edit breaks digest identity re-run validation and let it persist a fresh artifact (validate.py store-verdict)

core.verdicts:hand-edit is the one policy whose subject is the promise rather than the repo: a verdict that no longer hashes to its own filename is forged evidence, and nothing above the tool-call altitude catches it. Reading the store is untouched — cat/ls/jq/rg/diff over a verdict, and copying one OUT for inspection, never fire; only writes landing IN the store do — including in-place editors (sed -i, perl -pi/-ni) and deleters (rm, unlink, shred), matched as flag-tokens so a read whose script text merely contains -i stays silent (bats-proven both directions). Remaining disclosed gap: the noclobber override redirect (>|).

How it reaches users — every install path delivers hooks:

Install path Delivery
Claude Code plugin (claude plugin install agentops@agentops-marketplace) Automatic — the plugin bundles hooks/hooks.json (${CLAUDE_PLUGIN_ROOT} paths); hooks are active on install, no wiring step
npx skills@latest add boshu2/agentops / skills.sh copy The skill package carries its own installer: ~/.claude/skills/cc-hooks/scripts/install-hooks.sh (one command; file copies cannot self-wire)
git clone / brew checkout scripts/install-policy-dispatch.sh (delegates to the same skill-embedded installer)

The installer lints the registry before wiring, backs up settings, and is idempotent. Disable per host with /plugin disable agentops or by removing the two PreToolUse matchers from settings.

Contract tests: tests/scripts/policy-dispatch.bats (block+message+telemetry per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).

Writing Your Own Hook

Minimal Python:

#!/usr/bin/env python3
import json, sys

data = json.load(sys.stdin)
cmd = data.get('tool_input', {}).get('command', '')

if 'dangerous' in cmd:
    print("Blocked: dangerous", file=sys.stderr)
    sys.exit(2)

sys.exit(0)  # Allow

Hook input (stdin):

{"tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"...","cwd":"..."}

Environment Variables

Variable Scope Purpose
CLAUDE_PROJECT_DIR All Project root
CLAUDE_ENV_FILE SessionStart/Setup Persist env vars

Stop Hook (Force Continue)

{"decision":"block","reason":"Tests failing. Fix before stopping."}

Critical: Check stop_hook_active to prevent infinite loops.

Anti-Patterns

Don't Do
Old object format Array format with matcher
Unquoted $VAR "$VAR"
Exit 2 with JSON Exit 2 uses stderr only
Skip stop_hook_active check Always check in Stop hooks

Debugging

claude --debug  # Hook execution details
/hooks          # View/edit in REPL

Output Specification

  • Path: user ~/.claude/settings.json or project .claude/settings.json, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in.
  • Filename: preserve settings.json; give scripts descriptive executable filenames rather than embedding large shell programs in JSON.
  • Format: valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
  • Exit code: validate with jq -e '.hooks | type=="object"' <settings.json> and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation.
  • Downstream handoff: consumed by the operator only after the exact scope, reversal command, test evidence, and opt-in location are reported.

Quality Checklist

  • The matcher fires on the intended event/input and stays silent on representative near misses.
  • Blocking and allow paths use the documented exit code and output channel without leaking context.
  • The hook is reversible, narrowly scoped, recursion-safe, and clearly labeled as opt-in host policy.

References

Files (agentops)
  • hooks
    • codex-read-budget-guard.sh 1.9 KB
      #!/usr/bin/env bash
      # Opt-in Codex PreToolUse adapter for the documented canonical Bash event.
      # Codex shell/exec_command calls arrive as tool_name=Bash, tool_input.command.
      # Read/read_file and MCP tools are not mapped here. The sibling guard owns the
      # policy, waivers, line counting and hashed telemetry; only denial advice differs.
      # Source: https://learn.chatgpt.com/docs/hooks (Codex CLI 0.154.0 contract).
      # No preamble: this installed hook must fail open, independent of the checkout.
      set -uo pipefail
      
      [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0
      command -v jq >/dev/null 2>&1 || exit 0
      # CDPATH= clears a caller's directory-search setting for this one cd.
      # shellcheck disable=SC1007
      hook_dir="$( (CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 2>/dev/null)" || exit 0
      core="${hook_dir}/read-budget-guard.sh"
      [ -r "$core" ] || exit 0
      input="$(cat 2>/dev/null)" || exit 0
      printf '%s' "$input" | jq -e '
        type == "object" and .hook_event_name == "PreToolUse" and
        .tool_name == "Bash" and (.tool_input.command | type == "string")
      ' >/dev/null 2>&1 || exit 0
      
      # Capture only diagnostics, never relay stdout. Unexpected guard errors remain
      # fail-open; only the shared guard's explicit denial preserves exit 2.
      diagnostic="$(printf '%s' "$input" | bash "$core" 2>&1 >/dev/null)"
      decision=$?
      [ "$decision" -eq 2 ] || exit 0
      while IFS= read -r line; do
        line="${line//agentops:bulk-reader/bulk-reader}"
        case "$line" in
          '→ Read a slice:'*)
            printf '%s\n' "→ Read a bounded shell slice: sed -n 'START,ENDp' <file>, within AOP_READ_BUDGET_LINES (default 350)." >&2 ;;
          '    Agent tool:'*)
            printf '%s\n' '    Codex: delegate the question and file path to the installed bulk-reader role; request path:line bullets only.' >&2 ;;
          '    Workflow:'*|'    These names require the AgentOps plugin.'*) ;;
          *) printf '%s\n' "${line//offset+limit \/ sed -n/sed -n}" >&2 ;;
        esac
      done <<< "$diagnostic"
      exit 2
      
    • installed-skill-edit-guard.sh 4.6 KB
      #!/usr/bin/env bash
      # installed-skill-edit-guard (PreToolUse / Edit|Write)
      # age-workflow-guardrail-hooks-j39.1 — route Edit/Write of an INSTALLED skill copy
      # back to the repo source of truth.
      #
      # The mistake-token: an Edit/Write whose target path is under */.claude/skills/**
      # (or .codex/skills, .gemini/skills) has NO legitimate form — those are the
      # installed / symlinked copies (overwritten on install; symlinks through to the
      # factory checkout). The source of truth is skills/<name>/ in the agentops repo.
      #
      # Reversible footgun -> ROUTE, not hard-block: exit 2 + a one-line stderr redirect.
      #
      # Context-budget discipline (hooks are powerful but pollute context — use sparingly):
      #   - SILENT on the happy path: any other file_path -> exit 0, zero stdout/stderr.
      #   - Fires its one redirect ONLY on an installed-skill-copy edit, at most ONCE
      #     per session (sentinel-gated) so it never repeats.
      #   - NEVER emits stray stdout on an exit-0 PreToolUse path (stdout there is
      #     parsed as JSON). Block via exit 2 + stderr only.
      set -uo pipefail
      
      # Fail OPEN if jq is unavailable (the dispatcher precedent): a guard that can't
      # parse its input must never brick a tool call. Explicit preflight so the
      # fail-open is intentional, not an accident of an empty path falling through.
      command -v jq >/dev/null 2>&1 || exit 0
      
      input="$(cat)"
      path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')"
      sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
      
      # Match ONLY the file_path: an Edit/Write target under an installed skills dir.
      # We match the path segment `.claude/skills/` (or .codex/.gemini) anywhere in the
      # path so ~, $HOME, and absolute /Users/*/.claude/skills/** all hit. We match the
      # file_path field only — a repo doc whose BODY mentions "claude/skills" lands in
      # tool_input.content, never file_path, so prose can never fire this guard.
      case "$path" in
        */.claude/skills/*|*/.codex/skills/*|*/.gemini/skills/*)
          : # installed skill copy -> fire
          ;;
        *)
          exit 0  # repo skills/**, any other path -> SILENT happy path
          ;;
      esac
      
      dir="${TMPDIR:-/tmp}/claude-installed-skill-edit-guard"
      sentinel="$dir/${sid//\//_}"
      [ -f "$sentinel" ] && exit 0   # already redirected this session
      
      mkdir -p "$dir" 2>/dev/null || true
      : > "$sentinel" 2>/dev/null || true
      
      # Derive the repo-relative target so the redirect is actionable.
      name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)/.*#\2#p')"
      [ -n "$name" ] || name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)$#\2#p')"
      hint="skills/<name>/"
      [ -n "$name" ] && hint="skills/${name}/"
      
      # --- value-proof telemetry (age-workflow-guardrail-hooks-j39.2) -------------
      # Emit EXACTLY one gate-BLIND JSONL line per FIRE. The metric is the
      # fire-ATTEMPT rate over time (a learning signal the redirect itself cannot
      # fake) — see references/GUARDRAIL-VALUE-PROOF.md. PRIVACY: never the raw
      # command/path — only a SHA-256 hash of the path. Inert until the guard is
      # installed (this code only runs when the guard fires). Best-effort: telemetry
      # failure must NEVER change the guard's exit behavior.
      emit_telemetry() {
        command -v jq >/dev/null 2>&1 || return 0
        # Hash the path (privacy): sha256sum / shasum -a 256 / openssl, first available.
        local h=""
        if command -v sha256sum >/dev/null 2>&1; then
          h="$(printf '%s' "$path" | sha256sum | cut -d' ' -f1)"
        elif command -v shasum >/dev/null 2>&1; then
          h="$(printf '%s' "$path" | shasum -a 256 | cut -d' ' -f1)"
        elif command -v openssl >/dev/null 2>&1; then
          h="$(printf '%s' "$path" | openssl dgst -sha256 | sed 's/^.*= *//')"
        else
          return 0  # no hasher -> emit nothing rather than risk leaking the raw path
        fi
        [ -n "$h" ] || return 0
        local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}"
        local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
        mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
        local line
        line="$(jq -nc \
          --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
          --arg session "$sid" \
          --arg token_class "installed-skill-edit" \
          --arg path_sha256 "$h" \
          '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256}' \
        )" || return 0
        printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0
      }
      emit_telemetry
      
      cat >&2 <<MSG
      ⛔ INSTALLED-SKILL EDIT: do not edit installed skill copies.
        ${path}
        is an INSTALLED / symlinked copy — overwritten on install, or symlinked through
        to the factory checkout. Editing it is lost work.
        → Edit ${hint} in the agentops repo (the source of truth) instead.
      Fires once per session. Re-run your edit against the repo skills/ path.
      MSG
      exit 2
      
    • policy-dispatch.sh 6.4 KB
      #!/usr/bin/env bash
      # policy-dispatch.sh — ONE PreToolUse dispatcher over a policies-as-data registry
      # (age-bhsz / epic age-4qw1: admission control — the membrane at tool-call altitude).
      #
      # Reads the PreToolUse JSON once from stdin, evaluates every applicable policy
      # from the registry, and emits one decision:
      #   deny  -> exit 2 + route message on stderr (blocks the tool call)
      #   route -> exit 0 + permissionDecision:"ask" JSON on stdout (surfaces a dialog)
      #   audit -> exit 0, silent; the fire is only recorded in telemetry
      # Happy path: exit 0, ZERO output (stray stdout on exit-0 is parsed as JSON by
      # the harness and breaks the tool call — see skills/cc-hooks/SKILL.md).
      #
      # Predicate discipline (the #511 anti-lesson, schema-enforced by
      # scripts/lint-policies.sh + schemas/hooks-manifest.v2.schema.json): predicates
      # are SYNTACTIC mistake-tokens only — pure regex over tool_input.command or
      # tool_input.file_path. Policies with predicate_class other than "pure" are
      # structurally barred from deny/route until promoted from audit.
      #
      # Registry resolution order: $AOP_POLICIES, then policies.json beside this
      # script (installed layout), then ../policies/policies.json (repo layout).
      #
      # Waivers: AOP_WAIVE="id1,id2" env (one-shot), or a waiver file
      # ($AGENTOPS_HOME/policy-waivers, default ~/.agents/ao/policy-waivers) with
      # lines "<policy-id> <expiry-unix-epoch>".
      #
      # Telemetry: one JSONL line per fire (deny, route, audit, waived) appended to
      # $AGENTOPS_GUARDRAIL_TELEMETRY (default $AGENTOPS_HOME/guardrail-telemetry.jsonl,
      # AGENTOPS_HOME defaulting to ~/.agents/ao). Schema is a superset of the
      # installed-skill-edit-guard line: {ts, session, token_class, path_sha256} plus
      # {mode, decision}. The matched value is hashed, never stored raw. Telemetry
      # failure never changes the exit decision.
      set -uo pipefail
      
      # shellcheck disable=SC1007  # CDPATH= scopes an empty CDPATH to the cd, intentionally
      script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      
      registry="${AOP_POLICIES:-}"
      if [ -z "$registry" ]; then
        if [ -f "${script_dir}/policies.json" ]; then
          registry="${script_dir}/policies.json"
        else
          registry="${script_dir}/../policies/policies.json"
        fi
      fi
      # Fail OPEN if the registry or jq is unavailable: an admission layer that
      # bricks every tool call on a missing file is worse than no layer (dcg
      # precedent: fail-open on timeout).
      command -v jq >/dev/null 2>&1 || exit 0
      [ -f "$registry" ] || exit 0
      
      input="$(cat)"
      # 2>/dev/null: malformed stdin must be FULLY silent (fail open), not leak jq
      # parse errors to stderr (validator finding F3, 2026-07-20).
      tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)"
      cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)"
      fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)"
      sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
      [ -n "$tool" ] || exit 0
      
      hash_value() {
        # SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
        if command -v sha256sum >/dev/null 2>&1; then
          printf '%s' "$1" | sha256sum | cut -d' ' -f1
        elif command -v shasum >/dev/null 2>&1; then
          printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
        elif command -v openssl >/dev/null 2>&1; then
          printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
        fi
      }
      
      emit_telemetry() {
        # $1 policy id, $2 mode, $3 decision, $4 matched value
        local h
        h="$(hash_value "$4")"
        [ -n "$h" ] || return 0
        local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}"
        local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
        mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
        local line
        line="$(jq -nc \
          --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
          --arg session "$sid" \
          --arg token_class "$1" \
          --arg path_sha256 "$h" \
          --arg mode "$2" \
          --arg decision "$3" \
          '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision}' \
        )" || return 0
        printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0
      }
      
      waived() {
        # $1 policy id -> 0 when a waiver applies.
        case ",${AOP_WAIVE:-}," in
          *",$1,"*) return 0 ;;
        esac
        local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME}/.agents/ao}/policy-waivers}"
        [ -f "$wfile" ] || return 1
        local now id expiry
        now="$(date +%s)"
        while read -r id expiry _; do
          [ "$id" = "$1" ] || continue
          case "$expiry" in (*[!0-9]*|'') continue ;; esac
          [ "$expiry" -gt "$now" ] && return 0
        done < "$wfile"
        return 1
      }
      
      deny_id=""; deny_msg=""; deny_val=""
      route_id=""; route_msg=""; route_val=""
      
      # Iterate matchers flattened as unit-separator-joined fields:
      # id / mode / field / pattern / route_message. NOT @tsv — TSV escaping mangles
      # backslashes inside regex patterns (\. arrives as \\.), silently breaking
      # every pattern that escapes a metacharacter.
      while IFS=$'\x1f' read -r pid pmode pfield ppattern pmsg; do
        [ -n "$pid" ] || continue
        case "$pfield" in
          command)   val="$cmd" ;;
          file_path) val="$fpath" ;;
          *) continue ;;
        esac
        [ -n "$val" ] || continue
        printf '%s' "$val" | grep -qE "$ppattern" || continue
        if waived "$pid"; then
          emit_telemetry "$pid" "$pmode" "waived" "$val"
          continue
        fi
        case "$pmode" in
          deny)
            if [ -z "$deny_id" ]; then deny_id="$pid"; deny_msg="$pmsg"; deny_val="$val"; fi
            ;;
          route)
            if [ -z "$route_id" ]; then route_id="$pid"; route_msg="$pmsg"; route_val="$val"; fi
            ;;
          audit)
            emit_telemetry "$pid" "audit" "audit" "$val"
            ;;
        esac
      done < <(jq -r --arg tool "$tool" '
        .policies[]
        | . as $p
        | .matchers[]
        | select(.tools | index($tool))
        | [$p.id, $p.mode, .field, .pattern, ($p.route_message // "")]
        | join("")
      ' "$registry" 2>/dev/null)
      
      if [ -n "$deny_id" ]; then
        emit_telemetry "$deny_id" "deny" "deny" "$deny_val"
        sdir="${TMPDIR:-/tmp}/aop-policy-dispatch"
        sentinel="${sdir}/${sid//\//_}-${deny_id//[^a-zA-Z0-9]/_}"
        if [ -f "$sentinel" ]; then
          printf '⛔ policy %s: blocked (reason shown earlier this session).\n' "$deny_id" >&2
        else
          mkdir -p "$sdir" 2>/dev/null || true
          : > "$sentinel" 2>/dev/null || true
          printf '⛔ policy %s\n%s\n' "$deny_id" "$deny_msg" >&2
        fi
        exit 2
      fi
      
      if [ -n "$route_id" ]; then
        emit_telemetry "$route_id" "route" "ask" "$route_val"
        jq -nc --arg reason "policy ${route_id}: ${route_msg}" \
          '{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"ask", permissionDecisionReason:$reason}}'
        exit 0
      fi
      
      exit 0
      
    • read-budget-guard.sh 19.4 KB
      #!/usr/bin/env bash
      # read-budget-guard (PreToolUse / Read|Bash) — policy core.context:unbounded-read
      #
      # Blocks an UNBOUNDED read of a text file over the line budget (default 350).
      # The mistake-token: a Read without a numeric limit, or a Bash cat/head/tail
      # whose EFFECTIVE line count exceeds the budget. Every such line lands in this
      # context and is re-sent on every later turn; the same rule written into
      # CLAUDE.md was advisory and ignored (the Spotify finding), so it lives here as
      # a hook that can refuse. A LOOKUP predicate (wc -l on the exact argument), so
      # this is a STANDALONE opt-in guard — never a policies.json registry entry
      # (the dispatcher accepts pure regex predicates only). Ships INERT: nothing
      # wires it until scripts/install-read-budget-guard.sh is run explicitly.
      #
      # Decision (deny-not-route: EVERY attempt blocks, the guard never self-relaxes):
      #   FIRE  -> exit 2 + stderr. First fire in a session: the FULL message (both
      #            correct moves — slice it, or delegate to a bulk-reader); later
      #            fires in the same session: ONE short line (sentinel-gated).
      #   PASS / WAIVED / DISABLED -> exit 0, ZERO stdout, ZERO stderr (stray stdout
      #            on an exit-0 PreToolUse path is parsed as JSON by the harness).
      #
      # PASS cases: a Read with a numeric
      # limit; a file at/below budget; a missing / non-regular / binary path; a Bash
      # command containing | < > (a bounded consumer or a file sink); any command
      # word other than cat/head/tail; unresolvable tokens ($VAR, globs, backticks);
      # quoted text that merely mentions cat (the split is quote-aware).
      #
      # Env:
      #   AOP_READ_BUDGET_LINES     line budget (positive integer; malformed -> 350)
      #   AOP_WAIVE                 comma list of waived policy ids (env, or an inline
      #                             AOP_WAIVE=<ids> prefix on the Bash command)
      #   AOP_WAIVER_FILE           "<id> <expiry-unix-epoch>" lines, same semantics
      #                             as policy-dispatch.sh
      #   AGENTOPS_HOOKS_DISABLED=1 kill switch: exit 0, silent, no telemetry
      #   AGENTOPS_GUARDRAIL_TELEMETRY / AGENTOPS_HOME  telemetry ledger location
      #
      # Telemetry: exactly one JSONL line per FIRE and per WAIVED call — never on
      # pass / disabled / fail-open. The RESOLVED offending path is hashed (SHA-256);
      # the raw path and the raw command are never stored. Telemetry failure never
      # changes the exit decision.
      #
      # Fail OPEN: no jq -> exit 0; malformed JSON -> exit 0 silent; empty or unknown
      # tool -> exit 0. Portable bash 3.2 + BSD tools: no GNU-only flags, no sed -i,
      # no mapfile, no associative arrays; head/tail flags parsed with case. Bash
      # judging also fails open without awk or uname.
      set -uo pipefail
      # Tokens are matched literally: a `*` / `?` / `[` in a command must never be
      # expanded against the hook's own cwd.
      set -f
      
      # Kill switch: silent, no telemetry, before anything else is touched.
      [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0
      
      # Fail OPEN if jq is unavailable: a guard that cannot parse its input must
      # never brick a tool call.
      command -v jq >/dev/null 2>&1 || exit 0
      
      policy_id="core.context:unbounded-read"
      
      input="$(cat)"
      # 2>/dev/null: malformed stdin must be FULLY silent (fail open), never leak jq
      # parse errors to stderr.
      tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)"
      [ -n "$tool" ] || exit 0
      case "$tool" in Read|Bash) ;; *) exit 0 ;; esac
      sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
      [ -n "$sid" ] || sid="nosession"
      cwd="$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null)"
      [ -n "$cwd" ] || cwd="$PWD"
      
      # Normalize decimal strings before arithmetic. Bash wraps overflowing integers;
      # saturate budgets at its signed 64-bit maximum and reject out-of-range
      # command counts. Leading zeroes do not invoke octal arithmetic.
      max_integer=9223372036854775807
      normalize_uint() {
        local value="$1"
        case "$value" in ''|*[!0-9]*) return 1 ;; esac
        value="${value#"${value%%[!0]*}"}"
        [ -n "$value" ] || value=0
        # Equal-width decimals compare lexically before entering machine arithmetic.
        # shellcheck disable=SC2071
        if [ "${#value}" -gt 19 ] || { [ "${#value}" -eq 19 ] && [[ "$value" > "$max_integer" ]]; }; then
          [ "${2:-}" = exact ] && return 1
          value="$max_integer"
        fi
        printf '%s' "$value"
      }
      budget="$(normalize_uint "${AOP_READ_BUDGET_LINES:-350}")" || budget=350
      [ "$budget" -gt 0 ] || budget=350
      
      # Set when a leading AOP_WAIVE=<ids> assignment on the Bash command names this
      # policy: the WHOLE call is waived.
      inline_waived=0
      
      # resolve_path P → absolute path: relative paths resolve against the JSON cwd.
      resolve_path() {
        case "$1" in
          /*) printf '%s' "$1" ;;
          \~|\~/*)
            # Bash tokens already have their unquoted tilde expanded by the lexer.
            # Read paths keep the existing HOME shorthand; otherwise retain it literally.
            if [ "${2:-}" != literal ] && [ -n "${HOME:-}" ]; then printf '%s%s' "$HOME" "${1#\~}"; else printf '%s/%s' "$cwd" "$1"; fi ;;
          *)  printf '%s/%s' "$cwd" "$1" ;;
        esac
      }
      
      # is_text_file P → 0 when P is an existing, readable, regular file with no NUL
      # byte in its first 8 KiB (the portable binary test: compare the byte count
      # with and without NULs stripped).
      is_text_file() {
        [ -f "$1" ] && [ -r "$1" ] || return 1
        local all stripped
        all="$(head -c 8192 "$1" 2>/dev/null | wc -c | tr -d ' ')"
        stripped="$(head -c 8192 "$1" 2>/dev/null | tr -d '\000' | wc -c | tr -d ' ')"
        [ "$all" = "$stripped" ]
      }
      
      # line_count P → number of newline characters in P (trimmed).
      line_count() {
        wc -l < "$1" 2>/dev/null | tr -d ' '
      }
      
      hash_value() {
        # SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
        if command -v sha256sum >/dev/null 2>&1; then
          printf '%s' "$1" | sha256sum | cut -d' ' -f1
        elif command -v shasum >/dev/null 2>&1; then
          printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
        elif command -v openssl >/dev/null 2>&1; then
          printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
        fi
      }
      
      emit_telemetry() {
        # $1 decision (deny|waived), $2 resolved path, $3 effective lines, $4 tool.
        # Best-effort: no hasher -> no line (never leak the raw path); any failure
        # returns 0 so the exit decision is unchanged.
        # No ledger location at all (no HOME, no AGENTOPS_* override) -> no line;
        # never anchor the default at the filesystem root.
        [ -n "${AGENTOPS_GUARDRAIL_TELEMETRY:-}${AGENTOPS_HOME:-}${HOME:-}" ] || return 0
        local h
        h="$(hash_value "$2")"
        [ -n "$h" ] || return 0
        local tdir="${AGENTOPS_HOME:-${HOME:-}/.agents/ao}"
        local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
        mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
        local line
        line="$(jq -nc \
          --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
          --arg session "$sid" \
          --arg token_class "$policy_id" \
          --arg path_sha256 "$h" \
          --arg mode "deny" \
          --arg decision "$1" \
          --arg tool "$4" \
          --argjson lines "$3" \
          --argjson budget "$budget" \
          '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision, tool:$tool, lines:$lines, budget:$budget}' \
          2>/dev/null)" || return 0
        # Braces: a failed redirect is reported by bash BEFORE a trailing 2>/dev/null
        # applies, and an exit-0 path must stay silent.
        { printf '%s\n' "$line" >> "$tfile"; } 2>/dev/null || return 0
      }
      
      waived() {
        # 0 when a waiver applies: inline prefix, AOP_WAIVE env, or an unexpired
        # waiver-file entry (same semantics as policy-dispatch.sh).
        [ "$inline_waived" -eq 1 ] && return 0
        case ",${AOP_WAIVE:-}," in
          *",${policy_id},"*) return 0 ;;
        esac
        local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME:-}/.agents/ao}/policy-waivers}"
        [ -f "$wfile" ] || return 1
        local now id expiry
        now="$(date +%s)"
        while read -r id expiry _; do
          [ "$id" = "$policy_id" ] || continue
          case "$expiry" in (*[!0-9]*|'') continue ;; esac
          [ "$expiry" -gt "$now" ] && return 0
        done < "$wfile"
        return 1
      }
      
      fire() {
        # $1 resolved path, $2 effective lines, $3 tool. Never returns.
        if waived; then
          emit_telemetry "waived" "$1" "$2" "$3"
          exit 0
        fi
        emit_telemetry "deny" "$1" "$2" "$3"
        local sdir="${TMPDIR:-/tmp}/aop-read-budget-guard"
        local sentinel="${sdir}/${sid//\//_}"
        if [ -f "$sentinel" ]; then
          printf '⛔ policy %s: %s is %s lines (budget %s) — slice it (offset+limit / sed -n) or delegate to agentops:bulk-reader (full reason shown earlier this session).\n' \
            "$policy_id" "$1" "$2" "$budget" >&2
          exit 2
        fi
        mkdir -p "$sdir" 2>/dev/null || true
        { : > "$sentinel"; } 2>/dev/null || true
        cat >&2 <<MSG
      ⛔ policy ${policy_id}
      $1 is $2 lines (budget ${budget}). An unbounded read puts every line into this context and re-sends it on every later turn.
      → Read a slice: Read(file_path, offset, limit) with limit ≤ ${budget}, or Bash: sed -n '1,${budget}p' $1 / grep -n <pattern> $1.
      → Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
          Agent tool: subagent_type "agentops:bulk-reader", prompt "<question>\nfiles: $1"
          Workflow: agentops:bulk-read { question: "<question>", files: ["$1"] }
          These names require the AgentOps plugin. Use bare names only when the runtime lists standalone definitions or links under those names.
      Waive once: AOP_WAIVE=${policy_id} (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=$2 in the hook env (an operator setting, not a command prefix).
      MSG
        exit 2
      }
      
      # ---------------------------------------------------------------- Read ------
      
      check_read() {
        local fpath ltype abs lines
        fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path | select(type == "string")' 2>/dev/null)"
        [ -n "$fpath" ] || return 0
        # A bounded slice always passes; offset alone does NOT bound.
        ltype="$(printf '%s' "$input" | jq -r '.tool_input.limit | type' 2>/dev/null)"
        [ "$ltype" = "number" ] && return 0
        abs="$(resolve_path "$fpath")"
        is_text_file "$abs" || return 0
        lines="$(line_count "$abs")"
        case "$lines" in ''|*[!0-9]*) return 0 ;; esac
        [ "$lines" -gt "$budget" ] || return 0
        fire "$abs" "$lines" "Read"
      }
      
      # ---------------------------------------------------------------- Bash ------
      
      # Resolve an executable without invoking it. Bare names use the command's
      # literal PATH assignments and cwd; paths containing / resolve against cwd.
      resolve_command() {
        case "$1" in
          */*) resolve_path "$1" literal ;;
          *) (cd "$cwd" 2>/dev/null && PATH="$2" type -P -- "$1" 2>/dev/null) ;;
        esac
      }
      
      # check_segment receives literal words from the lexer, prefixed with "a" for
      # syntactic assignments or "w" for ordinary words. Never eval command input.
      check_segment() {
        local -a toks files
        toks=("$@"); files=()
        local n i t cmdw command_literal command_path utility_family platform v n_raw want_next sign num options
        local lookup_path="${PATH:-}"
        n=${#toks[@]}
        [ "$n" -gt 0 ] || return 0
        i=0
        while [ "$i" -lt "$n" ]; do
          t="${toks[$i]}"
          case "$t" in
            aPATH=*) lookup_path="${t#aPATH=}" ;;
            aAOP_WAIVE=*)
              v="${t#aAOP_WAIVE=}"
              case ",${v}," in *",${policy_id},"*) inline_waived=1 ;; esac ;;
            a*) ;;
            *) break ;;
          esac
          i=$((i + 1))
        done
        [ "$i" -lt "$n" ] || return 0
        command_literal="${toks[$i]#w}"
        cmdw="${command_literal##*/}"
        case "$cmdw" in cat|head|tail) ;; *) return 0 ;; esac
        command_path="$(resolve_command "$command_literal" "$lookup_path")" || return 0
        [ -n "$command_path" ] || return 0
        command_path="$(resolve_path "$command_path" literal)"
        [ -f "$command_path" ] && [ -x "$command_path" ] || return 0
        # Darwin system utilities reject some GNU forms without reading. Follow
        # executable identity, including symlinks: basename alone is insufficient.
        # uname is a guard dependency; never probe a caller-selected executable.
        utility_family=generic
        platform="$(uname -s 2>/dev/null)" || return 0
        if [ "$platform" = Darwin ]; then
          case "$cmdw" in
            cat) [ "$command_path" -ef /bin/cat ] && utility_family=bsd-cat ;;
            head) [ "$command_path" -ef /usr/bin/head ] && utility_family=bsd-head ;;
            tail) [ "$command_path" -ef /usr/bin/tail ] && utility_family=bsd-tail ;;
          esac
        fi
        i=$((i + 1))
      
        n_raw=10; want_next=""; options=1
        while [ "$i" -lt "$n" ]; do
          t="${toks[$i]:1}"
          i=$((i + 1))
          if [ -n "$want_next" ]; then
            n_raw="$t"; want_next=""; continue
          fi
          if [ "$options" -eq 1 ]; then
            case "$t" in
              --) options=0; continue ;;
              --help|--version) return 0 ;;
              -) continue ;; # stdin, including after -- (handled below too)
            esac
            if [ "$cmdw" = cat ]; then
              # Only known output-format flags are non-bounding. Unknown options
              # may terminate without reading any file, so fail open.
              if [ "$utility_family" = bsd-cat ]; then
                case "$t" in --*|-*[AET]*) return 0 ;; esac
              fi
              case "$t" in
                --number|--number-nonblank|--squeeze-blank|--show-all|--show-ends|--show-nonprinting|--show-tabs) continue ;;
                -*) v="${t#-}"; case "$v" in *[!AbensTtuvE]*) return 0 ;; *) continue ;; esac ;;
              esac
            else
              case "$t" in
                -c*|--bytes|--bytes=*|-f|-F|--follow|--follow=*) return 0 ;;
                -n|--lines) want_next=1; continue ;;
                -n*) n_raw="${t#-n}"; continue ;;
                --lines=*) n_raw="${t#--lines=}"; continue ;;
                -[0-9]*) n_raw="${t#-}"; continue ;;
                --quiet|--silent|--verbose) [ "$utility_family" = bsd-head ] && return 0; continue ;;
                -*)
                  v="${t#-}"
                  case "$v" in *[!qv]*) return 0 ;; esac
                  [ "$utility_family" = bsd-head ] && return 0
                  continue ;;
              esac
            fi
          fi
          [ "$t" = - ] && continue
          files[${#files[@]}]="$t"
        done
        [ -z "$want_next" ] || return 0
        [ "${#files[@]}" -gt 0 ] || return 0
      
        local abs lines eff total maxlines maxpath
        if [ "$cmdw" = cat ]; then
          total=0; maxlines=0; maxpath=""
          for t in "${files[@]}"; do
            abs="$(resolve_path "$t" literal)"
            is_text_file "$abs" || continue
            lines="$(line_count "$abs")"
            case "$lines" in ''|*[!0-9]*) continue ;; esac
            if [ "$lines" -gt "$((max_integer - total))" ]; then
              total="$max_integer"
            else
              total=$((total + lines))
            fi
            if [ "$lines" -gt "$maxlines" ] || [ -z "$maxpath" ]; then
              maxlines="$lines"; maxpath="$abs"
            fi
          done
          [ -n "$maxpath" ] || return 0
          [ "$total" -gt "$budget" ] || return 0
          fire "$maxpath" "$total" Bash
        fi
      
        # head -K means all but the last K; tail +K starts at line K (0 and 1
        # both start at the first line). Out-of-range counts fail open: the utility
        # may reject them instead of reading. Never let them wrap in arithmetic.
        sign=""; num="$n_raw"
        case "$n_raw" in
          +*) sign="+"; num="${n_raw#+}" ;;
          -*) sign="-"; num="${n_raw#-}" ;;
        esac
        [ "$utility_family" = bsd-head ] && [ "$sign" = - ] && return 0
        num="$(normalize_uint "$num" exact)" || return 0
        for t in "${files[@]}"; do
          abs="$(resolve_path "$t" literal)"
          is_text_file "$abs" || continue
          lines="$(line_count "$abs")"
          case "$lines" in ''|*[!0-9]*) continue ;; esac
          if [ "$cmdw" = head ] && [ "$sign" = - ]; then
            eff=$((lines - num))
            [ "$eff" -lt 0 ] && eff=0
          elif [ "$cmdw" = tail ] && [ "$sign" = + ]; then
            if [ "$num" -le 1 ]; then eff="$lines"; else eff=$((lines - num + 1)); fi
            [ "$eff" -lt 0 ] && eff=0
          else
            eff="$num"
            [ "$eff" -gt "$lines" ] && eff="$lines"
          fi
          [ "$eff" -gt "$budget" ] || continue
          fire "$abs" "$eff" Bash
        done
        return 0
      }
      
      check_bash() {
        local cmd token
        local -a words
        words=()
        cmd="$(printf '%s' "$input" | jq -r '.tool_input.command | select(type == "string")' 2>/dev/null)"
        [ -n "$cmd" ] || return 0
        # Pipes and redirects are explicitly outside this guard, even in quotes.
        case "$cmd" in *'|'*|*'<'*|*'>'*) return 0 ;; esac
        command -v awk >/dev/null 2>&1 || return 0
        # The lexer keeps literal word boundaries (including spaces/newlines),
        # strips shell quotes, and removes escaped newlines outside single quotes.
        # It emits NOTHING until the whole command is known to use this subset.
        # Expansions, ANSI-C quotes, control syntax, directory changes and persistent
        # assignments before later segments fail open for the whole call. This avoids both stale-cwd attribution and
        # prematurely blocking text before an unmatched/unsupported later quote.
        while IFS= read -r -d '' token; do
          if [ "$token" = s ]; then
            if [ "${#words[@]}" -gt 0 ]; then check_segment "${words[@]}"; fi
            words=()
          else
            words[${#words[@]}]="$token"
          fi
        done < <(printf '%s\n' "$cmd" | awk '
          function word_done(    value, kind) {
            if (!active) return
            if (persistent_assignment) bad = 1
            value = word
            if (tilde && ENVIRON["HOME"] != "") value = ENVIRON["HOME"] substr(value, 2)
            kind = assignment ? "a" : "w"
            records[++count] = kind value
            segment_words++; pending_and = 0
            if (!command_seen && !assignment) {
              command_seen = 1
              # Builtins/wrappers may change cwd or shell evaluation for later
              # segments; reserved words require a real shell grammar.
              if (value ~ /^(cd|pushd|popd|builtin|command|eval|source|\.|if|then|else|elif|fi|while|until|do|done|for|case|esac|select|function|!|time|coproc|exec)$/) bad = 1
            }
            word = ""; active = 0; assignment = 0; quoted = 0; tilde = 0
          }
          function segment_done() {
            word_done()
            # Assignment-only commands persist shell state for later segments.
            # Do not judge those later words using the original hook environment.
            if (segment_words && !command_seen) persistent_assignment = 1
            records[++count] = "s"
            command_seen = 0; segment_words = 0
          }
          BEGIN { q = ""; word = ""; count = 0 }
          {
            line = $0; n = length(line); continuation = 0
            for (i = 1; i <= n; i++) {
              c = substr(line, i, 1); nextc = substr(line, i + 1, 1)
              if (q == "\047") {
                if (c == "\047") q = ""; else word = word c
                continue
              }
              if (c == "\\") {
                if (i == n) { continuation = 1; break }
                active = 1; quoted = 1
                if (q == "\"" && nextc !~ /[\\"$`]/) word = word "\\"
                word = word nextc; i++; continue
              }
              if (q == "\"") {
                if (c == "\"") q = ""
                else if (c == "$" || c == "`") bad = 1
                else word = word c
                continue
              }
              if (c == "#" && !active) break
              if (c == "\"" || c == "\047") { q = c; active = 1; quoted = 1; continue }
              if (c == " " || c == "\t") { word_done(); continue }
              if (c == ";") {
                word_done(); if (!segment_words || pending_and) bad = 1
                segment_done(); continue
              }
              if (c == "&" && nextc == "&") {
                word_done(); if (!segment_words || pending_and) bad = 1
                segment_done(); pending_and = 1; i++; continue
              }
              if (c ~ /[$`*?\[(){}&]/) { bad = 1; continue }
              if (c == "~") {
                if (!active && (nextc == "/" || nextc == "" || nextc ~ /[ \t;]/)) tilde = 1
                else { bad = 1; continue }
              }
              if (c == "=" && !quoted && word ~ /^[A-Za-z_][A-Za-z0-9_]*$/) assignment = 1
              active = 1; word = word c
            }
            if (!continuation) {
              if (q != "") word = word "\n"; else segment_done()
            }
          }
          END {
            if (q != "" || continuation || pending_and || bad) exit
            for (j = 1; j <= count; j++) printf "%s%c", records[j], 0
          }
        ')
        return 0
      }
      
      case "$tool" in
        Read) check_read ;;
        Bash) check_bash ;;
      esac
      exit 0
      
    • skill-first-coord-guard.sh 2.3 KB
      #!/usr/bin/env bash
      # Portable PreToolUse command guard for Claude Code, Codex, and AGY.
      #
      # The hook is intentionally silent and fail-open unless it sees an actual
      # Agent Mail / NTM command head or `tmux send-keys`. On the first match for a
      # runtime session, it asks the agent to load the owning skill contract and
      # exits 2 so the command can be reconsidered. The next attempt is allowed.
      set -uo pipefail
      
      [[ "${AGENTOPS_HOOKS_DISABLED:-0}" == "1" ]] && exit 0
      command -v jq >/dev/null 2>&1 || exit 0
      
      input="$(cat)" || exit 0
      cmd="$(
        printf '%s' "$input" |
          jq -er '
            (
              .tool_input.command
              // .tool_input.cmd
              // .tool_input.command_line
              // .command
              // ""
            ) | strings
          ' 2>/dev/null
      )" || exit 0
      [[ -n "$cmd" ]] || exit 0
      
      sid="$(
        printf '%s' "$input" |
          jq -r '
            (
              .session_id
              // .thread_id
              // .conversation_id
              // .project_id
              // ""
            ) | tostring
          ' 2>/dev/null
      )" || sid=""
      
      # Remove quoted spans and heredoc bodies before examining shell command heads.
      # This avoids firing on issue bodies, commit messages, or prose that merely
      # mentions a coordination command.
      stripped="$(
        printf '%s' "$cmd" |
          perl -0777 -pe "
            s/'[^']*'//g;
            s/\"[^\"]*\"//g;
            s/<<-?\s*([A-Za-z_][A-Za-z0-9_]*).*?^\s*\1\b//gms;
          " 2>/dev/null
      )" || exit 0
      
      is_coord=0
      printf '%s' "$stripped" | awk '
        BEGIN { RS="[;&\n]|\\|\\|?"; FS="[ \t]+" }
        {
          i=1
          while (i<=NF && ($i=="" || $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++
          head=$i
          sub(/^.*\//, "", head)
          if (head=="am" || head=="ntm") found=1
          if (head=="tmux" && $(i+1)=="send-keys") found=1
        }
        END { exit (found ? 0 : 1) }
      ' && is_coord=1
      [[ "$is_coord" -eq 1 ]] || exit 0
      
      if [[ -n "$sid" ]]; then
        sentinel_dir="${TMPDIR:-/tmp}/agentops-coordguard"
        safe_sid="$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_.-' '_')"
        sentinel="$sentinel_dir/$safe_sid"
        [[ -f "$sentinel" ]] && exit 0
        mkdir -p "$sentinel_dir" 2>/dev/null || true
        : >"$sentinel" 2>/dev/null || true
      fi
      
      cat >&2 <<'MSG'
      AgentOps coordination guard: load the `agent-mail` or `ntm` skill contract
      before hand-writing coordination commands. Re-run the command after loading
      the skill; this guard fires at most once for a runtime session.
      MSG
      exit 2
      
  • policies
    • policies.json 7.9 KB
      {
        "schema": "hooks-manifest.v2",
        "comment": "Policies-as-data registry for policy-dispatch.sh (age-bhsz, epic age-4qw1). Predicate discipline: only predicate_class 'pure' (syntactic regex over tool_input.command / tool_input.file_path) may carry mode deny|route; lookup/stateful predicates ship audit-only until promoted with reviewed fires. Enforced by skills/cc-hooks/scripts/lint-policies.sh against schemas/hooks-manifest.v2.schema.json. Patterns are POSIX ERE (BSD grep -E compatible: no \\b, no lookaround).",
        "policies": [
          {
            "id": "core.git:add-beads-ledger",
            "predicate_class": "pure",
            "mode": "deny",
            "matchers": [
              {
                "tools": [
                  "Bash"
                ],
                "field": "command",
                "pattern": "(^|[;&|][[:space:]]*)git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+add[[:space:]]([^;&|]*[[:space:]/=])?_beads"
              }
            ],
            "route_message": "_beads/ is the PRIVATE bead ledger (its own git repo) — never stage it into the public tree; the leak is one-way. Sync it by pushing the ledger repo itself: (cd _beads && git push)",
            "rationale": "CLAUDE.md footgun row + memory agentops-br-private-ledger. Only the explicit '_beads' path form is matched; 'git add -A' silently sweeping _beads/ is the stateful variant and stays out of deny per predicate discipline.",
            "value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.git:add-beads-ledger); retire on false-positive evidence"
          },
          {
            "id": "core.provenance:ledger-hand-append",
            "predicate_class": "pure",
            "mode": "deny",
            "matchers": [
              {
                "tools": [
                  "Bash"
                ],
                "field": "command",
                "pattern": "(>>?[[:space:]]*[^[:space:];&|]*docs/provenance/ledger\\.jsonl)|(tee[[:space:]]+(-a[[:space:]]+)?[^[:space:];&|]*docs/provenance/ledger\\.jsonl)"
              },
              {
                "tools": [
                  "Edit",
                  "Write"
                ],
                "field": "file_path",
                "pattern": "(^|/)docs/provenance/ledger\\.jsonl$"
              }
            ],
            "route_message": "docs/provenance/ledger.jsonl is HASH-CHAINED (prev_hash/payload_hash/hash on every record) and SEALED — a hand-written row breaks VerifyChain for every record after it. Append through the owning command instead: ao provenance add (schema-validated, sealed onto the chain tip)",
            "rationale": "Memory pawl-gated-land-flow ('SEALED, never hand-append'). Reads (grep/jq/cat with no redirect onto the file) never match.",
            "value_proof": "declining fire-attempt rate (token_class core.provenance:ledger-hand-append); retire on false-positive evidence"
          },
          {
            "id": "core.skills:copy-into-installed",
            "predicate_class": "pure",
            "mode": "deny",
            "matchers": [
              {
                "tools": [
                  "Bash"
                ],
                "field": "command",
                "pattern": "(^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.(claude|codex|gemini)/skills(/[^[:space:];&|]*)?[[:space:]]*([;&|]|$)"
              }
            ],
            "route_message": "~/.claude/skills (and .codex/.gemini) are INSTALLED/symlinked copies — a cp/rsync/mv into them writes through the symlink into whatever branch the source checkout is on, or is overwritten on install. Link instead: ao skills link. Closes the Bash gap of the Edit|Write-only installed-skill-edit-guard.",
            "rationale": "Global CLAUDE.md 'Never cp into ~/.claude/skills'. Destination position is enforced: the installed-skills path must be the LAST token of the command segment, so copying FROM an installed dir out to the repo never fires.",
            "value_proof": "declining fire-attempt rate (token_class core.skills:copy-into-installed); retire on false-positive evidence"
          },
          {
            "id": "core.skills:edit-installed-copy",
            "predicate_class": "pure",
            "mode": "deny",
            "matchers": [
              {
                "tools": [
                  "Edit",
                  "Write"
                ],
                "field": "file_path",
                "pattern": "/\\.(claude|codex|gemini)/skills/"
              }
            ],
            "route_message": "This is an INSTALLED skill copy (overwritten on install, or symlinked through to the factory checkout) — editing it is lost work. Edit skills/<name>/ in the source repo instead.",
            "rationale": "Registry twin of the standalone installed-skill-edit-guard: matches tool_input.file_path ONLY (prose that merely mentions claude/skills lands in tool_input.content and can never fire). Subsumes the standalone guard for dispatcher users; the standalone script remains for hosts wanting only that one guard.",
            "value_proof": "declining fire-attempt rate (token_class core.skills:edit-installed-copy); 2 real fires already recorded by the standalone guard's telemetry; retire on false-positive evidence"
          },
          {
            "id": "core.verdicts:hand-edit",
            "predicate_class": "pure",
            "mode": "deny",
            "matchers": [
              {
                "tools": [
                  "Bash"
                ],
                "field": "command",
                "pattern": "(>>?[[:space:]]*[^[:space:];&|]*\\.agents/ao/verdicts/)|(tee[[:space:]]+(-[^[:space:];&|]*[[:space:]]+)*[^[:space:];&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.agents/ao/verdicts/[^[:space:];&|]*[[:space:]]*([;&|]|$))"
              },
              {
                "tools": [
                  "Bash"
                ],
                "field": "command",
                "pattern": "((^|[;&|][[:space:]]*)sed[[:space:]]+([^;&|]*[[:space:]])?-i[^[:space:]]*[[:space:]][^;&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)perl[[:space:]]+([^;&|]*[[:space:]])?-(p|n)?i[^[:space:]]*[[:space:]][^;&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)(rm|unlink|shred)[[:space:]][^;&|]*\\.agents/ao/verdicts/)"
              },
              {
                "tools": [
                  "Edit",
                  "Write"
                ],
                "field": "file_path",
                "pattern": "(^|/)\\.agents/ao/verdicts/"
              }
            ],
            "route_message": ".agents/ao/verdicts/ holds content-addressed evidence — the filename IS the SHA-256 of the file's own canonical content, and only the validate flow writes it. A hand edit breaks digest identity: the name keeps pointing at bytes that no longer hash to it, so every consumer reads a verdict that cannot verify. Don't patch the artifact — re-run validation and let it persist a fresh one: ao provenance store-verdict --root <subject-root> --evidence-root <explicit-non-Git-root> --draft <draft.json> --intent-source <intent> --subject-manifest <manifest.json> --author-context-id <author> --validator-context-id <validator> --freshness-source <runtime|caller> --freshness-attester-id <attester> --scope-result <PASS|FAIL|NOT_PROVEN>",
            "rationale": "The first policy guarding the PRODUCT's invariant rather than this repo's own artifacts. CLAUDE.md 'Validate once, fresh' and docs/architecture/rpi-traversal.md make a verdict.v2 artifact digest-named (sha256/<digest>.json), so hand-editing one is silent evidence forgery, not a typo fix. Sibling craft of core.provenance:ledger-hand-append. Destination position is enforced on cp/rsync/mv (the verdicts path must be the LAST token of the segment), so copying a verdict OUT for inspection never fires, and reads (cat/ls/jq with no redirect ONTO the store) never match. .agents/ao/intents/ stays deliberately out of scope: this policy guards one invariant and ships the evidence for that one; widening it needs its own fire evidence. In-place editors (sed -i, perl -pi/-ni, with or without a backup suffix) and deleters (rm, unlink, shred) are matched as flag-tokens so reads never fire (sed 's/-input//' <verdict> stays silent — bats-proven both directions). Remaining disclosed gap: the noclobber override redirect (>|), which needs its own shaping.",
            "value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.verdicts:hand-edit); retire on false-positive evidence"
          }
        ]
      }
      
  • references
    • DCG-RCH.md 7.7 KB
      # DCG and RCH: Production Hook Examples
      
      Real-world PreToolUse hooks from production systems.
      
      ## Combined Configuration
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                { "type": "command", "command": "dcg" },
                { "type": "command", "command": "rch" }
              ]
            }
          ]
        }
      }
      ```
      
      Both hooks run in parallel on every Bash command.
      
      ---
      
      ## DCG (Destructive Command Guard)
      
      **Purpose:** Safety hook that blocks dangerous commands before execution.
      
      ### What DCG Blocks
      
      **Git Commands:**
      - `git reset --hard` - Destroys uncommitted work
      - `git checkout -- <path>` - Discards local changes
      - `git restore` (without --staged) - Discards changes
      - `git clean -f` - Deletes untracked files
      - `git push --force` - Rewrites remote history
      - `git branch -D` - Force-deletes branch
      - `git stash drop/clear` - Destroys stashes
      
      **Filesystem:**
      - `rm -rf` outside of /tmp, /var/tmp, $TMPDIR
      
      **Additional Packs:**
      - `containers.docker` - Container destruction
      - `kubernetes.kubectl` - Cluster operations
      - `databases.sql` - DROP, TRUNCATE, DELETE without WHERE
      - `cloud.terraform` - Infrastructure destruction
      
      ### Installation
      
      ```bash
      # Install via Homebrew
      brew install dcg
      
      # Or from source
      cargo install destructive_command_guard
      ```
      
      ### Configuration
      
      ```bash
      # Environment variables
      DCG_VERBOSE=0-3        # Verbosity (0=quiet, 3=trace)
      DCG_QUIET=1           # Suppress non-error output
      DCG_NO_COLOR=1        # Disable colors
      DCG_FORMAT=text|json|sarif
      DCG_CONFIG=/path      # Explicit config file
      DCG_HOOK_TIMEOUT_MS   # Evaluation timeout
      ```
      
      ### How It Works
      
      1. Receives JSON hook input via stdin
      2. Parses the `tool_input.command` field
      3. Evaluates against pattern packs
      4. Returns exit 2 with explanation if blocked
      5. Returns exit 0 if allowed
      
      ### Example Output (Blocked)
      
      ```
      🛡️  DCG blocked: git reset --hard
      
      This command destroys uncommitted work. Alternatives:
        • git stash          - Save changes temporarily
        • git diff > backup  - Export changes first
        • git reset --soft   - Keep changes staged
      ```
      
      ---
      
      ## RCH (Remote Compilation Helper)
      
      **Purpose:** Intercepts build commands and offloads to faster remote workers.
      
      ### What RCH Intercepts
      
      - `cargo build`, `cargo test`, `cargo check`
      - `make`, `cmake --build`
      - `go build`, `go test`
      - `npm run build`, `yarn build`
      - Other configurable patterns
      
      ### How It Works
      
      ```
      ┌─────────────────────────────────────────────────────────┐
      │  Claude Code                                             │
      │  ─────────────                                          │
      │  1. Claude wants: cargo build --release                 │
      │                        │                                │
      │                        ▼                                │
      │  2. PreToolUse hook fires → RCH receives JSON           │
      │                        │                                │
      │                        ▼                                │
      │  3. RCH detects: "This is a cargo command"              │
      │                        │                                │
      │                        ▼                                │
      │  4. RCH routes to remote worker via SSH                 │
      │     - Syncs project files                               │
      │     - Executes on fast machine                          │
      │     - Streams output back                               │
      │                        │                                │
      │                        ▼                                │
      │  5. Returns JSON: permissionDecision: "allow"           │
      │     with updatedInput containing modified command       │
      └─────────────────────────────────────────────────────────┘
      ```
      
      ### Installation
      
      ```bash
      # Quick start
      rch hook install && rch daemon start
      
      # Verify
      rch status --workers --jobs
      ```
      
      ### Commands
      
      ```bash
      rch hook install     # Install PreToolUse hook
      rch hook test        # Test with sample cargo build
      rch daemon start     # Start local daemon
      rch daemon stop      # Stop daemon
      rch workers probe    # Test worker connectivity
      rch workers add      # Add new worker
      rch config show      # Show configuration
      rch doctor           # Run diagnostics
      ```
      
      ### Configuration
      
      ```bash
      # Environment variables
      RCH_PROFILE=dev|prod|test
      RCH_LOG_LEVEL=trace|debug|info|warn|error
      RCH_DAEMON_SOCKET=/path/to/socket
      RCH_SSH_KEY=/path/to/key
      RCH_TRANSFER_ZSTD_LEVEL=1-22
      ```
      
      ### Config Precedence
      
      1. Command-line arguments
      2. Environment variables
      3. Profile defaults (RCH_PROFILE)
      4. .env / .rch.env files
      5. Project config (.rch/config.toml)
      6. User config (~/.config/rch/config.toml)
      7. Built-in defaults
      
      ---
      
      ## Hook Interaction
      
      DCG and RCH work together:
      
      1. **DCG runs first** (parallel, but faster)
         - If DCG blocks → command never reaches RCH
         - If DCG allows → continues to RCH
      
      2. **RCH evaluates**
         - If build command → intercept and route
         - If not build → pass through unchanged
      
      3. **Results merged**
         - Both can modify the command
         - Both can add context
         - Any block is final
      
      ---
      
      ## Writing Your Own Hook Like DCG/RCH
      
      ### Minimal Rust Structure
      
      ```rust
      use serde::{Deserialize, Serialize};
      use std::io::{self, Read};
      
      #[derive(Deserialize)]
      struct HookInput {
          tool_name: String,
          tool_input: ToolInput,
      }
      
      #[derive(Deserialize)]
      struct ToolInput {
          command: String,
      }
      
      #[derive(Serialize)]
      struct HookOutput {
          #[serde(rename = "hookSpecificOutput")]
          hook_specific_output: HookSpecificOutput,
      }
      
      #[derive(Serialize)]
      struct HookSpecificOutput {
          #[serde(rename = "hookEventName")]
          hook_event_name: String,
          #[serde(rename = "permissionDecision")]
          permission_decision: String,
          #[serde(rename = "permissionDecisionReason")]
          permission_decision_reason: String,
      }
      
      fn main() {
          let mut input = String::new();
          io::stdin().read_to_string(&mut input).unwrap();
      
          let hook_input: HookInput = serde_json::from_str(&input).unwrap();
      
          if should_block(&hook_input.tool_input.command) {
              eprintln!("Blocked: dangerous command");
              std::process::exit(2);
          }
      
          // Allow
          std::process::exit(0);
      }
      ```
      
      ### Minimal Python Structure
      
      ```python
      #!/usr/bin/env python3
      import json
      import sys
      
      def main():
          input_data = json.load(sys.stdin)
          command = input_data.get('tool_input', {}).get('command', '')
      
          if is_dangerous(command):
              print("Blocked: dangerous command", file=sys.stderr)
              sys.exit(2)
      
          # Allow with modification
          output = {
              "hookSpecificOutput": {
                  "hookEventName": "PreToolUse",
                  "permissionDecision": "allow",
                  "permissionDecisionReason": "Safe command",
                  "updatedInput": {
                      "command": modify_command(command)
                  }
              }
          }
          print(json.dumps(output))
          sys.exit(0)
      
      if __name__ == '__main__':
          main()
      ```
      
      ---
      
      ## Troubleshooting
      
      ### DCG Not Blocking
      
      ```bash
      # Check DCG is in path
      which dcg
      
      # Test manually
      echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | dcg
      echo $?  # Should be 2
      ```
      
      ### RCH Not Intercepting
      
      ```bash
      # Check daemon running
      rch daemon status
      
      # Check worker connectivity
      rch workers probe --all
      
      # Test hook manually
      rch hook test
      ```
      
      ### Hook Format Error
      
      ```
      hooks.PreToolUse: Expected array, but received object
      ```
      
      **Fix:** Use new array format:
      ```json
      // Wrong
      {"PreToolUse": {"tools": ["Bash"], "hooks": [...]}}
      
      // Correct
      {"PreToolUse": [{"matcher": "Bash", "hooks": [...]}]}
      ```
      
    • GUARDRAIL-VALUE-PROOF.md 9.7 KB
      # Guardrail Value-Proof Methodology (pre-registered)
      
      `age-workflow-guardrail-hooks-j39.2` · BC6-Orchestration · cc-hooks family
      
      This document is the **pre-registered methodology** that lets a workflow-guardrail
      hook earn the "lease on life" ADR-0002 demands. It is written and committed
      *before* the measurement is run, so the success criterion and the null-tolerance
      cannot be retrofitted to whatever the data happens to say.
      
      > **Status at landing (no overclaim):** this ENABLES the ADR-0002 proof — it does
      > not yet PROVIDE it. The guard ships INERT (opt-in installer); the telemetry
      > channel collects **zero** data until it is installed AND N≥30 real fires
      > accrue. So ADR-0002 l.58 is *not cleared at landing* — it becomes clearable once
      > the data exists. (Recorded by the 2026-06-17 recent-commits review.)
      
      ## Why this exists (the whole point)
      
      AgentOps went hookless (#511) on the finding that hooks "couldn't be proven to
      have value" — the 2.x A/B eval showed injected context made no difference
      (`aggregate_delta = 0`). ADR-0002
      (`docs/adr/ADR-0002-agentops-3-hookless-cdlc-rearchitecture.md`, l.58) therefore
      requires, for any hook to survive: **"test or eval evidence showing positive
      value."** Without that evidence, the installed-skill-edit keystone guard is just
      another unproven hook awaiting the next teardown. This methodology + the per-fire
      telemetry it consumes *is* that evidence pipeline.
      
      ## The sensor: gate-blind per-fire telemetry
      
      The keystone guard (`skills/cc-hooks/hooks/installed-skill-edit-guard.sh`) emits
      **exactly one JSONL line per FIRE** to
      `${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl`
      (override with `AGENTOPS_GUARDRAIL_TELEMETRY`):
      
      ```json
      {"ts":"2026-06-16T18:30:00Z","session":"<session_id>","token_class":"installed-skill-edit","path_sha256":"<64-hex>"}
      ```
      
      - `ts` — UTC ISO-8601, second resolution.
      - `session` — the Claude `session_id` (the unit the attempt-rate is computed per).
      - `token_class` — which mistake-token / guard fired (`installed-skill-edit`).
      - `path_sha256` — **a SHA-256 hash of the edited path, never the raw path.**
      
      **Privacy invariant:** the raw command/path is NEVER persisted — only the hash.
      The hash is one-way; it lets us count *distinct* edited targets and detect
      repeats without ever logging what the agent was editing. Asserted in
      `tests/scripts/installed-skill-edit-telemetry.bats`.
      
      **Inert by default:** the emission code only runs when the guard fires, and this
      standalone guard ships INERT (opt-in installer only) even though the PreToolUse
      policy dispatcher ships by default. On a machine where the guard is not
      installed, zero lines are ever written. On a
      machine where it IS installed, the happy path (any non-installed-skill edit)
      writes nothing.
      
      **Gate-blind:** the sensor records the *attempt*, not the outcome of the
      redirect. It cannot see whether the agent subsequently "did the right thing" — by
      design (see the Goodhart note below).
      
      ## The metric: fire-ATTEMPT rate over time
      
      Define, per session `s`:
      
      - `fires(s)` = count of telemetry lines with `token_class = installed-skill-edit`
        emitted during session `s`.
      
      The success signal is a **declining fire-attempt rate across sessions** —
      i.e. a downward trend in `fires(s)` (or `fires(s)` normalized by session
      length / edit volume) as `s` advances in time. The interpretation: once a guard
      reliably interrupts a mistake-token, the agent (and the operator tuning prompts/
      skills around it) stops *attempting* the mistake. That is a learning signal that
      the gate's own redirect **cannot fabricate** — the redirect fires *after* the
      attempt is already counted; lowering the count requires the attempt itself to
      stop happening, which the hook cannot do by counting.
      
      ### Why NOT the hand-roll / "did they comply" rate (the Goodhart trap)
      
      The original design measured the hand-roll rate with the guard on vs off. That
      was **rejected** (premortem finding #3) as circular / Goodhart:
      
      - The gate's redirect lowers the post-redirect hand-roll rate *by construction* —
        the guard exists to do exactly that, so "the rate went down" proves nothing.
      - The counterfactual ("would the agent have complied without the guard?") is
        unobservable in a single timeline.
      - `N=1` with the guard always-on is the same regime that produced the repo's
        `delta=0` / `-0.37` corpus-A/B nulls.
      
      The attempt rate over time sidesteps all three: it is measured on the *input*
      side of the redirect, so the redirect cannot move it; the trend is across the
      agent's *own* history, needing no off-arm counterfactual.
      
      ## Pre-registered decision rule
      
      Fixed **before** any data is collected:
      
      - **Minimum N:** at least **30 sessions** with the guard installed before any
        trend claim is made. Below N, report raw counts only — no verdict.
      - **Noise floor:** fire counts are low-rate and bursty (one footgun cluster can
        spike a single session). A declining trend counts only if it survives a
        per-session-median (or 5-session moving-average) smoothing — a single quiet
        session is not a trend.
      - **Earns its keep (KEEP):** at N ≥ 30, the smoothed fire-attempt rate shows a
        **monotone-ish downward trend** (later windows strictly below earlier windows)
        AND the guard demonstrably caused at least one redirect (≥1 fire) without ever
        firing on the happy path (zero false-positive telemetry lines). This is
        positive behavior-change evidence per ADR-0002 l.58.
      - **NULL is ACCEPTABLE (KEEP-on-no-harm):** if at N ≥ 30 the rate is flat or the
        trend is inconclusive, that is an **expected, acceptable outcome — not a project
        failure.** The repo's measured A/B base rate for context interventions is
        null/negative; a flat attempt-rate paired with **zero context tax** (silent on
        every happy path, asserted by the keystone bats) and **zero false positives**
        satisfies the ADR-0002 l.58 "lease on life" as *no harm + a measurable signal
        channel that exists and runs*. A guard that is provably silent and provably
        fires only on the real mistake-token has earned its keep even with a flat
        trend, because the failure mode it replaces (unproven, noisy, always-injecting
        hooks) is strictly worse.
      - **CUT:** the guard is cut if, at N ≥ 30, telemetry shows it fired on the **happy
        path** (any false-positive line — a path that was not an installed-skill edit),
        OR the emission imposed a measurable context/latency tax, OR the fire-attempt
        rate **rises** with no operator explanation. Any of these means it costs more
        than it proves.
      
      ## Falsifiability summary
      
      | Outcome at N ≥ 30 | Verdict | Rationale |
      |---|---|---|
      | Smoothed attempt-rate declines, ≥1 true fire, 0 false fires | KEEP | positive behavior-change evidence (ADR-0002 l.58) |
      | Attempt-rate flat/inconclusive, 0 false fires, 0 tax | KEEP (null = acceptable) | no harm + live signal channel; beats unproven always-on hooks |
      | Any false-positive fire, OR measurable tax, OR rising rate | CUT | costs more than it proves |
      
      ## Reproducing the read (when N is reached)
      
      ```bash
      # Fires per session, oldest→newest:
      jq -r 'select(.token_class=="installed-skill-edit") | .session' \
        "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}" \
        | sort | uniq -c
      
      # Distinct targets touched (hashes), to spot repeated footguns:
      jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \
        "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}" \
        | sort | uniq -c | sort -rn
      ```
      
      No raw path is ever available in the ledger — only hashes — so the read is
      privacy-preserving by construction.
      
      ## Read-budget guard (core.context:unbounded-read)
      
      The opt-in read-budget guard (`skills/cc-hooks/hooks/read-budget-guard.sh`,
      recipe [READ-BUDGET-GUARD.md](READ-BUDGET-GUARD.md)) reuses this sensor and
      this decision rule. Its `token_class` is the policy id
      `core.context:unbounded-read`; each line carries five extra fields:
      
      ```json
      {"ts":"…","session":"…","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
      ```
      
      - `mode` / `decision` — the dispatcher's pair: `mode` is always `deny` (this
        guard never routes); `decision` is `deny` (a fire) or `waived` (an
        `AOP_WAIVE` waiver let the call through: one line, no fire).
      - `tool` — `Read` or `Bash`.
      - `lines` / `budget` — JSON numbers: the effective line count of the offending
        read and the budget it exceeded. `path_sha256` hashes the RESOLVED path; the
        raw path and the raw command are never written.
      
      **Metric:** the same declining fire-attempt rate per session. Secondary,
      stated-denominator estimate: `sum(lines)` over `decision == "deny"` lines is an
      upper bound on lines kept out of context (denominator = fires the guard saw; it
      says nothing about pipes, redirects, globs, `sed`, `awk`, `less` — silent by design).
      
      **Countermetric:** waiver rate = `waived / (deny + waived)` per session.
      
      **CUT signals (any one):** a fire on a `limit`-bounded Read, a bounded
      effective read, a command that does not read the attributed file, or a file at
      or below budget (except a `cat` sum over budget). Each false positive is a
      defect, not noise; regression coverage is evidence for the tested shapes,
      not proof that a shell parser makes false positives impossible; or a waiver
      rate above 50% at N ≥ 30 —
      the budget is wrong for this repository, not the agent (retune
      `AOP_READ_BUDGET_LINES`; do not keep a guard everyone waives).
      
      Same **N ≥ 30** minimum and **null-is-acceptable** rule as above: a flat attempt
      rate with zero false fires and zero happy-path output is KEEP. Ships INERT —
      zero lines until installed; ADR-0002 l.58 is not cleared at landing here either.
      
      ```bash
      jq -r 'select(.token_class=="core.context:unbounded-read") | [.session,.decision,.tool,.lines] | @tsv' \
        "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}"
      ```
      
    • HOOK-EVENTS.md 7.3 KB
      # Hook Events Reference
      
      Complete documentation for all Claude Code hook events.
      
      ## PreToolUse
      
      **When:** After Claude creates tool parameters, before tool execution
      **Can Block:** Yes
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "transcript_path": "/path/to/session.jsonl",
        "cwd": "/current/directory",
        "permission_mode": "default|plan|acceptEdits|dontAsk|bypassPermissions",
        "hook_event_name": "PreToolUse",
        "tool_name": "Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|mcp__*",
        "tool_input": { /* tool-specific */ },
        "tool_use_id": "toolu_01ABC..."
      }
      ```
      
      ### Tool-Specific Inputs
      
      **Bash:**
      ```json
      {
        "command": "npm test",
        "description": "Run test suite",
        "timeout": 120000,
        "run_in_background": false
      }
      ```
      
      **Write:**
      ```json
      {
        "file_path": "/absolute/path/to/file.txt",
        "content": "file content"
      }
      ```
      
      **Edit:**
      ```json
      {
        "file_path": "/absolute/path/to/file.txt",
        "old_string": "original text",
        "new_string": "replacement",
        "replace_all": false
      }
      ```
      
      **Read:**
      ```json
      {
        "file_path": "/absolute/path/to/file.txt",
        "offset": 0,
        "limit": 100
      }
      ```
      
      ### Output: Decision Control
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "allow|deny|ask",
          "permissionDecisionReason": "Explanation",
          "updatedInput": { "field": "modified value" },
          "additionalContext": "Context added for Claude"
        }
      }
      ```
      
      | Decision | Effect |
      |----------|--------|
      | `allow` | Bypass permission system, execute immediately |
      | `deny` | Block execution, reason shown to Claude |
      | `ask` | Show permission dialog to user |
      
      ---
      
      ## PostToolUse
      
      **When:** Immediately after tool completes successfully
      **Can Block:** No (tool already ran)
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "transcript_path": "/path/to/session.jsonl",
        "cwd": "/current/directory",
        "permission_mode": "default",
        "hook_event_name": "PostToolUse",
        "tool_name": "Write",
        "tool_input": { /* original input */ },
        "tool_response": { /* tool result */ },
        "tool_use_id": "toolu_01ABC..."
      }
      ```
      
      ### Output: Feedback to Claude
      
      ```json
      {
        "decision": "block",
        "reason": "Linting errors found. Fix before continuing.",
        "hookSpecificOutput": {
          "hookEventName": "PostToolUse",
          "additionalContext": "Error on line 42: missing semicolon"
        }
      }
      ```
      
      ---
      
      ## PermissionRequest
      
      **When:** Permission dialog is about to be shown
      **Can Block:** Yes (auto-allow or auto-deny)
      
      ### Output: Auto-Resolve Permission
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PermissionRequest",
          "decision": {
            "behavior": "allow|deny",
            "updatedInput": { "command": "safe-command" },
            "message": "Reason for denial",
            "interrupt": false
          }
        }
      }
      ```
      
      ---
      
      ## UserPromptSubmit
      
      **When:** User submits prompt, before Claude processes
      **Can Block:** Yes
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "UserPromptSubmit",
        "prompt": "User's input text"
      }
      ```
      
      ### Output: Add Context or Block
      
      **Add context (simple):** Print to stdout with exit 0
      ```bash
      echo "Current time: $(date)"
      exit 0
      ```
      
      **Add context (JSON):**
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "UserPromptSubmit",
          "additionalContext": "Project is in maintenance mode until 5pm"
        }
      }
      ```
      
      **Block prompt:**
      ```json
      {
        "decision": "block",
        "reason": "Cannot process: contains sensitive data"
      }
      ```
      
      ---
      
      ## Stop
      
      **When:** Claude finishes responding (not on user interrupt)
      **Can Block:** Yes (force continue)
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "Stop",
        "stop_hook_active": true,
        "transcript_path": "/path/to/session.jsonl"
      }
      ```
      
      **Important:** Check `stop_hook_active` to prevent infinite loops.
      
      ### Output: Force Continue
      
      ```json
      {
        "decision": "block",
        "reason": "Tests are failing. Fix the errors in src/auth.ts"
      }
      ```
      
      ### Prompt-Based Stop Hook
      
      ```json
      {
        "hooks": {
          "Stop": [
            {
              "hooks": [
                {
                  "type": "prompt",
                  "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks complete.",
                  "timeout": 30
                }
              ]
            }
          ]
        }
      }
      ```
      
      LLM responds: `{"ok": true}` or `{"ok": false, "reason": "Tasks incomplete"}`
      
      ---
      
      ## SubagentStop
      
      **When:** Subagent (Task tool) finishes
      **Can Block:** Yes
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "SubagentStop",
        "stop_hook_active": false,
        "agent_id": "def456",
        "agent_transcript_path": "/path/to/subagents/agent-def456.jsonl"
      }
      ```
      
      ---
      
      ## SubagentStart
      
      **When:** Subagent is spawned
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "SubagentStart",
        "agent_id": "agent-abc123",
        "agent_type": "Explore|Plan|Bash|custom-name"
      }
      ```
      
      ---
      
      ## SessionStart
      
      **When:** Session begins or resumes
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "SessionStart",
        "source": "startup|resume|clear|compact",
        "model": "claude-sonnet-4-6",
        "agent_type": "agent-name"
      }
      ```
      
      ### Matchers
      
      - `startup` - New session
      - `resume` - From --resume, --continue, /resume
      - `clear` - After /clear
      - `compact` - After compaction
      
      ### Persisting Environment Variables
      
      ```bash
      #!/bin/bash
      if [ -n "$CLAUDE_ENV_FILE" ]; then
        echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
        echo 'export API_KEY=xxx' >> "$CLAUDE_ENV_FILE"
      fi
      exit 0
      ```
      
      ---
      
      ## SessionEnd
      
      **When:** Session terminates
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "SessionEnd",
        "reason": "clear|logout|prompt_input_exit|other"
      }
      ```
      
      ---
      
      ## Notification
      
      **When:** Claude Code sends notifications
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "Notification",
        "message": "Claude needs your permission to use Bash",
        "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog"
      }
      ```
      
      ### Example: Custom Desktop Notifications
      
      ```json
      {
        "hooks": {
          "Notification": [
            {
              "matcher": "permission_prompt",
              "hooks": [
                { "type": "command", "command": "notify-send 'Claude Code' 'Permission needed'" }
              ]
            },
            {
              "matcher": "idle_prompt",
              "hooks": [
                { "type": "command", "command": "notify-send 'Claude Code' 'Waiting for input'" }
              ]
            }
          ]
        }
      }
      ```
      
      ---
      
      ## PreCompact
      
      **When:** Before context compaction
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "PreCompact",
        "trigger": "manual|auto",
        "custom_instructions": ""
      }
      ```
      
      ### Matchers
      
      - `manual` - From /compact command
      - `auto` - Automatic due to full context
      
      ---
      
      ## Setup
      
      **When:** Invoked with --init, --init-only, or --maintenance
      **Can Block:** No
      
      ### Input Schema
      
      ```json
      {
        "session_id": "string",
        "hook_event_name": "Setup",
        "trigger": "init|maintenance"
      }
      ```
      
      ### Matchers
      
      - `init` - From --init or --init-only
      - `maintenance` - From --maintenance
      
      Has access to `CLAUDE_ENV_FILE` for persisting environment.
      
      ---
      
      ## MCP Tool Naming
      
      MCP tools follow pattern: `mcp__<server>__<tool>`
      
      ```json
      {
        "matcher": "mcp__memory__.*",
        "hooks": [{ "type": "command", "command": "log-memory-ops.sh" }]
      }
      ```
      
      Examples:
      - `mcp__memory__create_entities`
      - `mcp__filesystem__read_file`
      - `mcp__github__search_repositories`
      
    • INSTALLED-SKILL-EDIT-GUARD.md 4.3 KB
      # Installed-Skill-Edit Guard (opt-in)
      
      A PreToolUse `Edit|Write` guard that routes an edit of an **installed skill copy**
      (`*/.claude/skills/**`, `*/.codex/skills/**`, `*/.gemini/skills/**`) back to the
      repo source of truth (`skills/<name>/`). AgentOps is hookless by default —
      this guard ships **inert**; you activate it with the opt-in installer.
      
      ## Why it exists — a TRUE mistake-token
      
      An `Edit`/`Write` whose target path is under `*/.claude/skills/**` has **no
      legitimate form**. Those files are installed / symlinked copies:
      
      - they are **overwritten** on `scripts/install.sh`, so an edit there is silently
        lost work, or
      - they **symlink through** to the factory checkout, so an edit there writes into
        whatever branch that checkout happens to be on — never the intended source.
      
      CLAUDE.md's standing rule is "NEVER edit `~/.claude/skills/` — edit `skills/` in
      this repo." That rule is advisory context, which is delta≈0. This guard makes it
      **mechanical**: it keys on the action signature (the `file_path`), not the
      agent's self-narrative, so it fires even when the agent believes it is doing the
      right thing.
      
      Unlike an activity-keyed guard (which false-fires on legitimate identical forms
      and gets disabled — the #511 fate), this token is syntactically detectable with
      **zero false-positive surface**: only an installed-skills `file_path` matches,
      and a repo doc that merely *mentions* `claude/skills` in its body lands in
      `tool_input.content`, never `file_path`.
      
      ## Reversible → ROUTE, not hard-block
      
      Editing the wrong copy is recoverable (re-do the edit against `skills/`), so the
      guard **routes** rather than hard-blocks: exit 2 + a one-line stderr redirect
      naming the correct `skills/<name>/` target. It does not silently swallow the edit
      or deny irreversibly.
      
      ## Context-budget doctrine
      
      Hooks are the most powerful enforcement (mechanical, can't be reasoned past) but
      they pollute context — use sparingly:
      
      - **SILENT on the happy path**: any non-installed-skills `file_path` → exit 0,
        zero stdout, zero stderr.
      - Fire the one redirect **only on a real violation**, at most **once per
        session** (sentinel-gated in `$TMPDIR`), so it never repeats.
      - **NEVER emit stray stdout on an exit-0 PreToolUse path** — stdout there is
        parsed as JSON and a stray line breaks the tool call. Block via exit 2 +
        stderr only.
      
      ## The guard
      
      Ships as `skills/cc-hooks/hooks/installed-skill-edit-guard.sh`. It reads the
      PreToolUse JSON on stdin, matches `tool_input.file_path` only, and derives the
      repo-relative `skills/<name>/` target for the redirect message.
      
      ## Opt-in install
      
      ```bash
      # user scope (~/.claude/settings.json) — the default
      scripts/install-installed-skill-edit-guard.sh
      
      # project scope (.claude/settings.json)
      scripts/install-installed-skill-edit-guard.sh --project
      
      # explicit target
      SETTINGS=/path/to/settings.json scripts/install-installed-skill-edit-guard.sh
      ```
      
      The installer copies the guard to `~/.claude/hooks/installed-skill-edit-guard.sh`
      and adds (idempotently) a PreToolUse `Edit|Write` matcher:
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Edit|Write",
              "hooks": [
                { "type": "command", "command": "~/.claude/hooks/installed-skill-edit-guard.sh" }
              ]
            }
          ]
        }
      }
      ```
      
      Requires `jq` on `PATH`.
      
      ## Test it
      
      `tests/scripts/installed-skill-edit-guard.bats` round-trips the real PreToolUse
      JSON shape on stdin and proves the contract:
      
      - **FIRE (exit 2)**: `~/.claude/skills/<x>/SKILL.md`, an absolute
        `/Users/*/.claude/skills/**`, `.codex/skills/**`, `.gemini/skills/**`.
      - **SILENT (exit 0, zero output)**: repo `skills/**` (absolute or relative), an
        unrelated source file, a doc whose path mentions `claude` but not the
        installed-skills segment, and a missing `file_path`.
      - **once-per-session**: first violation fires, the second self-relaxes.
      
      ```bash
      bats tests/scripts/installed-skill-edit-guard.bats
      ```
      
      ## Known limitations
      
      It matches the `file_path` only, so it cannot guard an edit reached through a tool
      that does not populate `file_path` (e.g. a `Bash` `sed -i` into the installed
      copy) — that is a `Bash` path, not an `Edit`/`Write`, and out of scope here. The
      cost of a missed case is one un-routed edit; there is no false fire and no broken
      tool call. Erring toward silence keeps it cheap on context and safe to run.
      
    • JSON-OUTPUT.md 5.7 KB
      # JSON Output Reference
      
      Complete schemas for hook JSON responses.
      
      ## Common Fields (All Hooks)
      
      ```json
      {
        "continue": true,
        "stopReason": "Why Claude should stop",
        "suppressOutput": false,
        "systemMessage": "Warning shown to user"
      }
      ```
      
      | Field | Type | Description |
      |-------|------|-------------|
      | `continue` | boolean | If false, Claude stops after hooks run |
      | `stopReason` | string | Message shown when continue=false |
      | `suppressOutput` | boolean | Hide from verbose mode (ctrl+o) |
      | `systemMessage` | string | Warning displayed to user |
      
      ---
      
      ## PreToolUse
      
      ### Allow (Auto-Approve)
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "allow",
          "permissionDecisionReason": "Safe operation auto-approved"
        }
      }
      ```
      
      ### Deny (Block)
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "deny",
          "permissionDecisionReason": "Blocked: dangerous command"
        }
      }
      ```
      
      ### Ask (Show Dialog)
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "ask",
          "permissionDecisionReason": "Requires explicit approval"
        }
      }
      ```
      
      ### Modify Input
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "allow",
          "permissionDecisionReason": "Modified for safety",
          "updatedInput": {
            "command": "npm run lint -- --fix",
            "timeout": 60000
          }
        }
      }
      ```
      
      ### Add Context
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "additionalContext": "Environment: production. Proceed with caution."
        }
      }
      ```
      
      ### Full Example
      
      ```json
      {
        "continue": true,
        "suppressOutput": true,
        "hookSpecificOutput": {
          "hookEventName": "PreToolUse",
          "permissionDecision": "allow",
          "permissionDecisionReason": "Build command routed to remote worker",
          "updatedInput": {
            "command": "rch-exec cargo build --release"
          },
          "additionalContext": "Build will execute on worker-1 (32 cores)"
        }
      }
      ```
      
      ---
      
      ## PermissionRequest
      
      ### Allow Permission
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PermissionRequest",
          "decision": {
            "behavior": "allow"
          }
        }
      }
      ```
      
      ### Allow with Modified Input
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PermissionRequest",
          "decision": {
            "behavior": "allow",
            "updatedInput": {
              "command": "npm run build:safe"
            }
          }
        }
      }
      ```
      
      ### Deny Permission
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PermissionRequest",
          "decision": {
            "behavior": "deny",
            "message": "Denied: production deployment requires approval",
            "interrupt": false
          }
        }
      }
      ```
      
      ### Deny and Stop Claude
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PermissionRequest",
          "decision": {
            "behavior": "deny",
            "message": "Critical: manual intervention required",
            "interrupt": true
          }
        }
      }
      ```
      
      ---
      
      ## PostToolUse
      
      ### Provide Feedback (Block)
      
      ```json
      {
        "decision": "block",
        "reason": "Linting errors found. Fix before continuing.",
        "hookSpecificOutput": {
          "hookEventName": "PostToolUse",
          "additionalContext": "Errors:\n- line 42: missing semicolon\n- line 55: unused variable"
        }
      }
      ```
      
      ### Add Context Only
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "PostToolUse",
          "additionalContext": "File formatted successfully with prettier"
        }
      }
      ```
      
      ---
      
      ## UserPromptSubmit
      
      ### Add Context (Simpler)
      
      Just print to stdout:
      ```bash
      echo "Current time: $(date)"
      echo "Project: myapp v1.2.3"
      ```
      
      ### Add Context (JSON)
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "UserPromptSubmit",
          "additionalContext": "User is working on feature-auth branch. 3 open PRs pending review."
        }
      }
      ```
      
      ### Block Prompt
      
      ```json
      {
        "decision": "block",
        "reason": "Prompt contains potentially sensitive data. Please rephrase."
      }
      ```
      
      ---
      
      ## Stop / SubagentStop
      
      ### Allow Stop (Default)
      
      No output needed, or:
      ```json
      {}
      ```
      
      ### Force Continue
      
      ```json
      {
        "decision": "block",
        "reason": "Tests are failing. Run `npm test` and fix errors in src/auth.ts before stopping."
      }
      ```
      
      ---
      
      ## SessionStart
      
      ### Add Context
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "SessionStart",
          "additionalContext": "Project: myapp\nBranch: feature-auth\nOpen issues: 5"
        }
      }
      ```
      
      ---
      
      ## Setup
      
      ### Add Context
      
      ```json
      {
        "hookSpecificOutput": {
          "hookEventName": "Setup",
          "additionalContext": "Dependencies installed. Database migrations applied."
        }
      }
      ```
      
      ---
      
      ## Prompt-Based Hook Response
      
      For `type: "prompt"` hooks:
      
      ### Allow
      
      ```json
      {
        "ok": true
      }
      ```
      
      ### Block/Deny
      
      ```json
      {
        "ok": false,
        "reason": "Tasks incomplete. The test suite is still failing."
      }
      ```
      
      ---
      
      ## Exit Code Behavior Summary
      
      | Exit Code | JSON Parsed? | Effect |
      |-----------|--------------|--------|
      | 0 | Yes | Success, JSON controls behavior |
      | 2 | No | Block, stderr fed to Claude |
      | Other | No | Non-blocking error, stderr to verbose |
      
      **Important:** Exit code 2 ignores any JSON output. Use stderr for the message.
      
      ---
      
      ## Deprecated Fields
      
      These still work but use new format:
      
      | Old | New |
      |-----|-----|
      | `decision: "approve"` | `permissionDecision: "allow"` |
      | `decision: "block"` | `permissionDecision: "deny"` |
      | `reason` | `permissionDecisionReason` |
      
      ---
      
      ## Field Availability by Event
      
      | Field | PreToolUse | PostToolUse | UserPromptSubmit | Stop |
      |-------|------------|-------------|------------------|------|
      | `continue` | ✓ | ✓ | ✓ | ✓ |
      | `decision` | ✓ | ✓ | ✓ | ✓ |
      | `permissionDecision` | ✓ | - | - | - |
      | `updatedInput` | ✓ | - | - | - |
      | `additionalContext` | ✓ | ✓ | ✓ | - |
      | `reason` | ✓ | ✓ | ✓ | ✓ |
      
    • PATTERNS.md 9.4 KB
      # Hook Patterns and Recipes
      
      Common patterns for Claude Code hooks.
      
      ## Auto-Format on File Write
      
      ### TypeScript/JavaScript with Prettier
      
      ```json
      {
        "hooks": {
          "PostToolUse": [
            {
              "matcher": "Edit|Write",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.ts || \"$f\" == *.tsx || \"$f\" == *.js ]] && npx prettier --write \"$f\"; } || true"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Go with gofmt
      
      ```json
      {
        "hooks": {
          "PostToolUse": [
            {
              "matcher": "Edit|Write",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.go ]] && gofmt -w \"$f\"; } || true"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Rust with rustfmt
      
      ```json
      {
        "hooks": {
          "PostToolUse": [
            {
              "matcher": "Edit|Write",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.rs ]] && rustfmt \"$f\"; } || true"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Multi-Language Formatter
      
      ```bash
      #!/bin/bash
      # ~/.claude/hooks/auto-format.sh
      INPUT=$(cat)
      FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')
      
      case "$FILE" in
        *.ts|*.tsx|*.js|*.jsx)
          npx prettier --write "$FILE" 2>/dev/null
          ;;
        *.go)
          gofmt -w "$FILE" 2>/dev/null
          ;;
        *.rs)
          rustfmt "$FILE" 2>/dev/null
          ;;
        *.py)
          black "$FILE" 2>/dev/null || ruff format "$FILE" 2>/dev/null
          ;;
      esac
      exit 0
      ```
      
      ---
      
      ## File Protection
      
      ### Block Sensitive Files
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Edit|Write",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|\\.git/|credentials|secrets|password)' && { echo 'Blocked: sensitive file' >&2; exit 2; } || exit 0"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Block Production Paths
      
      ```bash
      #!/bin/bash
      INPUT=$(cat)
      # Never assign to $PATH — it clobbers the shell's executable search path and
      # breaks every command after it. Use a distinct variable name.
      FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
      
      BLOCKED_PATTERNS=(
        "/prod/"
        "/production/"
        "deploy/"
        ".env.production"
      )
      
      for pattern in "${BLOCKED_PATTERNS[@]}"; do
        if [[ "$FILE_PATH" == *"$pattern"* ]]; then
          echo "Blocked: production file $FILE_PATH" >&2
          exit 2
        fi
      done
      exit 0
      ```
      
      ---
      
      ## Command Validation
      
      ### Block Dangerous Git Commands
      
      ```python
      #!/usr/bin/env python3
      import json
      import sys
      import re
      
      DANGEROUS_PATTERNS = [
          r'git\s+reset\s+--hard',
          r'git\s+clean\s+-[fd]',
          r'git\s+push\s+.*--force',
          r'git\s+checkout\s+--\s+\.',
          r'git\s+branch\s+-D',
      ]
      
      input_data = json.load(sys.stdin)
      command = input_data.get('tool_input', {}).get('command', '')
      
      for pattern in DANGEROUS_PATTERNS:
          if re.search(pattern, command):
              print(f"Blocked: dangerous git command", file=sys.stderr)
              sys.exit(2)
      
      sys.exit(0)
      ```
      
      ### Suggest Better Commands
      
      ```python
      #!/usr/bin/env python3
      import json
      import sys
      import re
      
      SUGGESTIONS = [
          (r'\bgrep\b(?!.*\|)', "Use 'rg' (ripgrep) instead of grep"),
          (r'\bfind\s+\S+\s+-name\b', "Use 'fd' or 'rg --files' instead of find"),
          (r'\bcat\s+\S+\s*\|\s*grep', "Use 'rg pattern file' directly"),
      ]
      
      input_data = json.load(sys.stdin)
      command = input_data.get('tool_input', {}).get('command', '')
      
      for pattern, suggestion in SUGGESTIONS:
          if re.search(pattern, command):
              print(f"Suggestion: {suggestion}", file=sys.stderr)
              # Non-blocking - just advice
              break
      
      sys.exit(0)
      ```
      
      ---
      
      ## Logging and Auditing
      
      ### Log All Commands
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                {
                  "type": "command",
                  "command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-command-log.txt"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Structured JSON Logging
      
      ```bash
      #!/bin/bash
      INPUT=$(cat)
      TIMESTAMP=$(date -Iseconds)
      LOG_ENTRY=$(echo "$INPUT" | jq -c --arg ts "$TIMESTAMP" '{timestamp: $ts, tool: .tool_name, input: .tool_input}')
      echo "$LOG_ENTRY" >> ~/.claude/hooks.jsonl
      exit 0
      ```
      
      ### Log to Syslog
      
      ```bash
      #!/bin/bash
      INPUT=$(cat)
      TOOL=$(echo "$INPUT" | jq -r '.tool_name')
      CMD=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // "unknown"')
      logger -t claude-code "Tool: $TOOL, Target: $CMD"
      exit 0
      ```
      
      ---
      
      ## Custom Notifications
      
      ### Desktop Notification (Linux)
      
      ```json
      {
        "hooks": {
          "Notification": [
            {
              "matcher": "permission_prompt",
              "hooks": [
                {
                  "type": "command",
                  "command": "notify-send -u critical 'Claude Code' 'Permission required'"
                }
              ]
            },
            {
              "matcher": "idle_prompt",
              "hooks": [
                {
                  "type": "command",
                  "command": "notify-send 'Claude Code' 'Waiting for your input'"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### macOS Notification
      
      ```json
      {
        "hooks": {
          "Notification": [
            {
              "matcher": "",
              "hooks": [
                {
                  "type": "command",
                  "command": "osascript -e 'display notification \"Awaiting input\" with title \"Claude Code\"'"
                }
              ]
            }
          ]
        }
      }
      ```
      
      ### Slack/Discord Webhook
      
      ```bash
      #!/bin/bash
      INPUT=$(cat)
      MSG=$(echo "$INPUT" | jq -r '.message')
      curl -X POST "$SLACK_WEBHOOK_URL" \
        -H 'Content-Type: application/json' \
        -d "{\"text\": \"Claude Code: $MSG\"}" \
        2>/dev/null
      exit 0
      ```
      
      ---
      
      ## Context Injection
      
      ### Add Project Context at Session Start
      
      ```bash
      #!/bin/bash
      # SessionStart hook
      if [ -f "$CLAUDE_PROJECT_DIR/.claude/context.md" ]; then
        cat "$CLAUDE_PROJECT_DIR/.claude/context.md"
      fi
      
      # Add git status
      echo "Current branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"
      echo "Modified files: $(git status --porcelain 2>/dev/null | wc -l || echo 0)"
      
      exit 0
      ```
      
      ### Add Context from External Tool
      
      ```python
      #!/usr/bin/env python3
      import json
      import subprocess
      import sys
      
      # Get current issues
      result = subprocess.run(['gh', 'issue', 'list', '--limit', '5', '--json', 'title,number'],
                              capture_output=True, text=True)
      
      if result.returncode == 0:
          issues = json.loads(result.stdout)
          if issues:
              output = {
                  "hookSpecificOutput": {
                      "hookEventName": "SessionStart",
                      "additionalContext": f"Open issues: {json.dumps(issues)}"
                  }
              }
              print(json.dumps(output))
      
      sys.exit(0)
      ```
      
      ---
      
      ## Stop Hook: Ensure Quality
      
      ### Run Tests Before Stopping
      
      ```python
      #!/usr/bin/env python3
      import json
      import sys
      import subprocess
      
      input_data = json.load(sys.stdin)
      
      # Prevent infinite loops
      if input_data.get('stop_hook_active'):
          sys.exit(0)
      
      # Check if tests pass
      result = subprocess.run(['npm', 'test'], capture_output=True, timeout=60)
      
      if result.returncode != 0:
          output = {
              "decision": "block",
              "reason": f"Tests failing. Fix before stopping. Error: {result.stderr.decode()[:500]}"
          }
          print(json.dumps(output))
      
      sys.exit(0)
      ```
      
      ### Check for Uncommitted Changes
      
      ```bash
      #!/bin/bash
      INPUT=$(cat)
      
      # Skip if already in stop loop
      if echo "$INPUT" | jq -e '.stop_hook_active' > /dev/null 2>&1; then
        exit 0
      fi
      
      # Check for uncommitted changes
      if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
        echo '{"decision":"block","reason":"Uncommitted changes detected. Commit or stash before finishing."}'
      fi
      
      exit 0
      ```
      
      ---
      
      ## Prompt-Based Hook (LLM Evaluation)
      
      ### Intelligent Stop Decision
      
      ```json
      {
        "hooks": {
          "Stop": [
            {
              "hooks": [
                {
                  "type": "prompt",
                  "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS\n\nCheck:\n1. Are all requested tasks complete?\n2. Are there any errors that need fixing?\n3. Is follow-up work needed?\n\nRespond: {\"ok\": true} to stop, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
                  "timeout": 30
                }
              ]
            }
          ]
        }
      }
      ```
      
      ---
      
      ## Environment Setup
      
      ### Load nvm/Node Version
      
      ```bash
      #!/bin/bash
      # SessionStart hook with CLAUDE_ENV_FILE
      
      ENV_BEFORE=$(export -p | sort)
      
      # Load nvm
      export NVM_DIR="$HOME/.nvm"
      [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
      
      # Use project's node version
      if [ -f ".nvmrc" ]; then
        nvm use 2>/dev/null
      fi
      
      # Persist environment changes
      if [ -n "$CLAUDE_ENV_FILE" ]; then
        ENV_AFTER=$(export -p | sort)
        comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE"
      fi
      
      exit 0
      ```
      
      ### Activate Python Virtualenv
      
      ```bash
      #!/bin/bash
      if [ -n "$CLAUDE_ENV_FILE" ]; then
        if [ -d ".venv" ]; then
          echo 'export VIRTUAL_ENV=".venv"' >> "$CLAUDE_ENV_FILE"
          echo 'export PATH=".venv/bin:$PATH"' >> "$CLAUDE_ENV_FILE"
        fi
      fi
      exit 0
      ```
      
      ---
      
      ## Skill/Agent Scoped Hooks
      
      ### In SKILL.md Frontmatter
      
      ```yaml
      ---
      name: secure-deployment
      description: Deploy with security checks
      hooks:
        PreToolUse:
          - matcher: "Bash"
            hooks:
              - type: command
                command: "$CLAUDE_PROJECT_DIR/.claude/hooks/deploy-check.sh"
                once: true  # Only runs once per session
      ---
      ```
      
      ### In Subagent Definition
      
      ```yaml
      ---
      name: code-reviewer
      hooks:
        PostToolUse:
          - matcher: "Edit|Write"
            hooks:
              - type: command
                command: "./scripts/lint-check.sh"
      ---
      ```
      
    • READ-BUDGET-GUARD.md 20.4 KB
      # Read-Budget Guard (opt-in)
      
      A PreToolUse `Read|Bash` guard that blocks an **unbounded read of a file over
      the line budget** — a `Read` with no `limit`, or a `cat` / `head` / `tail`
      whose effective line count exceeds `AOP_READ_BUDGET_LINES` (default 350) — and
      names the two correct moves: read a slice, or delegate the file to a cheap
      reader that returns line-referenced bullets. AgentOps is hookless by default —
      this guard ships **inert**; you activate it with the opt-in installer.
      
      ## Why it exists — the rule CLAUDE.md could not enforce
      
      Spotify open-sourced its internal Claude Code setup and reports (its claim, not
      re-measured here) a ~90% token cut. The part that transfers is not the number
      but the finding behind it: v1 put "never read a large file whole" in CLAUDE.md
      and the rule was ignored — advisory context, delta≈0, the same result AgentOps
      measured in #511. The rule only held once it moved into a PreToolUse hook that
      refuses the tool call and points at the bounded alternatives.
      
      The cost it guards is compounding, not one-shot. An unbounded read of an N-line
      file puts N lines into this context **and re-sends them on every later turn**
      of the session. A 2,000-line read on turn 3 is paid again on turns 4 through
      40. A bounded slice costs its slice once; a delegated read costs a few bullets,
      because the file bytes never enter the caller's context at all.
      
      ## The predicate — a LOOKUP, so a standalone guard
      
      The policy dispatcher registry (`policies/policies.json`) only lets a
      `predicate_class: pure` regex over the raw command or `file_path` `deny` (the #511
      anti-lesson). "Is this file over 350 lines?" is not a regex: it is a
      **lookup** — one deterministic local check, `wc -l` on the exact argument, no
      repo state, no history, no model. So this guard ships as a standalone opt-in
      recipe next to [INSTALLED-SKILL-EDIT-GUARD.md](INSTALLED-SKILL-EDIT-GUARD.md)
      and never as a registry policy, even though it borrows the registry's id form
      (`core.context:unbounded-read`), its waiver mechanics and its telemetry line.
      
      The guard passes numeric-limit `Read` calls, missing/non-regular/binary files,
      and commands containing pipes or redirects. Its Bash lexer recognizes a
      conservative literal-command subset described below; unsupported syntax fails
      open. Regression tests check both missed reads and false attribution. The
      parser is not a full shell interpreter, and a passing test suite does not
      establish that every possible shell command is classified correctly.
      
      ## Deny, not route
      
      The installed-skill-edit guard routes because a wrong edit is recoverable. An
      over-budget read is not: once the bytes land in context, nothing un-reads them.
      So this guard **denies** (exit 2 + stderr) and **every attempt blocks** — it
      never self-relaxes, because the second unbounded read costs exactly what the
      first would have. What is once-per-session is the *explanation*: the first fire
      in a session prints the full message; later fires print one short line (still
      exit 2). The message names the two correct moves and nothing else.
      
      Context-budget doctrine still applies: silent on every happy path (exit 0, zero
      stdout, zero stderr — a stray stdout line on an exit-0 PreToolUse path is parsed
      as JSON and breaks the tool call), block via exit 2 + stderr only, fail OPEN.
      
      ## The contract
      
      Ships as `skills/cc-hooks/hooks/read-budget-guard.sh` (inert until the opt-in
      installer wires it; `set -uo pipefail`, no `-e`). It reads the real PreToolUse
      JSON on stdin (`{tool_name, tool_input, session_id, cwd}`) with `jq`; a missing
      `session_id` is `nosession`. Policy id and `token_class`:
      `core.context:unbounded-read`.
      
      ### `Read`
      
      - `tool_input.limit` is a number → **PASS**. `offset` alone does not bound a
        read and does not pass.
      - Otherwise resolve `tool_input.file_path` (relative → against the JSON `cwd`,
        else `$PWD`). Not an existing regular readable file, or binary (a NUL byte in
        the first 8192 bytes) → **PASS**.
      - `lines = wc -l < file`; `lines > budget` → **FIRE**.
      
      ### `Bash`
      
      - The command contains any of `|`, `<`, `>` → **PASS**. A pipe feeds a bounded
        consumer, a redirect feeds a file sink; neither lands whole in context. Out
        of scope by design, not by accident.
      - Otherwise tokenize literal words and split on `;`, `&&` and newlines
        outside single/double quotes. Quoted and escaped spaces remain part of the
        same filename; concatenated literal fragments (`my" notes".md`) work too.
        Backslash-newline is deleted outside single quotes, including inside double
        quotes. Other quoted newlines remain literal filename bytes. A `#` at a word
        start begins a comment through the newline.
      - Parsing completes before any segment is judged. Unmatched quotes, malformed
        separators, expansion syntax (`$VAR`, substitution, ANSI-C `$'...'`, unquoted globs), shell control
        syntax and directory-changing commands (`cd`, `pushd`, `popd`, including
        `builtin`/`command` wrappers) skip the whole call. Later segments are never
        attributed to the original `cwd` after a recognized directory change.
      - Leading syntactic `VAR=value` assignments are removed; a quoted assignment
        word such as `"NAME=value"` is still a command word. An `AOP_WAIVE=...`
        prefix containing the policy id waives the whole call. The basename of the
        first remaining word must be `cat`, `head` or `tail`. Resolve that literal
        executable against the command cwd and PATH (including literal leading PATH
        assignments); missing or non-executable paths pass. Resolution never invokes
        the selected executable. An assignment-only segment followed by another
        nonempty segment (`PATH=/nonexistent; cat file`) skips the whole call because
        the assignment persists shell state; later segments must not reuse the hook
        environment. A trailing assignment alone does not hide an earlier read.
      - On Darwin, compare executable identity (`-ef`, following symlinks) with
        `/bin/cat`, `/usr/bin/head` and `/usr/bin/tail`. The system `cat` rejects
        GNU-only `-A`, `-E`, `-T` (including combinations) and long flags; the system
        `head` rejects negative counts and quiet/verbose flags. Those forms pass
        because the native utility does not read the file. GNU executables named
        `cat`/`head`/`tail` retain the generic GNU forms below, even on Darwin.
      - Unquoted leading `~/` expands against `HOME`; quoted/escaped tildes remain
        literal. Other files resolve against the input `cwd`; missing, non-regular
        and binary files are skipped. `--` ends flag parsing, including before an
        option-looking filename. `-` denotes stdin and is skipped.
      - `cat`: effective = **sum** of resolved files' line counts; FIRE when over
        budget (the message names the largest file; `N` is the total). Known output
        formatting flags (`-n`, `-b`, `-s`, `-A`, `-e`, `-E`, `-t`, `-T`, `-u`,
        `-v`, their combinations and GNU long equivalents) do not bound the read.
      - `head`: `-n N`, `-nN`, `-N`, `--lines=N`, `--lines N` (default 10).
        Positive counts, including `-n +N`, use `min(N, lines)` per file; GNU
        negative `-n -K` (all but the last K) uses `max(lines - K, 0)`. FIRE if any is over budget.
      - `tail`: the same flag forms; negative counts use `min(K, lines)`;
        `-n +K` = `max(lines - K + 1, 0)`, with `+0` and `+1` both meaning the
        whole file. FIRE if any effective count is over budget.
      - `--help`, `--version`, unknown flags, byte counts and follow modes skip the
        segment. Supported `head`/`tail` quiet/verbose formatting flags are accepted,
        except for the Darwin system `head` as described above. Invalid
        or missing numeric option values skip the segment.
      - Decimal normalization removes leading zeroes before arithmetic. Budgets
        above `9223372036854775807` saturate at that value; command counts outside
        that range skip the segment because the utility may reject them. Huge
        positive values cannot wrap into tiny budgets or negative read indices.
      
      ### Always PASS (exit 0, zero output)
      
      Any other `tool_name` (an `Edit` of a huge file is a write, not a read); an
      empty or unparseable command; `cat` with no file; `git status`; `grep -n`,
      `sed -n '1,400p'`, `awk`, `less`, `more` — bounded or paged consumers, silent
      by design because they *are* the correct moves.
      
      ### Waiver, kill switch, budget
      
      | Control | Effect |
      |---|---|
      | `AOP_READ_BUDGET_LINES=<n>` | the budget; default 350, and anything that is not a positive integer falls back to 350; larger than signed 64-bit values saturate as described above. Hook env only — an operator setting, never honored as a command prefix (that would be an uncounted self-relax) |
      | `AOP_WAIVE=core.context:unbounded-read` | waive once — as hook env, or as a prefix on the Bash command itself (comma list; the id must be in it) |
      | `AOP_WAIVER_FILE` line `core.context:unbounded-read <expiry-epoch>` | timed waiver; default file `${AGENTOPS_HOME:-$HOME/.agents/ao}/policy-waivers`, same semantics as the dispatcher; an expired line still fires |
      | `AGENTOPS_HOOKS_DISABLED=1` | kill switch: exit 0, silent, no telemetry |
      
      A waived call exits 0 with zero output and writes one telemetry line with
      `decision: "waived"`, so waivers are counted — they are the countermetric.
      
      ### Fail OPEN
      
      No `jq` on `PATH` → exit 0. Malformed JSON → exit 0, silent. Empty or unknown
      tool → exit 0. Bash judging also passes without `awk` or `uname`. A guard
      that cannot decide must never brick the tool call.
      Telemetry failure never changes the exit decision.
      
      ### The message
      
      First fire in a session (full):
      
      ```text
      ⛔ policy core.context:unbounded-read
      <path> is <N> lines (budget <B>). An unbounded read puts every line into this context and re-sends it on every later turn.
      → Read a slice: Read(file_path, offset, limit) with limit ≤ <B>, or Bash: sed -n '1,<B>p' <path> / grep -n <pattern> <path>.
      → Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context:
          Agent tool: subagent_type "agentops:bulk-reader", prompt "<question>\nfiles: <path>"
          Workflow: agentops:bulk-read { question: "<question>", files: ["<path>"] }
          These names require the AgentOps plugin. Use bare names only when the runtime lists standalone definitions or links under those names.
      Waive once: AOP_WAIVE=core.context:unbounded-read (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=<N> in the hook env (an operator setting, not a command prefix).
      ```
      
      Later fires in the same session (short, still exit 2):
      
      ```text
      ⛔ policy core.context:unbounded-read: <path> is <N> lines (budget <B>) — slice it (offset+limit / sed -n) or delegate to agentops:bulk-reader (full reason shown earlier this session).
      ```
      
      The per-session sentinel lives under `${TMPDIR:-/tmp}/aop-read-budget-guard/`
      (one file per `session_id`, `/` replaced by `_`).
      
      ### Telemetry
      
      Exactly one JSONL line per FIRE and per WAIVED call — none on pass, disabled or
      fail-open — appended to
      `${AGENTOPS_GUARDRAIL_TELEMETRY:-${AGENTOPS_HOME:-$HOME/.agents/ao}/guardrail-telemetry.jsonl}`:
      
      ```json
      {"ts":"2026-09-12T10:00:00Z","session":"<session_id>","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350}
      ```
      
      `path_sha256` is the SHA-256 of the **resolved** offending path — never the raw
      path, never the command. `lines` and `budget` are JSON numbers. No hasher
      (`sha256sum` / `shasum -a 256` / `openssl dgst -sha256`) → no line rather than
      a raw path. Methodology and the pre-registered KEEP/CUT rule:
      [GUARDRAIL-VALUE-PROOF.md](GUARDRAIL-VALUE-PROOF.md).
      
      ## The delegation pairing
      
      The guard's second arrow points at the delegation layer; without it the guard
      only says "no". Three bounded, one-shot, cheap-model delegations ship next to
      it — Claude Code plugin agents and Workflow-tool conveyors; the caller sees
      bullets or a receipt, never bytes, and nothing is kept between calls:
      
      | Piece | What the caller gets |
      |---|---|
      | `agents/bulk-reader.md` — subagent `agentops:bulk-reader` (`Read`/`Grep`/`Glob`/`Bash`, no `Write`/`Edit`, haiku) | line-referenced bullets (`path:line`, at most 40 unless the caller sets another cap), no prose |
      | `workflows/bulk-read.js` — `agentops:bulk-read { question, files, root?, model?, maxBullets?, budgetLines? }` | one reader per file in parallel; `{question, files:[{file, bullets, lines_covered, complete, note?, error?}], bullets_total}` |
      | `workflows/code-write.js` with subagent `agentops:code-writer` (`agents/code-writer.md`) — `agentops:code-write { items:[{key, spec, reference, target, check?}] }` | metadata-only realpath/stat preflight for batches, then sequential writers; bounded receipts (`written`, `lines`, `check_ok`, `summary`), no check output; a reference file is REQUIRED |
      
      These invocation names require the AgentOps plugin. Bare names apply only to
      standalone definitions or links when the runtime actually lists those names.
      The plugin adds the prefix; source agent names and workflow `meta.name` stay bare.
      
      Guard compatibility: the reader and writer prompts read in **slices** (`Read`
      with `offset` + `limit ≤ budgetLines`), never an unbounded
      `Read`/`cat`/`head`/`tail`. Readers start at offset 1 and continue through EOF;
      the limit is per call, and the bullet cap does not limit coverage. Truncated
      responses require smaller slices from the first unread line, not an EOF claim.
      An early answer does not establish the final decision while lines remain unread.
      So a delegate's own
      reads pass this guard on a host where it is installed — the delegation is not
      an exemption, it is a reader that obeys the same rule. A follow-up question
      about the same file costs another delegation, not another copy of the file in
      this context.
      
      Malformed worker replies produce explicit errors. Missing reader receipts leave coverage unknown (`lines_covered: null`); missing writer receipts leave write and check state unknown, never proving that no file changed. Metadata preflight and target-only edits still require worker compliance; the Workflow surface is not a filesystem sandbox.
      
      A receipt or a bullet list is a runtime fact, not validation. Whatever a writer
      lands still gets fresh, author-distinct judgment like any other change. Pattern
      and doctrine in AgentOps terms:
      [context-budget delegation](../../agent-native/references/context-budget-delegation.md);
      workflow install and args: `workflows/README.md` in the repository checkout.
      
      ## Opt-in install
      
      ```bash
      # user scope (~/.claude/settings.json) — the default
      scripts/install-read-budget-guard.sh
      
      # project scope (.claude/settings.json)
      scripts/install-read-budget-guard.sh --project
      
      # explicit target
      SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh
      ```
      
      The installer copies the guard to `~/.claude/hooks/read-budget-guard.sh`, takes
      a uniquely named timestamped `.bak` before changing existing settings, and adds
      (idempotently, matching command type and matcher) one PreToolUse `Read|Bash` matcher:
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Read|Bash",
              "hooks": [
                { "type": "command", "command": "~/.claude/hooks/read-budget-guard.sh" }
              ]
            }
          ]
        }
      }
      ```
      
      Requires `jq` on `PATH`. The plugin manifest `hooks/hooks.json` is not touched:
      nothing wires this guard automatically, on any install path. Uninstall is the
      line the installer prints: remove the matcher, then `rm` the copied script.
      
      ## Test it
      
      Five bats files round-trip the real PreToolUse JSON (built with `jq -nc`,
      never hand-written strings) under an isolated `TMPDIR` and `HOME`, with
      `AGENTOPS_GUARDRAIL_TELEMETRY` pointed into `TMPDIR`:
      
      - `tests/scripts/read-budget-guard.bats` — **FIRE** (exit 2, stderr names the
        policy id): an unbounded `Read` of a 400-line file, `Read` with `offset`
        only, `cat big.txt`, `cat -n big.txt`, `head -n 500` / `-500` /
        `--lines=500`, `tail -n 400`, `tail -n +5`, `cat a.txt b.txt` (200 + 200), a
        relative path resolved through the JSON `cwd`, a second fire in the same
        session (short line, still exit 2), the first fire's output contains
        `bulk-reader`. **SILENT** (exit 0, zero output): `Read` with `limit 100`, a
        100-line file, a NUL-bearing binary with 400 newlines, a missing path, a
        directory, `cat big.txt | head -20`, `cat big.txt > out.txt`, `head big.txt`,
        `head -n 50`, `tail -n 20`, `grep -n`, `sed -n '1,400p'`, `cat small.txt`,
        `git status`, bare `cat`, `cd sub && cat big.txt`, an `Edit` of a big file.
        **WAIVERS**: env, command prefix, waiver file (future expiry passes, expired
        still fires), `AGENTOPS_HOOKS_DISABLED=1`, `AOP_READ_BUDGET_LINES=1000`.
        **FAIL-OPEN**: malformed JSON `{`, no `jq` on `PATH`.
      - `tests/scripts/read-budget-guard-regression.bats` — independent-review
        reproductions for ANSI-C quoted prose, cwd collisions, negative-head counts,
        help/unknown flags, literal spaced paths, quoted continuations, `--`, integer
        overflow, quoted tildes, malformed syntax and shell control flow. Every case
        captures stdout and stderr separately. The legacy negative-head expectation
        was corrected from 400 to 395 for GNU `head -n -5` on a 400-line file; the legacy
        silent expectation for a 400-line spaced filename was corrected to denial.
        Both changes restore the effective-read contract; a separate small spaced
        file with a large sibling checks that paths are not misattributed.
      - `tests/scripts/read-budget-guard-utility.bats` — compare actual utility exit
        status and stdout line counts with guard decisions. Darwin system rejects
        remain silent; positive signed head reads block; GNU formatting and negative
        counts remain guarded. GNU-specific tests use the installed GNU executable
        through a command named `cat`/`head`, and explicitly skip when GNU is absent.
        Darwin-only tests explicitly skip on other hosts. The earlier negative-head
        tests use this same distinction instead of claiming BSD rejected input reads.
      - `tests/scripts/read-budget-guard-telemetry.bats` — one line per fire; valid
        JSON with every field; `lines` and `budget` are numbers; `path_sha256` is 64
        hex and equals the hash of the resolved path; the raw path and the raw
        command never appear; nothing on the happy path; `waived` on a waiver; two
        lines for two fires in one session; nothing when disabled.
      - `tests/scripts/install-read-budget-guard.bats` — mode 755; exactly one
        `Read|Bash` matcher whose command is the installed path; idempotent re-run;
        `--project` writes `.claude/settings.json` in the cwd; a `.bak` when settings
        pre-existed; the installed file byte-equals the repo source.
      
      ```bash
      bats tests/scripts/read-budget-guard.bats \
           tests/scripts/read-budget-guard-regression.bats \
           tests/scripts/read-budget-guard-telemetry.bats \
           tests/scripts/read-budget-guard-utility.bats \
           tests/scripts/install-read-budget-guard.bats
      ```
      
      ## Known limitations
      
      The parser deliberately skips unsupported shapes instead of guessing. Known
      false-negative shapes:
      
      - **Pipes and redirects** pass wholesale (`cat big.txt | cat` included) — the
        `|` / `<` / `>` check does not inspect the consumer.
      - **Expansions and shell grammar** (`cat *.log`, `cat "$f"`, backticks,
        ANSI-C quotes, conditionals, subshells) skip the whole call. The hook never
        evaluates shell input. Literal quoted/escaped special characters are
        preserved, and unquoted leading `~/` is the one expansion mirrored.
      - **Command prefixes** (`sudo cat`, `time cat`, `env X=1 cat`) are silent:
        only a segment whose command word is `cat`, `head` or `tail` is judged.
      - **Persistent assignments**: an assignment-only segment before a later
        nonempty segment skips the whole call; persistent shell state is not tracked.
        An assignment prefix attached to a command remains supported.
      - **Directory changes and evaluation builtins** (`cd`, `pushd`, `popd`,
        `builtin`, `command`, `source`, `eval`, `exec`) skip the whole call. Shell
        functions, aliases and the exit status of earlier commands are not resolved;
        this guard cannot establish runtime reachability or arbitrary shell state.
      - **`sed`, `awk`, `less`, `more`, `grep`, `xargs`, `sh -c`** are silent by
        design; only `cat`, `head` and `tail` are inspected. `head -c` and `tail -f`
        skip their segment.
      - **Other utility implementations**: executable names use the generic flag
        set unless they match the Darwin system identities above. Arbitrary custom
        replacements and their option contracts are not inspected or executed.
      - **Lines, not bytes**: `wc -l` is the predicate, so a one-line multi-megabyte
        file passes.
      - **A subagent's own reads run under the same hook.** A `bulk-reader` that
        issues an unbounded `Read` is blocked like anyone else — which is why the
        shipped reader prompt slices. A hand-written reader that does not slice is
        denied, not exempted.
      
    • SKILL-FIRST-COORDINATION-GUARD.md 10.7 KB
      # Skill-First Coordination Guard (opt-in recipe)
      
      A copy-paste PreToolUse hook pair that nudges an agent to **load the
      coordination skill before hand-rolling the `am` / `atm` / `ntm` /
      `tmux send-keys` CLI surfaces**. AgentOps is hookless by design — nothing
      here auto-installs. This is documentation plus a recipe you opt into per host.
      
      ## Why it exists
      
      The dominant multi-agent failure mode is an agent reverse-engineering the
      coordination CLI (Agent Mail, ATM/NTM, raw `tmux send-keys`) from first
      principles instead of loading the skill that already carries the command surface
      *and* the doctrine. The skill knows the reservation protocol, the inbox model,
      the liveness truth stack; the hand-rolled invocation does not. This guard fires
      one loud nudge the first time it sees a bare coordination command in a session,
      then self-relaxes the moment the relevant skill loads.
      
      ## Context-budget doctrine for hooks
      
      Hooks are the most powerful enforcement available — mechanical, can't be
      reasoned past — but they **pollute context**, so use them sparingly:
      
      - A hook must be **SILENT on the happy path**: exit 0, no stdout, no stderr.
      - Fire **only on a real violation**, ideally **once per session**,
        sentinel-gated so it never repeats.
      - Prefer **PreToolUse violation-guards** over `UserPromptSubmit` /
        `SessionStart` per-turn injectors — the latter pay context on every turn
        whether or not anything is wrong.
      - **NEVER emit stray stdout on an exit-0 PreToolUse path** — stdout there is
        parsed as JSON and a stray line breaks the tool call. Block via **exit 2 +
        stderr** instead.
      
      This recipe is built to that doctrine: silent on every non-coordination command,
      one stderr message gated by a per-session sentinel file, self-relaxing after the
      skill loads.
      
      ## The matching defect this recipe fixes
      
      A naive line-based match —
      `grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*(am|atm|ntm)([[:space:]]|$)'` —
      **over-matches**: `grep` is line-oriented, so `^` matches *every* heredoc-body
      line, and a quoted `|ntm` inside an argument reads as a top-level `|` delimiter.
      A `br create "t" --body "...mentions am/atm/ntm and agent-mail|ntm|agent-native..."`
      would falsely fire even though no coordination command is being *run*.
      
      The fix below matches `am`/`atm`/`ntm`/`tmux send-keys` **only as an actual
      command head** — never inside quoted strings, heredoc bodies, or prose. It
      strips quoted spans (multiline-aware) and heredoc bodies, splits the remainder
      on top-level separators (`;` `&` `|` newline), and tests only the head token of
      each segment (skipping leading `VAR=val` assignments).
      
      ## Script 1 — the guard (`skill-first-coord-guard.sh`)
      
      PreToolUse / Bash. Fires once per session on a real hand-roll; silent otherwise.
      
      ```bash
      #!/usr/bin/env bash
      # skill-first-coord-guard (PreToolUse / Bash)
      # Nudge to load the agent-mail / ntm (ATM) skill BEFORE hand-rolling the
      # am / atm / ntm / tmux-send-keys CLI surfaces.
      #
      # Context-budget discipline (hooks are powerful but pollute context — use sparingly):
      #   - SILENT on the happy path (non-coordination commands → exit 0, no output).
      #   - Fires its one loud message ONLY on an actual hand-roll, and at most ONCE
      #     per session. Self-relaxes after the coordination skill loads
      #     (skill-first-coord-mark.sh) or after the single nag.
      set -uo pipefail
      
      input="$(cat)"
      cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
      sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
      
      # Match the coordination CLI (am/atm/ntm) or `tmux send-keys` ONLY as an actual
      # command HEAD — never inside quoted strings, heredoc bodies, or prose. A naive
      # line-based grep over-matches: `^` matches every heredoc-body line, and a
      # quoted `|ntm` reads as a top-level delimiter, so a `br create` whose BODY
      # merely mentions am/atm/ntm would falsely fire. So we:
      #   1. strip single/double-quoted spans (multiline-aware) and heredoc bodies,
      #   2. split what remains on top-level separators ( ; & | newline ),
      #   3. test only the HEAD token of each segment (skipping VAR=val assignments).
      is_coord=0
      stripped="$(printf '%s' "$cmd" | perl -0777 -pe "
        s/'[^']*'//g;                                      # single-quoted spans
        s/\"[^\"]*\"//g;                                   # double-quoted spans (multiline)
        s/<<-?\s*([A-Za-z_][A-Za-z0-9_]*).*?^\s*\1\b//gms; # heredoc bodies
      ")"
      printf '%s' "$stripped" | awk '
        BEGIN { RS="[;&\n]|\\|\\|?"; FS="[ \t]+" }
        {
          i=1
          while (i<=NF && ($i=="" || $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++  # skip VAR=val
          head=$i
          if (head=="am" || head=="atm" || head=="ntm") { found=1 }
          if (head=="tmux") { nxt=$(i+1); if (nxt=="send-keys") found=1 }   # tmux send-keys
        }
        END { exit (found?0:1) }
      ' && is_coord=1
      [ "$is_coord" -eq 1 ] || exit 0
      
      dir="${TMPDIR:-/tmp}/claude-coordguard"
      sentinel="$dir/${sid//\//_}"
      [ -f "$sentinel" ] && exit 0   # skill already loaded, or already nagged this session
      
      mkdir -p "$dir" 2>/dev/null || true
      : > "$sentinel" 2>/dev/null || true
      cat >&2 <<'MSG'
      ⛔ SKILL-FIRST (coordination): load the skill before hand-rolling the AM/ATM CLI.
        • am  (Agent Mail)          → Skill tool: agent-mail
        • atm / ntm pane command    → Skill tool: ntm; agent-native for role lifecycle
        • tmux send-keys to a pane  → Skill tool: ntm
      The skill carries the command surface + doctrine — don't reverse-engineer the CLI.
      Fires once per session and self-relaxes after the skill loads. Re-run your command.
      MSG
      exit 2
      ```
      
      ## Script 2 — the mark (`skill-first-coord-mark.sh`)
      
      PreToolUse / Skill. Silently records that a coordination skill loaded so the
      guard self-relaxes. Zero context output — pure side effect.
      
      ```bash
      #!/usr/bin/env bash
      # skill-first-coord-mark (PreToolUse / Skill)
      # Silently record that a coordination skill loaded this session so the
      # skill-first-coord-guard self-relaxes. ZERO context output — pure side effect.
      set -uo pipefail
      
      input="$(cat)"
      skill="$(printf '%s' "$input" | jq -r '.tool_input.skill // ""')"
      case "$skill" in
        agent-mail|ntm|agent-native)
          sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
          dir="${TMPDIR:-/tmp}/claude-coordguard"
          mkdir -p "$dir" 2>/dev/null || true
          : > "$dir/${sid//\//_}" 2>/dev/null || true
          ;;
      esac
      exit 0
      ```
      
      ## Opt-in install
      
      1. Save both scripts (e.g. to `~/.claude/hooks/`) and `chmod +x` them.
      2. Add the hook pair to `~/.claude/settings.json` (user) or
         `.claude/settings.json` (project). Note the **two separate matchers** —
         `Bash` runs the guard, `Skill` runs the mark:
      
      ```json
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "Bash",
              "hooks": [
                { "type": "command", "command": "~/.claude/hooks/skill-first-coord-guard.sh" }
              ]
            },
            {
              "matcher": "Skill",
              "hooks": [
                { "type": "command", "command": "~/.claude/hooks/skill-first-coord-mark.sh" }
              ]
            }
          ]
        }
      }
      ```
      
      Requires `jq`, `perl`, and `awk` on `PATH` (all standard on macOS and Linux).
      
      ## Test it (and prove it)
      
      A small bats test exercising the fire / silent / once-per-session contract.
      Save as `tests/skill-first-coord-guard.bats` and run with `bats <file>`:
      
      ```bash
      #!/usr/bin/env bats
      # Contract for skill-first-coord-guard.sh:
      #   FIRE (exit 2):   am robot status · atm up · ntm --robot-attention
      #                    git commit -m x && am mail send · tmux send-keys -t x hi
      #   SILENT (exit 0): ls -la · npm test · team build · echo "I am here"
      #                    br create "t" --body "...am/atm/ntm... agent-mail|ntm|agent-native..."
      GUARD="${GUARD:-$HOME/.claude/hooks/skill-first-coord-guard.sh}"
      
      setup() { export TMPDIR="$(mktemp -d)"; }
      
      run_guard() { # $1=command $2=session_id
        jq -nc --arg c "$1" --arg s "$2" '{tool_input:{command:$c},session_id:$s}' | bash "$GUARD"
      }
      
      @test "FIRE: am robot status"               { run run_guard 'am robot status' "s1";              [ "$status" -eq 2 ]; }
      @test "FIRE: atm up"                         { run run_guard 'atm up' "s2";                       [ "$status" -eq 2 ]; }
      @test "FIRE: ntm --robot-attention"          { run run_guard 'ntm --robot-attention' "s3";        [ "$status" -eq 2 ]; }
      @test "FIRE: chained && am mail send"        { run run_guard 'git commit -m x && am mail send' "s4"; [ "$status" -eq 2 ]; }
      @test "FIRE: tmux send-keys"                 { run run_guard 'tmux send-keys -t x hi' "s5";       [ "$status" -eq 2 ]; }
      
      @test "SILENT: ls -la"                       { run run_guard 'ls -la' "s6";                       [ "$status" -eq 0 ]; [ -z "$output" ]; }
      @test "SILENT: npm test"                     { run run_guard 'npm test' "s7";                     [ "$status" -eq 0 ]; [ -z "$output" ]; }
      @test "SILENT: team build"                   { run run_guard 'team build' "s8";                   [ "$status" -eq 0 ]; [ -z "$output" ]; }
      @test "SILENT: echo quoted am"              { run run_guard 'echo "I am here"' "s9";             [ "$status" -eq 0 ]; [ -z "$output" ]; }
      @test "SILENT: br create body mentions am/atm/ntm (false-positive guard)" {
        run run_guard 'br create "t" --body "...am/atm/ntm... agent-mail|ntm|agent-native..."' "s10"
        [ "$status" -eq 0 ]; [ -z "$output" ]
      }
      
      @test "once-per-session: first fires, second self-relaxes" {
        run run_guard 'am robot status' "same"; [ "$status" -eq 2 ]
        run run_guard 'atm up' "same";          [ "$status" -eq 0 ]
      }
      ```
      
      Proven output of an equivalent pure-shell harness against all contract cases
      (every FIRE → exit 2, every SILENT → exit 0 with zero stderr bytes, including
      the `br create` false-positive case and a multiline heredoc body):
      
      ```
      === FIRE (expect exit 2) ===
        exit=2  am robot status
        exit=2  atm up
        exit=2  ntm --robot-attention
        exit=2  git commit -m x && am mail send
        exit=2  tmux send-keys -t x hi
      === SILENT (expect exit 0, no stderr) ===
        exit=0  ls -la                                                       [stderr-bytes=0]
        exit=0  npm test                                                     [stderr-bytes=0]
        exit=0  team build                                                   [stderr-bytes=0]
        exit=0  echo "I am here"                                             [stderr-bytes=0]
        exit=0  br create "t" --body "...am/atm/ntm... agent-mail|ntm|..."   [stderr-bytes=0]
      === once-per-session sentinel ===
        1st am : exit=2
        2nd atm: exit=0
      ```
      
      ## Known limitations
      
      The guard tests only the actual command **head**, so it intentionally does NOT
      fire when a coordination CLI is reached indirectly — via command-substitution
      (`$(am …)`), backticks, or a command-prefix wrapper (`time am …`, `env X=1 am …`).
      This is by design: the guard is an opt-in *nudge*, not a security boundary. The
      cost of a missed case is exactly one un-nudged hand-roll — no false fire, no
      broken command. Erring toward silence keeps it cheap on context and safe to run.
      The recipe also requires `jq`, `awk`, and `perl` on `PATH`.
      
  • scripts
    • install-hooks.sh 3.5 KB
      #!/usr/bin/env bash
      # install-hooks.sh — wire the AgentOps policy dispatcher into Claude settings,
      # from ANY install shape (epic age-4qw1: hooks ship by default).
      #
      # This copy lives INSIDE the cc-hooks skill package so every distribution path
      # carries its own wiring:
      #   - npx skills / skills.sh copy  -> ~/.claude/skills/cc-hooks/scripts/install-hooks.sh
      #   - git clone / brew checkout    -> skills/cc-hooks/scripts/install-hooks.sh
      #     (scripts/install-policy-dispatch.sh at the repo root delegates here)
      #   - Claude Code PLUGIN installs need NO installer at all: the plugin bundles
      #     hooks/hooks.json and Claude wires it automatically.
      #
      # Everything resolves relative to THIS script's skill dir, so it works from a
      # copied skill directory with no repo present. Idempotent; backs up settings.
      #
      # Usage:
      #   install-hooks.sh            # user settings (~/.claude/settings.json)
      #   install-hooks.sh --project  # project settings (.claude/settings.json)
      #   SETTINGS=/path/settings.json install-hooks.sh
      set -euo pipefail
      umask 022
      
      # shellcheck disable=SC1007  # CDPATH= scopes an empty CDPATH to the cd, intentionally
      script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      skill_dir="$(dirname "$script_dir")"
      src_dispatch="${skill_dir}/hooks/policy-dispatch.sh"
      src_policies="${skill_dir}/policies/policies.json"
      lint="${script_dir}/lint-policies.sh"
      [[ -f "$src_dispatch" ]] || { echo "ERROR: dispatcher missing: ${src_dispatch}" >&2; exit 1; }
      [[ -f "$src_policies" ]] || { echo "ERROR: registry missing: ${src_policies}" >&2; exit 1; }
      command -v jq >/dev/null || { echo "ERROR: jq required" >&2; exit 1; }
      
      # Never install a registry that fails its own contract.
      bash "$lint" "$src_policies"
      
      settings="${SETTINGS:-}"
      if [[ -z "$settings" ]]; then
        case "${1:-}" in
          --project) settings=".claude/settings.json" ;;
          *)         settings="${HOME}/.claude/settings.json" ;;
        esac
      fi
      
      hooks_dir="${HOME}/.claude/hooks/aop"
      mkdir -p "$hooks_dir"
      install -m 0755 "$src_dispatch" "${hooks_dir}/policy-dispatch.sh"
      install -m 0644 "$src_policies" "${hooks_dir}/policies.json"
      dst="${hooks_dir}/policy-dispatch.sh"
      echo "✓ installed ${dst} (+ policies.json beside it)"
      
      mkdir -p "$(dirname "$settings")"
      [[ -f "$settings" ]] || echo '{}' > "$settings"
      
      if [[ -s "$settings" ]]; then
        backup="$(mktemp "${settings}.bak.$(date +%Y%m%d%H%M%S).XXXXXX")"
        cp -p "$settings" "$backup"
        echo "✓ backed up settings → ${backup}"
      fi
      
      tmp="$(mktemp)"
      trap 'rm -f "$tmp"' EXIT
      jq --arg cmd "$dst" '
        .hooks //= {} |
        .hooks.PreToolUse //= [] |
        reduce ("Bash", "Edit|Write") as $m (.;
          if any(.hooks.PreToolUse[]?; .matcher == $m and any((.hooks // [])[]?; .command == $cmd))
          then .
          else .hooks.PreToolUse += [{
            "matcher": $m,
            "hooks": [ { "type": "command", "command": $cmd } ]
          }]
          end
        )
      ' "$settings" > "$tmp" && mv "$tmp" "$settings"
      trap - EXIT
      
      if grep -qF "$dst" "$settings"; then
        echo "✓ wired PreToolUse (Bash, Edit|Write) policy dispatcher into ${settings}"
      else
        echo "ERROR: failed to wire dispatcher into ${settings}" >&2
        exit 1
      fi
      
      echo ""
      echo "Policy dispatcher active for this Claude scope. SILENT on every clean call;"
      echo "deny policies block with a one-line route to the correct tool; fires land one"
      echo "hashed telemetry line in \${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl."
      echo "Waive once:  AOP_WAIVE=<policy-id> <your command>"
      echo "Uninstall:   remove the two PreToolUse matchers for ${dst} from ${settings}, then rm -rf ${hooks_dir}"
      
    • lint-policies.sh 4 KB
      #!/usr/bin/env bash
      # lint-policies.sh — mechanical enforcement of the hooks-manifest.v2 contract
      # (age-bhsz). jq + grep only (no jsonschema dependency), so the discipline is
      # checkable in bats, pre-commit, and CI alike.
      #
      # Checks, in order:
      #   1. registry parses as JSON and declares schema hooks-manifest.v2
      #   2. every policy has id / predicate_class / mode / matchers / route_message /
      #      rationale / value_proof
      #   3. id matches domain.object:token and is unique
      #   4. mode is deny|route|audit; predicate_class is pure|lookup|stateful
      #   5. PREDICATE DISCIPLINE: predicate_class != pure  =>  mode == audit
      #      (the #511 anti-lesson — stateful guards are barred from blocking
      #      until promoted from audit with reviewed fires)
      #   6. matcher tools are Bash|Edit|Write; field is command|file_path
      #   7. every pattern compiles under grep -E on this host
      #
      # Usage: lint-policies.sh [registry.json]   (default: ../policies/policies.json)
      set -uo pipefail
      
      # shellcheck disable=SC1007  # CDPATH= scopes an empty CDPATH to the cd, intentionally
      script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      registry="${1:-${script_dir}/../policies/policies.json}"
      
      fail() { printf 'lint-policies: FAIL: %s\n' "$1" >&2; exit 1; }
      
      command -v jq >/dev/null 2>&1 || fail "jq is required"
      [ -f "$registry" ] || fail "registry not found: ${registry}"
      
      jq empty "$registry" 2>/dev/null || fail "not valid JSON: ${registry}"
      
      schema="$(jq -r '.schema // ""' "$registry")"
      [ "$schema" = "hooks-manifest.v2" ] || fail "schema must be hooks-manifest.v2, got: '${schema}'"
      
      count="$(jq '.policies | length' "$registry")"
      [ "$count" -ge 1 ] || fail "policies array is empty"
      
      # Required fields present and non-empty on every policy.
      missing="$(jq -r '
        .policies[]
        | . as $p
        | ["id","predicate_class","mode","matchers","route_message","rationale","value_proof"][]
        | select(($p[.] // "") == "" or ($p[.] == null))
        | ($p.id // "<no-id>") + " missing " + .
      ' "$registry")"
      [ -z "$missing" ] || fail "$missing"
      
      # id format + uniqueness.
      bad_id="$(jq -r '.policies[].id | select(test("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$") | not)' "$registry")"
      [ -z "$bad_id" ] || fail "id not domain.object:token: ${bad_id}"
      dup_id="$(jq -r '[.policies[].id] | group_by(.) | map(select(length > 1) | .[0]) | .[]' "$registry")"
      [ -z "$dup_id" ] || fail "duplicate policy id: ${dup_id}"
      
      # Enums.
      bad_mode="$(jq -r '.policies[] | select(.mode | IN("deny","route","audit") | not) | .id' "$registry")"
      [ -z "$bad_mode" ] || fail "invalid mode on: ${bad_mode}"
      bad_class="$(jq -r '.policies[] | select(.predicate_class | IN("pure","lookup","stateful") | not) | .id' "$registry")"
      [ -z "$bad_class" ] || fail "invalid predicate_class on: ${bad_class}"
      
      # THE DISCIPLINE RULE: non-pure predicates may only audit.
      undisciplined="$(jq -r '.policies[] | select(.predicate_class != "pure" and .mode != "audit") | .id' "$registry")"
      [ -z "$undisciplined" ] || fail "predicate discipline violation (non-pure predicate in blocking mode): ${undisciplined}"
      
      # Matcher shape.
      bad_tool="$(jq -r '.policies[] | .id as $id | .matchers[].tools[] | select(IN("Bash","Edit","Write") | not) | $id + " tool " + .' "$registry")"
      [ -z "$bad_tool" ] || fail "invalid matcher tool: ${bad_tool}"
      bad_field="$(jq -r '.policies[] | .id as $id | .matchers[] | select(.field | IN("command","file_path") | not) | $id' "$registry")"
      [ -z "$bad_field" ] || fail "invalid matcher field on: ${bad_field}"
      
      # Every pattern must compile under grep -E on this host.
      # join(), not @tsv: TSV escaping mangles backslashes inside patterns.
      while IFS=$'\x1f' read -r pid pattern; do
        [ -n "$pid" ] || continue
        if ! printf '' | grep -qE "$pattern" 2>/dev/null; then
          # grep exits 1 on no-match with a VALID pattern; only exit >1 is a compile error.
          rc=$?
          [ "$rc" -le 1 ] || fail "pattern does not compile (grep -E rc=${rc}) on ${pid}: ${pattern}"
        fi
      done < <(jq -r '.policies[] | .id as $id | .matchers[] | [$id, .pattern] | join("")' "$registry")
      
      printf 'lint-policies: OK (%s policies)\n' "$count"
      
  • .agentops-generated.json 265 B
    {
      "generator": "codex-sync",
      "source_skill": "skills/cc-hooks",
      "layout": "modular",
      "source_hash": "7f88206dba2dd7297b9c867b11a951993425fde34de48dbce38e058b5b94e79c",
      "generated_hash": "4e07db6637d618887e47f0e22026e79be85022e29e1766e3699b167b34cecd3f"
    }
    
  • prompt.md 363 B
    # cc-hooks
    
    Configure Claude Code hooks and narrow enforcement guards. Use when: the caller requests hook installation, repair or policy changes; a hook is not required to use other skills.
    
    ## Instructions
    
    Load and follow the skill instructions from the sibling `SKILL.md` file for this skill.
    Then read local files in `references/` and `scripts/` when needed.
    
  • SKILL.md 16.9 KB
    ---
    name: cc-hooks
    description: 'Configure Claude Code hooks and narrow enforcement guards. Use when: the caller requests hook installation, repair or policy changes; a hook is not required to use other skills.'
    ---
    # Claude Code Hooks
    
    Shell commands that fire at specific points in Claude Code's lifecycle.
    
    Hooks enforce mechanically what prose cannot: a model can reason its way past
    an instruction, but it cannot reason its way past an exit 2 — which is exactly
    why every hook must be narrow, silent, and reversible.
    
    Named failure mode — **chatty happy path**: a hook that emits stdout on exit 0
    corrupts the tool call it was guarding; silence on success is part of the
    contract, not a style preference.
    
    ## Prompt
    
    ```text
    Add a PreToolUse hook to fleet-router/.claude/settings.json that blocks `git push --force` on the main branch. Keep it silent on exit 0, exit 2 with a message on block, and confirm it fires with a manual test invocation before committing the change.
    ```
    
    ## It's working if
    
    - The hook script exits `2` with a stderr message when it blocks `git push --force`, and exit `0` with no stdout on the allowed path.
    - `.claude/settings.json` gains one matcher entry for the new hook, alongside the existing hooks list rather than replacing it.
    - A manual test invocation against the new matcher shows the block firing in the transcript, with exit `2` visible, before the change gets committed.
    - The hook inspects only the `PreToolUse` call it guards, keeping every other file untouched.
    
    ## Constraints
    
    - Enforcement hooks (the PreToolUse policy dispatcher) ship by DEFAULT: plugin installs auto-wire `hooks/hooks.json`; skill copies and checkouts wire with one command (`scripts/install-hooks.sh`). Operators can disable per host (`/plugin disable`, or remove the settings matchers).
    - Injection hooks (SessionStart/UserPromptSubmit context stuffing) stay dead — the #511 teardown proved delta=0 at 10.35M resident tokens. Never ship one; the hookless-cold-start gate still enforces this.
    - Keep the happy path silent and block only with the event's documented exit/JSON contract because stray stdout can corrupt a tool call.
    - Bound Stop hooks with `stop_hook_active` and scope matchers narrowly to prevent recursion and unrelated-command interception.
    
    <!-- TOC: Quick Start | Events | Blocking | Writing Hooks | Anti-Patterns | References -->
    
    ## Quick Start
    
    Add to `~/.claude/settings.json` (user) or `.claude/settings.json` (project):
    
    ```json
    {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-validator.sh"}]}]}}
    ```
    
    ## Hook Events
    
    | Event | When | Blocks? | Common Use |
    |-------|------|---------|------------|
    | `PreToolUse` | Before tool runs | Yes | Block/modify commands |
    | `PostToolUse` | After tool succeeds | Feedback | Auto-format, lint |
    | `PermissionRequest` | Permission dialog | Yes | Auto-approve/deny |
    | `UserPromptSubmit` | Prompt submitted | Yes | Add context, validate |
    | `Stop` | Claude finishes | Yes | Force continue |
    | `SessionStart` | Session begins | No | Load context, set env |
    | `Notification` | Notifications | No | Desktop alerts |
    
    Full schemas: [HOOK-EVENTS.md](references/HOOK-EVENTS.md)
    
    ## Matchers
    
    ```
    "Bash"              → exact match
    "Edit|Write"        → regex OR
    "mcp__.*__write"    → MCP tools
    "*" or ""           → all tools
    ```
    
    Tools: `Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Task`, `WebFetch`, `WebSearch`
    
    ## Exit Codes
    
    | Code | Effect |
    |------|--------|
    | 0 | Success - JSON parsed from stdout |
    | 2 | **Block** - stderr fed to Claude |
    | Other | Non-blocking error |
    
    ## Blocking a Tool
    
    **Simple (exit 2):**
    ```bash
    echo "Blocked: reason" >&2 && exit 2
    ```
    
    **JSON (exit 0):**
    ```json
    {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}}
    ```
    
    Decisions: `"allow"` (auto-approve), `"deny"` (block), `"ask"` (show dialog)
    
    ## Modifying Input
    
    ```json
    {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
      "updatedInput":{"command":"modified-command"}}}
    ```
    
    ## Real-World: DCG + RCH
    
    ```json
    {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
      {"type":"command","command":"dcg"},
      {"type":"command","command":"rch"}
    ]}]}}
    ```
    
    - **DCG**: Blocks `git reset --hard`, `rm -rf`, `git push --force`
    - **RCH**: Routes builds to remote workers
    
    Details: [DCG-RCH.md](references/DCG-RCH.md)
    
    ## Skill-First Coordination Guard (opt-in)
    
    A copy-paste PreToolUse recipe that nudges agents to **load the coordination
    skill before hand-rolling the `am`/`atm`/`ntm`/`tmux send-keys` CLI**. This
    recipe auto-installs nothing; you opt in per host (unlike the policy
    dispatcher, which ships by default).
    
    **Context-budget doctrine for hooks:** hooks are the most powerful enforcement
    (mechanical, can't be reasoned past) but they pollute context — use sparingly. A
    hook must be SILENT on the happy path (exit 0, no stdout/stderr), fire ONLY on a
    real violation (ideally once per session, sentinel-gated), prefer PreToolUse
    violation-guards over `UserPromptSubmit`/`SessionStart` per-turn injectors, and
    NEVER emit stray stdout on an exit-0 PreToolUse path (it is parsed as JSON and
    breaks the tool call). Block via exit 2 + stderr.
    
    The recipe ships both scripts verbatim, a precise head-only matcher (so a
    `br create --body "...am/atm/ntm..."` never false-fires), the two-matcher
    opt-in `settings.json` snippet, and a bats test proving every fire/silent case.
    
    Recipe: [SKILL-FIRST-COORDINATION-GUARD.md](references/SKILL-FIRST-COORDINATION-GUARD.md)
    
    ## Installed-Skill-Edit Guard (opt-in)
    
    A PreToolUse `Edit|Write` guard that routes an edit of an **installed skill copy**
    (`*/.claude/skills/**`, `.codex`, `.gemini`) back to the repo source of truth
    `skills/<name>/`. This is a TRUE mistake-token — editing an installed/symlinked
    copy has no legitimate form (overwritten on install, or symlinks through to the
    factory checkout). Zero false-positive surface: it matches `tool_input.file_path`
    only, so a doc that merely mentions `claude/skills` in its body never fires.
    Reversible → it ROUTES (exit 2 + one-line redirect), not hard-blocks. Silent on
    every other path; fires once per session. Ships INERT — opt-in installer:
    
    ```bash
    scripts/install-installed-skill-edit-guard.sh   # user scope; --project for project
    ```
    
    Recipe: [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md)
    
    ### Value-proof (why this guard survives the hookless teardown)
    
    The keystone guard ships **gate-blind per-fire telemetry**: on each fire it
    appends exactly one JSONL line — `{ts, session, token_class, path_sha256}` — to
    `${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl` (override with
    `AGENTOPS_GUARDRAIL_TELEMETRY`). The path is **SHA-256 hashed, never raw**
    (privacy); nothing is written on the happy path; the sensor is inert until the
    guard is installed and fires. The pre-registered methodology — metric =
    declining fire-ATTEMPT rate over time (a signal the redirect cannot fake, NOT the
    circular hand-roll rate), minimum N, noise floor, and **null-at-small-N is an
    acceptable outcome** — satisfies ADR-0002 l.58 ("test or eval evidence showing
    positive value"), the criterion whose absence killed 2.x hooks (#511).
    
    Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
    
    ## Read-Budget Guard (opt-in)
    
    A PreToolUse `Read|Bash` guard that DENIES an **unbounded read over the line
    budget** (`AOP_READ_BUDGET_LINES`, default 350): a `Read` with no `limit`, or a
    `cat`/`head`/`tail` whose effective line count exceeds it. The Spotify finding:
    the same rule in CLAUDE.md was advisory and ignored, and an over-budget read
    re-sends its lines on every later turn. The predicate is a LOOKUP (`wc -l` on
    the exact argument), so it is a standalone guard, never a registry policy. A
    `limit`-bounded slice, a file at/below budget, a pipe, a redirect or quoted text
    that merely mentions `cat` passes. Literal quoted/escaped paths are preserved;
    unsupported shell syntax and directory-changing chains fail open. Negative
    `head` counts use the actual number of retained lines for GNU head; rejected
    Darwin system utility flags pass after checking executable identity. Positive
    signed head counts are bounded by their numeric value. Nothing un-reads bytes
    once in context → every attempt blocks (exit 2 + stderr): full message once per session naming the two
    correct moves (slice it, or delegate to the plugin's `agentops:bulk-reader`
    subagent / `agentops:bulk-read` workflow), one short line after. Use bare names
    only for standalone definitions or links when the runtime lists them. Waive once with
    `AOP_WAIVE=core.context:unbounded-read`; hashed telemetry adds `tool`, `lines`,
    `budget` plus the dispatcher's `mode`/`decision` pair. Ships INERT — opt-in installer:
    
    ```bash
    scripts/install-read-budget-guard.sh   # user scope; --project for project
    ```
    
    Recipe: [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md)
    
    ## Policy Dispatch Engine (ships by default)
    
    The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
    [hooks/policy-dispatch.sh](hooks/policy-dispatch.sh) — evaluating a
    **policies-as-data** registry
    ([policies/policies.json](policies/policies.json), contract
    `schemas/hooks-manifest.v2.schema.json`) instead of N hand-wired settings
    entries. This is the membrane at tool-call altitude: same vocabulary, lower
    altitude than the pawl/gate at push time.
    
    Per policy: dcg-style id (`domain.object:token`), `mode: deny | route | audit`,
    matchers (tool + `command`/`file_path` regex), a `route_message` that names THE
    correct tool, a rationale, and a pre-registered `value_proof` (the ADR-0002
    lease-on-life: no proof accruing → retire the policy).
    
    **Predicate discipline, schema-enforced** (the #511 anti-lesson): only
    `predicate_class: pure` — syntactic mistake-tokens over the command or file
    path — may `deny`/`route`. Lookup/stateful predicates ship `audit`-only until
    promoted with reviewed fires.
    [scripts/lint-policies.sh](scripts/lint-policies.sh) enforces this mechanically
    (jq-only; runs in bats and CI).
    
    **Accepted false-positive surface:** because a pure predicate matches its token
    anywhere in the raw command string, a protected token quoted as *data* (a commit
    message body, a `dcg test "..."` probe, a here-doc payload) can still fire even
    though nothing harmful would run. This is the deliberate cost of the
    pure-only-may-deny rule — the alternative (repo/context lookups) is exactly the
    stateful predicate the discipline bars from `deny`. Every fire is reversible: a
    one-shot `AOP_WAIVE=<policy-id>` or a `policy-waivers` line clears it.
    
    Semantics: happy path = exit 0, zero output. `deny` = exit 2 + one stderr
    route line (full message once per session, short line after — every attempt
    still blocks). `route` = exit 0 + `permissionDecision:"ask"` JSON. `audit` =
    allow + record. Every fire appends one hashed guardrail-telemetry line
    (`token_class` = policy id, plus `mode`/`decision`). Waive once with
    `AOP_WAIVE=<policy-id>`, or a `policy-waivers` file line
    `<policy-id> <expiry-epoch>`. Missing registry or jq fails OPEN.
    
    Enforce cohort (all pure-regex, high-pain). The first four are the day-1
    maintainer cohort (age-wnyt) — they guard *this repository's* artifacts. The
    fifth guards the **product's own invariant** and therefore fires on every
    consumer repo, not just this one:
    
    | Policy | Blocks | Routes to |
    |---|---|---|
    | `core.git:add-beads-ledger` | `git add` naming `_beads/` (private ledger leak is one-way) | push the ledger repo itself — never `git add _beads` in the public tree |
    | `core.provenance:ledger-hand-append` | redirect/`tee`/Edit/Write onto `docs/provenance/ledger.jsonl` (hash-chained, sealed) | `ao provenance add` |
    | `core.skills:copy-into-installed` | `cp`/`rsync`/`mv` INTO `~/.claude|.codex|.gemini/skills` (dest-position enforced) | `ao skills link` |
    | `core.skills:edit-installed-copy` | Edit/Write of an installed skill copy (`file_path` only — prose can never fire it) | edit repo `skills/<name>/` |
    | `core.verdicts:hand-edit` | Edit/Write, or Bash `>`/`>>`/`tee`/`cp`/`rsync`/`mv` INTO `.agents/ao/verdicts/` (dest-position enforced) — the filename IS the SHA-256 of the content, so a hand edit breaks digest identity | re-run validation and let it persist a fresh artifact (`validate.py store-verdict`) |
    
    `core.verdicts:hand-edit` is the one policy whose subject is the *promise*
    rather than the repo: a verdict that no longer hashes to its own filename is
    forged evidence, and nothing above the tool-call altitude catches it. Reading
    the store is untouched — `cat`/`ls`/`jq`/`rg`/`diff` over a verdict, and
    copying one OUT for inspection, never fire; only writes landing IN the store
    do — including in-place editors (`sed -i`, `perl -pi`/`-ni`) and deleters
    (`rm`, `unlink`, `shred`), matched as flag-tokens so a read whose script text
    merely contains `-i` stays silent (bats-proven both directions). Remaining
    disclosed gap: the noclobber override redirect (`>|`).
    
    **How it reaches users — every install path delivers hooks:**
    
    | Install path | Delivery |
    |---|---|
    | Claude Code plugin (`claude plugin install agentops@agentops-marketplace`) | **Automatic** — the plugin bundles `hooks/hooks.json` (`${CLAUDE_PLUGIN_ROOT}` paths); hooks are active on install, no wiring step |
    | `npx skills@latest add boshu2/agentops` / skills.sh copy | The skill package carries its own installer: `~/.claude/skills/cc-hooks/scripts/install-hooks.sh` (one command; file copies cannot self-wire) |
    | git clone / brew checkout | `scripts/install-policy-dispatch.sh` (delegates to the same skill-embedded installer) |
    
    The installer lints the registry before wiring, backs up settings, and is
    idempotent. Disable per host with `/plugin disable agentops` or by removing the
    two PreToolUse matchers from settings.
    
    Contract tests: `tests/scripts/policy-dispatch.bats` (block+message+telemetry
    per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).
    
    ## Writing Your Own Hook
    
    **Minimal Python:**
    ```python
    #!/usr/bin/env python3
    import json, sys
    
    data = json.load(sys.stdin)
    cmd = data.get('tool_input', {}).get('command', '')
    
    if 'dangerous' in cmd:
        print("Blocked: dangerous", file=sys.stderr)
        sys.exit(2)
    
    sys.exit(0)  # Allow
    ```
    
    **Hook input (stdin):**
    ```json
    {"tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"...","cwd":"..."}
    ```
    
    ## Environment Variables
    
    | Variable | Scope | Purpose |
    |----------|-------|---------|
    | `CLAUDE_PROJECT_DIR` | All | Project root |
    | `CLAUDE_ENV_FILE` | SessionStart/Setup | Persist env vars |
    
    ## Stop Hook (Force Continue)
    
    ```json
    {"decision":"block","reason":"Tests failing. Fix before stopping."}
    ```
    
    **Critical:** Check `stop_hook_active` to prevent infinite loops.
    
    ## Anti-Patterns
    
    | Don't | Do |
    |-------|-----|
    | Old object format | Array format with `matcher` |
    | Unquoted `$VAR` | `"$VAR"` |
    | Exit 2 with JSON | Exit 2 uses stderr only |
    | Skip `stop_hook_active` check | Always check in Stop hooks |
    
    ## Debugging
    
    ```bash
    claude --debug  # Hook execution details
    /hooks          # View/edit in REPL
    ```
    
    ## Output Specification
    
    - **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in.
    - **Filename:** preserve `settings.json`; give scripts descriptive executable filenames rather than embedding large shell programs in JSON.
    - **Format:** valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
    - **Exit code:** validate with `jq -e '.hooks | type=="object"' <settings.json>` and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation.
    - **Downstream handoff:** consumed by the operator only after the exact scope, reversal command, test evidence, and opt-in location are reported.
    
    ## Quality Checklist
    
    - The matcher fires on the intended event/input and stays silent on representative near misses.
    - Blocking and allow paths use the documented exit code and output channel without leaking context.
    - The hook is reversible, narrowly scoped, recursion-safe, and clearly labeled as opt-in host policy.
    
    ## References
    
    - [HOOK-EVENTS.md](references/HOOK-EVENTS.md) - All events with full schemas
    - [DCG-RCH.md](references/DCG-RCH.md) - Production examples (dcg, rch)
    - [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
    - [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation
    - [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
    - [PATTERNS.md](references/PATTERNS.md) - Auto-format, logging, notifications
    - [JSON-OUTPUT.md](references/JSON-OUTPUT.md) - Response schemas
    
  • skill.spec.json 3.5 KB
    {
      "name": "cc-hooks",
      "skill_api_version": 1,
      "form": "A",
      "quality_score": 0.92,
      "sections": [
        { "id": "title",       "title": "Claude Code Hooks",     "type": "intro",       "priority": "required" },
        { "id": "quickstart",  "title": "Quick Start",           "type": "procedure",   "priority": "required" },
        { "id": "events",      "title": "Hook Events",           "type": "table",       "priority": "required" },
        { "id": "matchers",    "title": "Matchers",              "type": "overview",    "priority": "required" },
        { "id": "exitcodes",   "title": "Exit Codes",            "type": "table",       "priority": "required" },
        { "id": "blocking",    "title": "Blocking a Tool",       "type": "procedure",   "priority": "required" },
        { "id": "modifying",   "title": "Modifying Input",       "type": "procedure",   "priority": "standard" },
        { "id": "dcgrch",      "title": "Real-World: DCG + RCH",  "type": "examples",    "priority": "standard" },
        { "id": "writing",     "title": "Writing Your Own Hook", "type": "procedure",   "priority": "required" },
        { "id": "envvars",     "title": "Environment Variables", "type": "table",       "priority": "standard" },
        { "id": "stophook",    "title": "Stop Hook (Force Continue)", "type": "procedure", "priority": "standard" },
        { "id": "antipattern", "title": "Anti-Patterns",         "type": "constraints", "priority": "required" },
        { "id": "debugging",   "title": "Debugging",             "type": "procedure",   "priority": "standard" },
        { "id": "references",  "title": "References",            "type": "routing",     "priority": "required" }
      ],
      "references": [
        { "file": "references/HOOK-EVENTS.md", "topic": "all hook events with full input/output schemas" },
        { "file": "references/DCG-RCH.md",     "topic": "production examples (dcg, rch) wired as PreToolUse hooks" },
        { "file": "references/SKILL-FIRST-COORDINATION-GUARD.md", "topic": "opt-in skill-first coordination guard recipe + hook context-budget doctrine" },
        { "file": "references/READ-BUDGET-GUARD.md", "topic": "opt-in read-budget guard: blocks unbounded reads over the line budget and routes to slices or cheap-model bulk-read delegation" },
        { "file": "references/PATTERNS.md",    "topic": "auto-format, logging, notification hook patterns" },
        { "file": "references/JSON-OUTPUT.md", "topic": "hook response JSON schemas" }
      ],
      "metadata": {
        "tier": "execution",
        "stability": "stable",
        "dependencies": ["dcg", "rch"],
        "hexagonal_role": "supporting",
        "context_window": "inherit",
        "practices": ["pragmatic-programmer"],
        "triggers": [
          "cc-hooks",
          "Claude Code hooks",
          "PreToolUse",
          "PostToolUse",
          "Stop hook",
          "Notification hook",
          "block a command",
          "auto-format on edit",
          "custom permissions",
          "write a hook"
        ],
        "token_estimate": {
          "minimal": 60,
          "overview": 220,
          "standard": 650,
          "full": 1200
        }
      },
      "output_contract": "A hooks block in ~/.claude/settings.json or .claude/settings.json (matcher + command entries), or a hook script that reads tool JSON on stdin and signals allow/deny/ask via exit codes (0/2) or hookSpecificOutput JSON. Stop hooks must guard stop_hook_active against infinite loops.",
      "evidence": {
        "sources": [
          "skills/cc-hooks/SKILL.md",
          "skills/cc-hooks/references/HOOK-EVENTS.md",
          "skills/cc-hooks/references/DCG-RCH.md",
          "skills/cc-hooks/references/JSON-OUTPUT.md"
        ]
      }
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related