> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks

> What each agent hook denies or reminds, how settings wire them, and how they are tested.

Hooks are scripts the agent harness runs around tool calls. They are the one part of agent governance that cannot be forgotten, because the harness executes them, not the model. They live in `.agents/hooks/` and are wired in `.claude/settings.json`. Hooks have no dependencies beyond Node.

A hook either **denies** a tool call with a reason the agent sees, or **adds context** to the conversation. None of them edits files.

## What runs today

| Hook | Event and matcher | Effect |
| - | - | - |
| `session-context.mjs` | `SessionStart` | Restates the invariants that must survive compaction, plus the branch and up to 15 lines of `git status` |
| `guard-bash.mjs` | `PreToolUse`, `Bash` | Reports the permission tier of a command (deny, ask, or allow) from `_rules.mjs`; a subagent's (`agent_id`) hook ask is a deny |
| `guard-paths.mjs` | `PreToolUse`, `Write\|Edit\|MultiEdit\|NotebookEdit` | Refuses writes to generated, vendored or secret paths |
| `guard-paths.mjs` | `PreToolUse`, `Read\|Grep\|Glob` | Refuses the secrets inbox and secret files |
| `post-write-checks.mjs` | `PostToolUse`, `Write\|Edit\|MultiEdit` | Reports (never fixes, never blocks) a written file in the repository that oxfmt would reformat |

`_io.mjs` holds the shared stdin and output helpers, `_inbox.mjs` the shell scanner and the inbox matcher, and
`_rules.mjs` the permission tiers that `guard-bash`, `guard-paths` and the OpenCode guard all evaluate
([0293](/adr/0293-agent-permission-tiers-and-executor-pushes.md)). `_reviewer-agents.mjs` is generated by
`pnpm agents:sync`. None of them is a hook.

### `session-context`

Runs on session start, resume, compaction and clear. It prints the invariants: package location and naming, the tier tag and presets every member declares, versions only from catalogs, tests beside the code, docs in `apps/docs`, the secrets rule, WIP commits instead of the shared stash, and what done means. The point is that compaction cannot silently drop the laws. If an invariant changes on a docs page, change it here in the same commit.

### `guard-bash`

The rules live in `.agents/hooks/_rules.mjs` and follow the tiers of [0293](/adr/0293-agent-permission-tiers-and-executor-pushes.md).
Settings deny and ask rules in `.claude/settings.json` are the floor in every mode; the hook adds what a prefix rule
cannot see. Every rule runs on the command, its runner-free form and its dequoted form, so `pnpm gh`,
`pnpm exec infisical run`, `npx …`, `mise exec -- …`, `timeout 5 gh`, `then gh` and `"gh"` are judged like the
command they wrap. Prose is not a command: quoted text, heredoc bodies and `-m` (`-am` too) / `--title` / `--body`
/ `--description` / `linear comment` values are blanked, while `$(…)`, backticks, `sh -c` and `eval` bodies are
checked. Arguments are read as the shell passes them: quotes removed, redirections (`2>&1`, `>log`, `<<<x`) and a
closing `)` or `}` are not operands, and a quoted argument such as `"$HOME/.npmrc"` is still a path. `cd` is followed in order: a subshell's
`cd` ends at its `)`, and after a `cd` that may fail (`cd x; …`, `cd x || …`) both directories count. Every word
named `rm` or `find` is read as a command, so a wrapper the guard does not know (`setsid`, `flock f`, `op run --`)
never hides a recursive delete. A command line passed as one quoted word or here-string (`mise exec -c '…'`,
`tmux new -d '…'`, `watch '…'`, `ssh h '…'`, `trap '…' EXIT`, `at now <<< '…'`) is read as commands too:
`mise exec -c` by every rule, and any such word that holds a guarded command name (`rm`, `find`, `git`, `infisical`,
`encore`, `wrangler`, `changeset`, `drizzle-kit`, `mikro-orm`, `security`, a SQL client) by the delete, discard,
push, stash, secret, deploy, release and drop rules, a line quoted inside it too (`ssh h "tmux new -d '…'"`); the
file names in it count for the secret-file rule, and a program (`jq '.data.key'`, `node -e '…'`, `awk '…'`, a `jq`
option value), a search pattern (`grep`, `rg`, `git grep`) and a `vitest -t` name name no file. A double-quoted
line loses its `\"`, `\\`, `\$` and escaped backticks as the shell drops them (`sh -c "sh -c \"rm -rf ~\""`). That
reading stops past eight quoting levels, single or double, without denying. A `$(…)` or `<(…)` body is read as its own
command, never as nesting. Once a SQL client runs (`psql`, `mysql`, `sqlite3`, `encore db shell`, behind
`docker exec` too), its quoted, piped and heredoc text is read for `DROP DATABASE` / `SCHEMA`. A decision is bounded: a command nesting substitutions more
than 16 deep or script strings more than 8 deep, needing more than 48 git lookups, or taking more than two seconds,
is denied, because the hook fails open when its five seconds run out or it throws; a command that breaks the
reader is denied for the same reason. A git lookup in a directory that does not exist is skipped, and a path
longer than 4096 characters is denied unread.

Text echoed or printed into a shell inside a quoted line (`ssh h 'echo "rm -rf ~" | sh'`) is read as the commands
it runs. A search's pattern is found the way the tool parses it: `-e` / `--regexp` values attached or not (`-ex`,
`-e=x`, `-nex`), a pattern file from `-f` / `--file` (clustered too: `-nf .env`, which still names the file), and
the values of valued options such as `--include '*.ts'` skipped; a `git log|show|diff` `-S`, `-G` or `--grep` value
and a pattern inside `$(…)` or backticks are no file names either.

**A subagent's hook ask is a deny** (STA-351). The owner does not watch a subagent's prompts, so when the hook
input carries an `agent_id` (a subagent; the main thread never has one, a `claude --agent x` session included), an
ask from this hook is a deny carrying its reason and one line on how to rewrite the command so the guard can read
it: write a loop or probe directly instead of through `bash -c`, `sh -c` or `eval`, spell the command name out,
run commands instead of piping text into a shell. The asks in `HUMAN_ASKS` (`_rules.mjs`) still reach a human from
a subagent: `secrets:import … --confirm`, a `bot:git` push of tags, `encore db reset`,
`encore env set/create/delete`, `wrangler deploy` and `--remote`, and `eas`. The main thread keeps every hook ask.
The settings floor is separate: an ask rule in `.claude/settings.json` (`npm`, `pnpm` and `changeset publish`,
`git stash drop <ref>`, `git reflog expire|delete`, `docker compose down -v`, the MCP tools that spend money or
reach people) can still prompt from a subagent. The OpenCode guard applies the same `unattended` function to every
ask, human ones included, because nobody answers there.

| Tier | Commands |
| - | - |
| Deny | `infisical export`, `infisical secrets get` (every form) and `set`, a bare `infisical secrets`, `infisical run -- env\|printenv`, `infisical user get token`, `login --plain` |
| Deny | `env`, `printenv`, `export -p`, `declare -x`, bare `set`, `ps -E`, `ps eww`, `security find-generic-password` and other keychain reads, `$…_TOKEN` / `$…_API_KEY` |
| Deny | Any command naming a secret file (`.env*`, `.dev.vars`, `*.pem`, `*.key`, `~/.git-credentials`, `~/.config/gh/`), quoted or by a glob's literal text (not `*.json`), the secrets inbox, `bot:token`, `git credential` |
| Deny | Plain `gh`, network `git`, pack plumbing; `bot:git` options that run a program (`--upload-pack`, `--receive-pack`, `--exec`, `clone -u`, `rebase -x`, `submodule foreach`) |
| Deny | A push to main; `--force` without a lease, `-f`, `--delete`, `--mirror`, `--prune`, `+refspec`, `:branch`; a `bot:git` push of anything but `HEAD` or the current branch |
| Deny | `reset --hard`, `checkout`/`restore .`, `checkout -f`, `switch --discard-changes`, `clean -f` and `worktree remove --force` in the main checkout or another agent's worktree |
| Deny | `rm -rf` outside the session's own checkout and its own scratchpad directory (from `session_id`; not the `/tmp/claude-<uid>` root), of a worktree itself or of `.git`; a `find … -delete` from a checkout root that may select a file in `.git`; `git stash pop`, `clear`, `branch`, and `apply`/`drop` without a ref |
| Deny | `bot:review` from an agent type without `reviewer: true`, any merge or ref move outside `pnpm pr:merge`, the owner's label (any spelling, or a computed label value) or approving review, an `AI-REVIEW:` body via `gh` |
| Deny | `encore deploy`, `encore secret set/get`, `wrangler secret put/bulk`, `git tag <package>@<version>`, `DROP DATABASE/SCHEMA`, ORM push or migrate, `changeset version` |
| Ask | Raw `infisical run`, `infisical secrets delete`, `secrets:import … --confirm`, `encore db reset`, `encore env set/create/delete`, `wrangler deploy` and `--remote`, `eas` |
| Ask | `npx`, `pnpx`, `pnpm dlx`, `npm exec`, a `bot:git` push of tags, and a shell that runs text the guard cannot read (`curl … \| sh`) or a computed command name (`$x …`) |
| Ask | A `bot:gh` write (`api -X`/`-f`/`--input`, `repo edit`, `release`, `variable`, `secret`), a GraphQL label mutation, `git branch -D` of a branch no remote holds |
| Ask | `git config <key> <value>` without `--worktree`: the repository's config, which every linked worktree shares; `git config --worktree` of an allowed key is allowed |
| Ask | From the main checkout, discarding git or `rm -rf` in a nested linked worktree (`cd` or `-C`); build output there (`node_modules`, `dist`, `.turbo`) is allowed |

Known misses, by design: plain `gh`, network `git` and `bot:token` in a quoted line (`watch 'gh pr merge 1'`,
`watch 'git fetch'`, `watch 'pnpm bot:token'`; a push there still meets the push rules), code in another language
passed inline (`node -e`, `python -c`, `ruby -e`, `perl -e`), SQL fed to a package script that wraps a SQL client
(`pnpm <script> <<< 'DROP …'`), a glob that spells a secret name with wildcards (`.e?v`), a bare secret file name
in a wrapper read only two quoting levels deep (`cat .env` wrapped three times in `watch '…'` is allowed; a line
that also holds a guarded command name is read eight deep), a secret path built by a substitution
(`cat "$(pwd)/.env"`), and quoting nested more than eight levels deep. One known false deny: a heredoc written to a
file is read as a script when a body line starts with `/*`. The guard is an accident guard; GitHub's rulesets
and the bot's permissions are the boundary.

### `guard-paths`

Refuses reads of the secrets inbox and of secret files (`.env*` except `.env.example`, `.dev.vars`, `*.pem`, `*.key`,
`.secrets.local.*`), and writes to paths a tool other than the agent owns: `.env*` and secret-named files, `pnpm-lock.yaml`, `CHANGELOG.md` (Changesets owns it), `dist/`, Encore's generated `encore.gen/` and `.encore/` (the `~encore/*` clients), `apps/mobile/{ios,android}` (Expo prebuild), `uniwind-types.d.ts`, the `.claude/agents` and `.opencode/agents` projections, `_reviewer-agents.mjs`, and the generated rules block in `AGENTS.md`. Edit the source instead and re-run the generator.

### `post-write-checks`

Reports a written TS/JS/JSON/MD/CSS/YAML file that oxfmt would reformat. Best-effort and never blocking: the write already happened.

## Wiring

`.claude/settings.json` registers each hook by command with a five second timeout (ten for the formatter):

```json theme={"system"}
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/session-context.mjs\"",
            "timeout": 5
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/guard-bash.mjs\"",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Write|Edit|MultiEdit|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/guard-paths.mjs\"",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/.agents/hooks/post-write-checks.mjs\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

The path is `.agents/hooks/`, the one source. A hook is never copied into `.claude/`. The settings file is currently wired by hand, and the one canonical `.agents/` source projected to tool folders (see the [adoption plan](/adr/0016-agents-source-of-truth.md)) is what will keep it in step.

### `prompt-hints`

`UserPromptSubmit`: one-line reminders keyed on what the prompt is about. Cheap, never blocks. Registered in `.claude/settings.json` beside the hooks above.

## Testing a hook

A hook that no test exercises is a comment with a runtime cost. Every hook has a test in `.agents/hooks/__tests__/` (`pnpm test:hooks`, part of `pnpm validate`). The pattern to follow:

1. Pipe a fixture into the script as the harness would: `{"tool_input":{"command":"git reset --hard"}}` on stdin.
2. Assert the output is a `deny` decision for every rule, and empty for a near miss. Include the near misses: `git reset --soft`, `rm -r` without `-f`, a heredoc that mentions a forbidden command.
3. Run the test inside `pnpm verify`, so a regex edit that loosens a rule fails the gate.

Add a case in the same change as every new rule. `guard-differential.test.mjs` is the regression net for
`_rules.mjs`: a generated matrix of every wrapper the guard knows (`sudo`, `env`, `pnpm exec`, `mise exec --`, `npx`,
subshells, `$(…)`, `<(…)`, `sh -c`, `watch '…'`, `ssh h '…'`, `trap '…'`, and more) against every guarded command,
each with its expected tier, more than 150 ordinary agent commands that must stay allowed, and a 250 ms bound on the
guard's own time per decision (the time git takes to answer is not counted).

## Hooks versus permissions

A hook denies by pattern, which is easy to get subtly wrong. Treat it as a seatbelt, not a boundary. The boundary is the human owning secrets and merges, the gitleaks scan in `lefthook.yml`, and `pnpm verify` in CI. Never write a hook that turns a denial into a prompt for the agent to retry with different quoting.

Related: [Secrets](/constitution/secrets.md), [Concurrent sessions](/agents/concurrent-sessions.md), [Rules that don't rot](/agents/rules-that-dont-rot.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.