Hooks¶
The plugin provides PreToolUse and PostToolUse hooks that enforce workflow guardrails mechanically. These replace duplicated documentation rules across all consuming repos — rules that humans and agents alike routinely drift from when enforcement is prose-only.
Looking for the overall workflow? See
vergil-tooling→ Git Workflow for the big-picture guide covering branching, commit/PR/finalize cycle, and worktrees. This page is the reference for the plugin's hooks specifically; where each validation check runs is covered invergil-tooling→ Validation Matrix, and the per-repo hook guard invergil-tooling→ Hook Guard.
Each entry below covers what the hook catches, why it exists, and how to achieve the intent correctly when the hook blocks you.
Managed-repo gating¶
Every hook below except block-heredoc is gated on a
managed-repo check. A repo is "managed" when vergil.toml
exists at the repo root.
When the marker is not present, the gated hooks short-circuit to a
no-op so the plugin doesn't interfere with ad-hoc git work in
unrelated repositories. block-worktree-bypass-write has
exceptions that apply everywhere: it denies a relative target path;
without python3 it denies every Write and Edit; and it denies a
write whose path python3 cannot normalise. Detection is a pure-shell walk up from the
bash session's CWD looking for the marker, terminating at a
.git boundary or the filesystem root. No git subprocess; the
gate's overhead is a handful of stat() calls.
block-heredoc is intentionally not gated — heredoc syntax in
CLI invocations breaks unpredictably regardless of which repo
you're in, so the rule is universal.
See issue #87 for the rationale.
Command matching: quote-stripping¶
Every Bash-command matcher below (except block-heredoc) tests
tool names against a quote-stripped copy of the command string:
single- and double-quoted spans are replaced with "" first, then
the tool name must appear in command position
((^|[;&|({]\s*)<tool>(\s|$), applied per line).
This is why a commit body or issue body that mentions a blocked
command — even at the start of a line inside a multi-line --body
argument — does not trigger a false deny, while the same text in
command position still does.
block-heredoc matches raw text: its regex must see the quoted
delimiter in <<'EOF', which stripping would remove.
Canonical rule, accepted gaps, and the shared test vectors:
docs/specs/2026-06-05-450-command-matcher-quoting-design.md.
See issue #450.
Malformed input fails closed¶
Every PreToolUse hook below refuses a tool call it cannot check
rather than letting it through. That covers empty stdin, input that
is not valid JSON or is not a single JSON object, a missing jq,
and a payload without the hook's required field
(tool_input.command for the Bash hooks, tool_input.file_path for
block-worktree-bypass-write). The hook exits with code 2, the
PreToolUse blocking exit code, so Claude Code refuses the call and
shows the agent a stderr message naming the problem:
block-agent-merge.sh: blocked: the hook input is not valid JSON. This hook cannot check the tool call, so it fails closed and refuses it. This is a hook-protocol problem, not a problem with the command itself.
Behaviour on well-formed input is unchanged, including an empty
command string, which is allowed. Scope matches the
managed-repo gating: a gated hook still
allows everything outside a managed repo. Unparseable input names no
usable cwd, so the gate then checks the hook process's own working
directory. block-heredoc is not gated, so it refuses unparseable
input everywhere. guard-audit-writes refuses it everywhere for the
audit identity and still ignores other identities; its missing-path
case keeps its JSON deny. The shared reader is
hooks/scripts/lib/hook-input.sh; tests are in
hooks/tests/hook-input.test.sh. vrg-hook-guard and the per-repo
guard.sh shim follow the same rule
(vergil-tooling#3773).
See issue #776.
The PostToolUse hook detect-deprecation-warnings is advisory and is
not changed: a PostToolUse hook runs after the tool call, so it has
nothing to refuse.
PreToolUse Hooks — Bash¶
block-raw-git-commit¶
What. Denies Bash tool invocations that call git commit (or
pipe-chained equivalents).
Why. vrg-commit constructs standards-compliant conventional
commit messages. Hand-written git commit -m
invocations drift from the commit-message standard over time.
Commits carry no AI co-author trailer; Claude Code attribution is
turned off in .claude/settings.json
(vergil-project/vergil-tooling#3200).
Alternative. Use
vrg-commit
with the appropriate --type, --scope, --message, and --body
flags.
block-raw-gh-pr-create¶
What. Denies Bash tool invocations that call gh pr create.
Why. vrg-submit-pr builds standards-compliant PR bodies with
proper issue linkage keywords (Fixes, Closes, Resolves, Ref)
that the pr-issue-linkage CI validator requires. Manual gh pr
create commands routinely ship without linkage and fail CI on the
first try.
Alternative. Use
vrg-submit-pr
with --issue, --summary, --linkage, and --notes flags.
block-protected-branch-work¶
What. Denies Bash tool invocations that run raw git commit when
the effective working directory falls outside the worktree
convention's allowed locations. It does not match vrg-commit.
Why. Behavior depends on whether the target repo has adopted the
worktree convention.
The hook detects adoption by looking for a .worktrees/ line in the
repo's .gitignore. On adopted repos, commits must originate from
inside .worktrees/<name>/; the main tree is read-only for source
(ignored scratch in its build/ is not a source edit). On
non-adopted repos, the hook falls back to the legacy behavior of
blocking commits on main or develop regardless of directory.
Alternative. On adopted repos: create a worktree with
git worktree add .worktrees/issue-N-<slug> -b feature/N-<slug>
origin/develop and run all edits + commits from inside that
directory. On non-adopted repos: create and check out a feature
branch with a name matching the repo's branching_model prefixes.
This hook complements vrg-commit's own
protected-branch check
— that check runs inside vrg-commit against the branch of the
directory it actually executes in, so it is the authoritative guard for
vrg-commit. This hook only guesses the target repo from the session
cwd and the command text, and under the sandbox that cwd is reset to
the project root, so matching vrg-commit here produced false denies
for worktree commits in sibling repos (#694). The hook therefore covers
raw git commit only.
block-heredoc¶
What. Denies Bash tool invocations that contain heredoc syntax
(<<EOF, <<'EOF', etc.) in CLI arguments.
Why. Heredocs routinely produce incorrect escaping when passed
through multiple shell layers (agent → Bash tool → subprocess →
CLI). Several CLIs (gh, git commit, curl) silently succeed
with malformed content, producing invalid commit messages, broken
PR bodies, or corrupt JSON bodies. Writing to a file and passing it
via --body-file / --file / $(cat <path>) avoids the entire
class of bug.
Alternative. Write the multi-line content to a temp file and
reference it: --body-file /tmp/body.txt or
--body "$(cat /tmp/body.txt)".
block-associative-arrays¶
What. Denies Bash tool invocations that use bash 4+ associative
arrays (declare -A, typeset -A).
Why. The hook scripts and vrg-container-run dispatcher themselves
run on the host shell, which on macOS is bash 3.2 (Apple has not
shipped a newer bash since the GPL license change). Associative
arrays silently fail on macOS bash 3.2, producing hard-to-debug
behavior. Inside the dev container bash is modern, but the scripts
that launch the container cannot assume that environment.
Alternative. Use parallel indexed arrays, or switch to awk/jq for key-value lookups. If you genuinely need associative arrays, the code belongs inside the container, not in host scripts.
enforce-host-container-split¶
What. Checks the routing of every st-*, gh, git, and
language-toolchain command against the canonical host-vs-container
split from
#96.
- Denies wrapping a host-only tool in
vrg-container-run --(e.g.,vrg-container-run -- gh issue list). The host tool needs SSH-agent, host git config, or hostghauth — the container can't satisfy those. - Warns (via
additionalContext, not deny) when a container-only tool is invoked directly — whether bare (e.g.,ruff check .) or wrapped invrg-container-run --. Both bypass the canonical validation entry point. The correct command isvrg-container-run -- vrg-validate, which handles all tool routing internally.
The canonical tool lists live in
hooks/scripts/lib/host-container-tools.sh so the same source of
truth powers both the hook and any future docs/lint.
Why. The drift that produced #96 — 47 days of agents silently
wrapping host tools in the container — was caused by documentation
being the only enforcement mechanism. Issue
#168
extended this to also catch agents bypassing the canonical
validation entry point by calling linters directly (even correctly
wrapped in vrg-container-run). The agent should never invoke
individual linters — vrg-validate handles tool routing internally.
Alternative. For denied commands: invoke the host tool
directly (drop the vrg-container-run -- prefix). For warned
commands: use vrg-container-run -- vrg-validate instead of invoking
individual linters.
block-autoclose-linkage¶
What. Denies vrg-submit-pr invocations that pass --linkage
Fixes, --linkage Closes, or --linkage Resolves.
Why. These keywords auto-close the linked issue when the PR
merges. Under the 2.1 workflow issues are closed explicitly by
the human after PR finalization (vrg-finalize-pr) — an issue
closed automatically at merge time signals "done" before the human
has confirmed the work cycle is complete. Closing is deliberately
manual today; agents have closed issues incorrectly, so no
automation owns it (a future close-analysis agent may).
Alternative. Use --linkage Ref. Issue closing is the human's
post-finalization action — not the agent's.
block-agent-merge¶
What. Denies Bash tool invocations that call gh pr merge,
gh pr review --approve, or the equivalent gh api calls —
unconditionally.
Why. Under the 2.1 workflow agents have no merge path at all:
the per-VM GitHub App credentials cannot merge, and merging is the
human's Phase-6 action (vrg-finalize-pr). Skill prose saying "do
not merge" is advisory; agents rationalize past it. This hook makes
the rule mechanical — an ergonomic fast-fail on top of the hard
credential gate. The deny applies to all identities and all
branches, release PRs included; the pre-2.1 release-branch
allow-list (delegated to a vrg-check-pr-merge tool that was never
shipped) was removed in #441.
See #162
for the original motivating incident.
Alternative. Hand the PR URL to the human, who merges and
finalizes via vrg-finalize-pr.
block-github-contents-api¶
What. Denies gh api calls that write (PUT/POST/DELETE) to the
GitHub Contents API. Reads (GET) are allowed.
Why. Writing files via the API bypasses the local workflow
entirely — no validation, no commit standards, no PR template. File
changes go through the worktree: edit, vrg-commit, write
.vergil/pr-template.yml; the human submits with vrg-submit-pr.
(vrg-gh denies gh api outright; this hook catches raw gh.)
Alternative. Make the change in your worktree and follow the local workflow.
PreToolUse Hooks — Write|Edit¶
block-worktree-bypass-write¶
What. Blocks Write/Edit file modifications targeting the main worktree when the parallel-AI-agent worktree convention is active.
Why. The main worktree is read-only for source by convention — all
edits flow through a .worktrees/<name>/ worktree on a feature branch.
The convention's
build/ exception
allows ignored scratch in the main clone's build/ (the MQ lab's shared
live-lab state lives there), and build/.vergil-state/ is the namespace
for agent state (the paad review doctrine's state
directories).
Of the main worktree, the hook allows Write and Edit only under
build/, the one place the human can see what agents write, and there
only to paths git reports as ignored. Every
target path is normalised first (., .. and symlinks resolved, for
new files as well as existing ones) with python3, and matched where
it leads: a path that leads out of build/ through .. or a symlink,
or through a build that is itself a symlink, is not allowed as
build/ scratch. A relative target path is denied. Without
python3 the hook denies every Write and Edit, wherever it points; a
write whose path python3 cannot normalise is denied.
Design: docs/specs/2026-05-09-worktree-write-guard-design.md.
Tests: hooks/tests/block-worktree-bypass-write.test.sh.
Alternative. Write to your assigned worktree's absolute path.
guard-audit-writes¶
What. When VRG_IDENTITY_MODE=audit, denies Write/Edit/
NotebookEdit calls targeting anything other than .vergil/audit-*
(the audit's own artifacts) or build/ (scratch space) inside the
worktree. Other identities are never constrained by this hook.
Why. The 2.1 workflow spec makes the AUDIT identity "read-only
by discipline, not by sandbox" — this hook turns the discipline into
a mechanical gate per the no-honor-system principle. The allowlist
deliberately excludes .vergil/pr-template.yml: that file is the
USER agent's artifact and the human's vrg-submit-pr input.
Soft gate, by design. Identity comes from an environment
variable a misbehaving agent can unset; every in-VM guard is soft.
It steers a correctly behaving agent — hard enforcement lives at
the per-identity GitHub App credentials, the pinned
vergil-audit/approved required check, and the VM sandbox. Keeping
the guard simple is the accepted trade-off. Bash-command writes
(>, tee, sed -i …) are a documented gap for the same reason.
Failure mode. Fail-closed inside the audit identity (no path, unresolvable path, or path escaping the worktree → deny; input that cannot be parsed → blocked with exit 2); fail-open outside it.
Alternative. Write audit findings to
.vergil/audit-feedback.yml; use build/ for scratch. Tests:
hooks/tests/guard-audit-writes.test.sh.
PostToolUse Hooks — Bash¶
detect-deprecation-warnings¶
What. Scans test output and command output for deprecation warnings; surfaces them to the agent for triage.
Why. Deprecation warnings get silently tolerated for months until the deprecated feature is removed. Surfacing them at the moment they first appear makes them trackable via the deprecation-triage skill while the context is fresh.
Alternative. Triage the warning via the deprecation-triage
skill — either fix it now or capture it in a tracking issue with
clear resolution criteria.
Hooks deliberately not provided¶
Per-edit linting / validation¶
The plugin does not ship a PostToolUse Write|Edit hook that
runs ruff / mypy / yamllint / markdownlint / shellcheck on each
edited file. An earlier version did (validate-on-edit.sh plus
per-language validate-*.sh scripts); it was removed in
#91.
Why removed. Each per-edit invocation paid the dev-container
startup cost (1–3 s on typical hardware) for one file's worth of
work — five container starts for a single Python edit (ruff check
--fix, ruff format, ruff check, mypy, ty check). Across a
session with dozens of edits, that's minutes of wall-clock overhead
per session, every session. The same checks already run in two
cheaper places: vrg-validate covers them in a single
container start before PR submission, and CI re-runs them on every
PR. The per-edit layer was the third copy with the worst
cost-per-value ratio.
What replaces it. Nothing per-edit. Validation runs at PR time
via vrg-validate,
which is the documented "only validation command" per every
consuming repo's CLAUDE.md. The
block-raw-git-commit PreToolUse hook
already enforces commits going through vrg-commit, which checks
the branch and worktree context before committing: it refuses a
detached HEAD, a protected branch, a branch name that does not
match the branching model or lacks an issue number, and a
feature-branch commit from the main worktree. vrg-commit does
not run validation itself; that happens in vrg-validate and
again in PR CI.
Don't re-add this. A future contributor noticing the absence should resist the impulse to re-add per-edit validation as a "missing feature." The cost-per-value math doesn't work; the gap is intentional.
Stop hook for finalization¶
The plugin no longer ships a Stop hook that blocks session exit
on "PR submitted but vrg-finalize-repo not run." That hook
(stop-guard-finalization.sh) was removed in
#56.
Why removed. Under the 2.1 workflow the agent's work cycle ends
at the PR template: it writes .vergil/pr-template.yml and stops —
the human runs vrg-submit-pr, merges, and finalizes
(vrg-finalize-pr). A session-end gate keyed to agent-side
finalization has no correct trigger left.
What replaces it. Nothing needs to: submission, merge, and
finalization are all human actions now. The retired
remind-finalize PostToolUse hook (removed in #441) is gone for
the same reason — its trigger, vrg-submit-pr in an agent
session, no longer occurs.
Don't re-add this. Re-adding a session-end finalize gate would block the standard PR submission workflow and force agents into broken cleanup behavior just to satisfy the hook.
How hooks work — technical¶
Hooks are defined in hooks/hooks.json and implemented as shell
scripts in hooks/scripts/. Each hook receives the tool input as
JSON on stdin and returns a JSON response indicating whether to
allow, deny, or annotate the action.
PreToolUse response¶
A PreToolUse hook can deny an action by writing JSON to stdout and exiting 0:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Reason the action is being denied."
}
}
Exit 0 with no JSON = allow. Exit 2 = block: Claude Code refuses the call and feeds stderr to the agent. The plugin uses exit 2 for input it cannot parse (see Malformed input fails closed). Any other non-zero exit is a non-blocking error, and the call goes ahead.
PostToolUse response¶
A PostToolUse hook can inject context by writing JSON and exiting 0:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Message surfaced to the agent."
}
}
PostToolUse hooks cannot un-do the tool action; they can only add context the agent sees in its next turn. Exit 2 is a fatal hook error — useful when validation cannot run and the absence of validation should be visible.
Stop response¶
A Stop hook can block session exit by returning: