Skip to content
Komputa Practice
Esc
↑↓navigate↵open⌘Jpreview
On this page

Hooks reference

Every hook, from its own header comment.

This page is generated from the header comment of each script. Do not edit it. Change the header, and the next build changes this page.

Hooks

block-credential-read.sh

Source: hooks/block-credential-read.sh

Refuse a Read, or a Bash command that reads, a credential file, so the
secret never enters the context. Creating one stays allowed: a redirect
target (`> .env`) does not count. Example and template files pass.

Bash is judged per pipeline, not per command line. A command that names the
file without printing it (ls, stat, chmod) passes, and so do the forms that
print no values: a count or match test (grep -c/-l/-L/-q), key names only
(grep -o '^[A-Z_]*', cut -d= -f1, sed 's/=.*//', awk -F= '{print $1}'), or a
pipeline that ends in one of those or in wc. Key names apply to .env files
only. Anything else that reads it is refused.

block-dangerous-git.sh

Source: hooks/block-dangerous-git.sh

Matt Pocock's git-guardrails (github.com/mattpocock/skills,
skills/misc/git-guardrails-claude-code), without the plain `git push`
pattern: agents push their own branches.

A forced push is refused: the long force flag, -f in any short-flag group,
a +refspec, --mirror, --force-if-includes alone, and a lease with no SHA.
One form passes, the restack in pstack's merge-safety reference:
  git push --force-with-lease=refs/heads/<b>:<sha> <remote> HEAD:refs/heads/<b>
The lease names a branch and its exact SHA (7 to 40 hex), every refspec
pushes a leased branch, and no leased branch is main, master or the remote's
default. Only real git commands are judged: text in a quoted argument
of another program (an issue body, a message) is data. A quoted argument of
bash -c, sh -c, eval, ssh, xargs, su or watch is commands, so it is judged,
and so is the body of $(...) and `...`. A variable in the lease cannot be
checked, so it is refused. A git alias or a configured +refspec is not seen.

`git branch -D` passes when deleting loses nothing: every commit on each
branch it names is also reachable from another local branch, a
remote-tracking branch or a tag. That covers a branch merged into main
(local or remote), a pushed branch, and a throwaway branch tagged first.
When the first check fails, the hook fetches origin once (15 s cap) and
checks again, so a PR merged on the server since the last fetch passes.
`-d` cannot do this job when the checkout sits on another branch.
A redirect after the names (2>&1, >/dev/null) is not a name. Any branch with
commits found nowhere else, or an unknown or unparseable name, keeps the
block.

A remote branch delete (--delete, -d or a :branch refspec) follows the same
rule, after one fetch: nothing is lost, and it is never main, master or the
remote's default. --prune is refused, because it names no branch to judge.

`git reset --hard`, `git clean` with force, and `git checkout` or
`git restore` of a dot path (., .., ./x, ../x), :/ or * are refused.
`git checkout --ours .` and `--theirs .` pass: they resolve a conflict. So
does `-p`, which asks a person about each change.

Two layers judge these. A raw scan of the text refuses the plain spellings
wherever they appear, also in a variable-led command or a quoted message:
it fails safe, because it needs no parse. The word parse then catches what
the scan cannot see: reordered flags, abbreviations and `git -C`.

Heredoc bodies are data, not commands, so they are not scanned: a script
or file that mentions a pattern does not trip the hook. A heredoc whose
opening line names a shell (bash, sh, eval, ssh, ...) is still scanned.

This hook stops mistakes. It does not stop an agent that sets out to get past
it. Does not catch: code in another language (python3 -c, or a heredoc fed to
it); text piped to a shell (echo ... | bash); a command in a variable or a
script file; a git alias; a word built by quoting tricks ($'..', $".."); a path
in a variable; `git checkout` or `git restore` of a path with no leading dot; a forced
`git checkout -f` or `git switch -f`; `git stash drop`, `git update-ref -d` and
other commands not named above.

A payload that is not one JSON object, a Bash call with no command string, or
a missing jq or awk is refused. Any other failure inside the hook also refuses
(exit 2), because exit 1 would let the call run.

block-destructive-delete.sh

Source: hooks/block-destructive-delete.sh

A Claude Code (and Codex) PreToolUse gate for Bash. It refuses deletes that
cannot be undone. Under the working rules (rules/working-rules.md), an
action that cannot be undone is the lead's or the operator's decision, not a
worker's, so the operator must run it by hand. Only a deny reaches the agent
before the call runs, so the gate refuses (exit 2, one line on stderr); it
never only warns.

Blocks:
 - Forge and wiki deletes: `gh repo delete`, `glab repo delete`,
   `tea repo(s) delete|rm`, `docmost delete-space`; an API DELETE of a
   project, group, repo, org or space (`odev api`, `gh api`, `glab api`,
   curl, wget, http/https, xh) given by -X, --request or --method, or as
   httpie's method word; and any HTTP call to Docmost's `api/spaces/delete`.
 - A recursive rm (-r, -R or --recursive, with or without -f, in any flag
   order) whose target lies outside the current checkout and is a whole
   checkout (a sibling repo or worktree root, or a bare repo), a path
   directly under home, a folder that holds a checkout up to three levels
   down, or a path under the session tmp root (<tmp>/claude-<uid>) outside
   this session's own folder. `git rm -r` is judged the same way, from its
   `git -C` folder.
 - A recursive rm of a `.git` folder or anything under one, anywhere, the
   current checkout included: it holds the commits that were never pushed.
 The target is resolved first: `cd` and `pushd` earlier in the same
 command, `env -C`, `git -C`, `~`, `~user`, `$HOME`, `${HOME}`, `..`,
 symlinked parents (and the link itself when the path ends in `/`), {a,b}
 lists and globs.

Allows:
 - Other deletes strictly inside the current checkout: the git top level
   of the payload's cwd. There is none when cwd is in no checkout, or when
   the checkout is home or `/`.
 - Deletes inside this session's own folder,
   <tmp>/claude-<uid>/<transcript folder>/<session_id>/, built from the
   payload's transcript_path and session_id (<tmp> is $CLAUDE_CODE_TMPDIR,
   else /tmp). It holds the session's scratchpad.
 - A non-recursive rm; a recursive rm of any other path (a temp folder, a
   folder inside a sibling checkout, a deep folder under home); and a
   symlink named without a trailing `/` (rm removes the link only).
 - `/`, home itself, the working folder and its parents. Claude Code's own
   dangerous-rm check asks a person for those even in bypass mode, so this
   gate does not repeat it. That check does not run under Codex.
 - git branch and worktree removals: block-dangerous-git.sh judges those.
 - Text that only mentions a delete: heredoc bodies and quoted arguments
   are data. A quoted argument of bash/sh -c, eval, su or watch is
   commands, so it is judged, and so is the body of $(...) and `...`. An
   ssh command string is judged for forge deletes only: its paths are on
   another machine.

Does not catch: a target in a variable other than $HOME, in a command
substitution or in a {1..3} range (Claude Code's check prompts for an
unresolved rm target); text piped to a shell (echo ... | bash); targets read
by xargs or find -exec; find -delete,
rsync --delete, mv to /dev/null and other tools that delete; rm over ssh;
a delete inside a script file or an alias; an HTTP method override header;
a forge's web UI. A rm of a subfolder of a sibling checkout is allowed:
git can restore its tracked files.

A payload that is not one JSON object, or a Bash call with no command
string, is refused. Any other failure inside the gate also refuses (exit 2),
because exit 1 would let the call run.

block-operator-screen.sh

Source: hooks/block-operator-screen.sh

A Claude Code PreToolUse gate for Bash and the claude-in-chrome tools. It keeps a worker's browser off the
operator's screen (issue #69). The working rules say never to open a window on the operator's screen: a worker
runs a GUI program on its own hidden display. This gate acts only in a worker, an hc terminal started from a
template or by a parent, so the operator's own sessions are not touched.
For a worker it refuses:
 - a command that opens a URL or file in the operator's browser: xdg-open, gio open, sensible-browser,
   x-www-browser, gnome-open, open, python -m webbrowser;
 - a browser binary started without --headless, and Playwright with --headed or headless false;
 - every mcp__claude-in-chrome__ tool: it drives the operator's real Chrome and their sign-ins.
It cannot see inside a program: a dev server that opens a browser by itself (Strapi's autoOpen) needs its own
config, which the worker brief names. Set BROWSER=none for the worker too, which many dev servers honour.
Exit 2 refuses with one BLOCKED line. Any other failure refuses too, as in the other gates.

block-secret-write.sh

Source: hooks/block-secret-write.sh

Refuse a write that puts a credential into a file.
Enforces the CLAUDE.md rule: never put a secret in a file.

What it reads, by tool:
  Claude Code Write, Edit, MultiEdit: tool_input.content, .new_string and
    each .edits[].new_string.
  Codex apply_patch: the patch text in tool_input.command (or .input, or a
    bare string tool_input). Only the added lines ("+" lines) count: a
    removed line is a secret leaving the file, and a context line is already
    in it. Each line is judged alone, so two halves of a token on two lines
    are not a token.
A value of the wrong type is not text and is skipped. A payload that is not
one JSON value (malformed, empty, or two values) is refused: the gate cannot
tell what it writes.
No size limit: jq reads the whole payload and matches in one pass (a 20 MB
patch takes about 4 s).

block-test-weakening.py

Source: hooks/block-test-weakening.py

The judge behind hooks/block-test-weakening.sh. It reads a Claude Code PreToolUse payload on stdin.

It refuses (exit 2, one BLOCKED line on stderr) a call that deletes or weakens a test (issue #77).
Held:
  - Bash, delete: `rm`, `git rm`, `unlink`, `shred`, `find -delete`, `find -exec rm`; `mv` or `git mv` of a
    test path away (a rename to a new test path is not away). HERDR_TEST_PRUNE=1 lets these through.
  - Bash, overwrite or edit in place: `truncate`; a redirection that truncates an existing test file (`>`, `: >`,
    `cat /dev/null >`, `>|`, `&>`, `2>`; `>>` stays allowed); `cp`, `mv` or `git mv` onto an existing test file;
    `tee` without `-a`; `sed -i`; `perl -i`; `git checkout -- <test>`; `git restore <test>` (`--staged` alone is
    fine). HERDR_TEST_PRUNE=1 does not let these through, because the new content can hold a skip.
  - Write, Edit and MultiEdit on a test file: an empty result; a rise in skip or xfail markers or in commented
    asserts. A Write is compared with the file's current text, and a new file with a marker counts as a rise.
    HERDR_TEST_PRUNE=1 lets an empty result through, never a skip.
  - `$(...)`, a backtick, `bash -c` and `cd` are followed. The command text is never run.
NOT held, because text cannot decide them: `xargs rm`, `rm $VAR`, `rm $(echo path)`, `eval`,
`python -c "os.remove(...)"`, `rsync --delete`, `rm -rf .`, `rm -rf *`, `git clean`, a hard `git reset`,
`git checkout -- .` (these remove tests as a side effect); an assert changed to `assert True`, a deleted assert
line, a test file rewritten to one trivial test, `if False:` around a test; a skip set in a config file or
with `pytest.importorskip`; a new test file made by a redirection or `tee` (its content is not visible).
The reviewer template's break-the-behaviour check is the layer for those.
A test path is a path with a `tests` folder, or a file named `test_*`, `*_test.*`, `*.test.*` or `*.spec.*`
(any letter case), by its own name or by the real path behind a symlink. A Bash command may touch a path that
is under a `__pycache__`, `.pytest_cache`, `node_modules`, `build` or `dist` folder, a `*.pyc`, `*.log` or
`*.txt` file, or a path outside the git tree of the call.
A value of HERDR_TEST_PRUNE set inside the command text does not count: the gate reads its own environment,
which block-test-weakening.sh sets from the terminal's test-prune label.

block-test-weakening.sh

Source: hooks/block-test-weakening.sh

A Claude Code PreToolUse gate for Bash, Edit, MultiEdit and Write. It keeps a worker from deleting or weakening a
test to get green (issue #77; the rule is in rules/working-rules.md, "Never delete or weaken a test to get green").
It acts only in a worker, an hc terminal started from a template or by a parent, so the operator's own sessions
are not touched.
For a worker it refuses:
 - rm, git rm, unlink or find -delete of a test path (tests/, test_*, *_test.*, *.test.*, *.spec.*);
 - a Write or an Edit that leaves a test file empty;
 - an Edit that adds a skip or an xfail to a test file, or comments out an assert.
The label test-prune=1 on the worker's terminal (hc run --label test-prune=1) lets a deletion through. The lead
sets it for a worker that prunes tests in a reviewed PR
(rules/working-rules.md, "Never delete or weaken a test to get green"). The judge is block-test-weakening.py beside this file.
Exit 2 refuses with one BLOCKED line. Any other failure refuses too, as in the other gates.

check-live.sh

Source: hooks/check-live.sh

Compares each hook that ~/.claude/settings.json registers with its copy in
this folder. The live hooks are plain copies, so nothing else keeps them in
step, and checks each one blocks when it cannot start. Exit 1 on either.

Was this page helpful?