P
PreToolUse (Write|Edit|MultiEdit|Bash)
HookFires on
PreToolUsematchingWrite|Edit|MultiEdit|BashImmediately before a tool runs — this is where an action can be denied.
Where this sits in a session
- Session start→
- Prompt submitted→
- Prompt expansion→
- Before tool use→
- After tool use→
- Notification→
- Before compaction→
- Subagent finished→
- Turn finished→
- Session end
Can block — This hook is able to deny the action or send Claude back to work.
What it runs
Executed automatically when the event fires — no prompt, no confirmation.
command
bash "${CLAUDE_PLUGIN_ROOT}/hooks/run_guard.sh"Install it yourself
Paste into .claude/settings.json to run this hook without installing the whole plugin. Adjust the paths to point at your own copy of the scripts.
settings.json
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/run_guard.sh\""
}
],
"matcher": "Write|Edit|MultiEdit|Bash"
}
]
}
}Source
Read this before installing — it runs on your machine with your permissions.
#!/bin/sh
# version: 1.0.1
#
# ARS write-scope guard LAUNCHER — PreToolUse hook (#454 Windows portability fix).
#
# WHY THIS EXISTS: the guard hook used to be wired as `python3 ".../ars_write_scope_guard.py"`
# directly. On Windows, `python3` is commonly a 0-byte Microsoft Store App Execution Alias
# stub (not real Python); invoking it non-interactively fails BEFORE the guard's Python runs,
# so none of the guard's own fail-safes apply — it just errors and spams the hook log (#454).
#
# This launcher finds a REAL Python (skipping stubs), then runs the guard as a SUPERVISED
# subprocess. Design: docs/design/2026-06-17-454-windows-python-hook-portability-design.md.
#
# POSTURE (Plan A — graceful degradation; the guard is OPTIONAL v3.10 hardening and ARS core
# needs no Python): if no real Python is found, OR the guard subprocess misbehaves, the
# launcher emits a valid PASS-THROUGH hook JSON and exits 0. It NEVER exits non-zero on these
# degraded paths (a non-2 exit blocks nothing anyway and only spams logs; exit 2 would
# hard-lock the user out of all writes/Bash for an environment gap or an ARS-side bug — wrong
# for an optional layer). It stays SILENT on stderr on degraded paths: PreToolUse is a hot
# path, so any per-call stderr IS the spam #454 is about.
#
# Bash 3.2 / POSIX sh compatible (same constraint as scripts/announce-ars-loaded.sh). On
# Windows this runs under Git Bash; with no Git Bash, CC falls back to PowerShell which can't
# run this .sh — the guard is then inactive (accepted degradation, see spec §3.3).
# Canonical pass-through output: no permissionDecision => falls back to the normal permission
# flow (NEVER emit "allow" — that would skip every other permission rule).
PASS_THROUGH='{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}'
emit_passthrough_and_exit() {
printf '%s\n' "$PASS_THROUGH"
exit 0
}
# --- Resolve the guard script from THIS launcher's own location (codex P1) ---------------
# CC substitutes ${CLAUDE_PLUGIN_ROOT} into the hook COMMAND text before the shell, but does
# NOT guarantee it as an env var inside this script. So compute the guard path from $0.
# (No production env override: the guard path is ALWAYS derived from the launcher's own
# location. Tests that need a broken/alternate guard run the launcher from a temp plugin
# layout, so there is no production back door — P2-e.)
# shellcheck disable=SC1007 # `CDPATH= cd` is intentional: clear CDPATH for this one cd only
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || emit_passthrough_and_exit
GUARD="$SELF_DIR/../scripts/ars_write_scope_guard.py"
# --- Read the payload from stdin ONCE (we must replay it to the guard subprocess) ---------
# Known trade-off (gemini round-6 P2, accepted): the payload is held in a shell variable and
# replayed with `printf '%s'`. `printf` is a builtin so it is NOT bound by ARG_MAX, and the
# stripped trailing newline is irrelevant to the guard's JSON parse, so for ordinary hook
# payloads this is safe. A multi-megabyte Write payload on a POSIX shell that caps variable
# length is the narrow case this does not cover; buffering to a private temp file would handle
# it but adds another temp-file lifecycle (and symlink surface) to a hot path, so it is left as
# documented degradation rather than fixed speculatively.
PAYLOAD=$(cat)
# --- Marker probe: does this candidate run real Python? ----------------------------------
# A candidate is "real" iff the probe exits 0 AND prints the exact marker on stdout. A 0-byte
# Store stub fails to execute / prints nothing, so it is skipped. We bound each probe so a
# broken-but-hanging interpreter can't wedge the hot path (spec §3.3): prefer `timeout` when
# present, else a portable process-group watchdog.
MARKER=ARS_PY_OK
# Per-candidate (and guard) wall-clock bound, seconds. A small ops knob; validated to be a
# bare integer so it can't smuggle anything into the `timeout`/`sleep` args. Default 3.
PROBE_BOUND=${ARS_PROBE_BOUND:-3}
case "$PROBE_BOUND" in
''|*[!0-9]*) PROBE_BOUND=3 ;;
esac
# ARS_GUARD_FORCE_WATCHDOG=1 forces the no-`timeout` watchdog path even on hosts that HAVE
# `timeout`. This is a test/debug switch: it only changes the BOUNDING MECHANISM (timeout
# binary vs process-group watchdog), never the security decision — both paths enforce the
# same wall-clock bound and the same pass-through-on-overrun posture. Honored if set; safe
# to leave unset (the default prefers the `timeout` binary when present).
have_timeout() {
[ -z "${ARS_GUARD_FORCE_WATCHDOG:-}" ] && command -v timeout >/dev/null 2>&1
}
# Reserved exit status meaning "the bounded command was killed for overrunning its bound".
TIMEOUT_STATUS=124
# Run "$@" with a wall-clock bound; its stdout flows to OUR stdout (capture via $(...)).
# Returns the command's real exit status, or $TIMEOUT_STATUS if it overran the bound.
# Stdin for "$@" is whatever the caller arranges (we redirect it per call site) — the guard
# call site PIPES the payload in, so the bounded command MUST keep that stdin.
run_bounded() {
if have_timeout; then
# GNU timeout exits 124 on timeout — normalize to our sentinel for a uniform caller.
timeout "${PROBE_BOUND}s" "$@"
_st=$?
[ "$_st" -eq 124 ] && return "$TIMEOUT_STATUS"
return "$_st"
fi
# No `timeout` binary: a portable background-and-watchdog fallback. Several POSIX subtleties
# bite here, all of which broke a naive version (#454 dual-track):
#
# 1. STDIN: a backgrounded (`&`) command's stdin defaults to /dev/null, which would feed
# the GUARD an EMPTY payload (guard reads nothing -> pass-through -> a real `deny` is
# LOST and the guard is dead on any timeout-less host). We stash this function's stdin
# on fd 3 and redirect the job's stdin from it (`<&3`) so the piped payload arrives.
# 2. STDOUT: capturing the job's stdout through the `$(...)` pipe directly would WEDGE the
# pipe — if the job spawns a grandchild we can't reap (no `setsid` to group-kill), that
# orphan keeps the pipe's write end open and `$(...)` blocks until it dies (the whole
# point of the bound). So the job writes stdout to a temp file; we `cat` it back after
# reaping the direct child. The orphan may linger but can no longer wedge the hot path.
# 3. KILL: with `setsid` we new-pgid the job and signal the whole group (negative pid) so
# TERM-ignoring children die too; without it we can only reach the direct pid, so a
# grandchild a hung interpreter spawned can be ORPHANED (it no longer wedges the hot
# path per (2), but it lingers). There is no fully portable POSIX reap without process
# groups / setsid / job-control, and inventing one would add fragility for a doubly-
# degraded case (no `timeout` AND no `setsid` AND a broken interpreter that forks — the
# real marker probe and guard never spawn grandchildren). Accepted; prefer `timeout`
# (path A) and `setsid` where present (codex round-6 P2).
# 4. TIMEOUT DETECTION via a DONE-FILE handshake, not the exit code (a TERM'd `sh` wrapper
# does not reliably surface 143/137) and not "did the kill succeed" (gemini round-6 P1
# refuted that: after `wait` reaps the child its pid is freed, and if the OS recycles it
# before the parent disarms the watchdog, a blind `kill` would land on an INNOCENT
# unrelated process — succeeding, so it would both false-flag a timeout AND signal the
# wrong pid). Instead the parent writes a done-file the instant `wait` returns; the
# watchdog kills (and flags) ONLY if that file is still absent when its sleep elapses.
# So "child finished in time" and "child overran" are decided by the handshake, never by
# racing a pid. mktemp failure must NOT fall back to a predictable /tmp path: a local
# attacker could pre-create it as a symlink, our redirect would fail, and the launcher
# would treat that as a broken guard and fail OPEN (gemini round-6 P1). If we can't get a
# private temp, degrade safely to pass-through instead of writing a guessable path.
_rb_out=$(mktemp 2>/dev/null) || emit_passthrough_and_exit
_rb_fired=$(mktemp 2>/dev/null) || { rm -f "$_rb_out" 2>/dev/null; emit_passthrough_and_exit; }
_rb_done=$(mktemp 2>/dev/null) || { rm -f "$_rb_out" "$_rb_fired" 2>/dev/null; emit_passthrough_and_exit; }
rm -f "$_rb_fired" 2>/dev/null # absent = watchdog has not fired
rm -f "$_rb_done" 2>/dev/null # absent = parent has not yet signalled completion
if command -v setsid >/dev/null 2>&1; then
{ setsid "$@" >"$_rb_out" <&3 3<&- & } 3<&0
else
{ "$@" >"$_rb_out" <&3 3<&- & } 3<&0
fi
_cmd_pid=$!
# The watchdog subshell (and the `sleep` it forks) must NOT inherit our stdout: when the
# caller captures run_bounded via `$(...)`, an inherited fd1 IS that pipe, and after we
# kill the subshell its orphaned `sleep` keeps the write end open — the caller's `$(...)`
# then blocks until the FULL bound elapses even though the job finished in milliseconds.
# That stall happened on every healthy probe/guard run on every timeout-less host (~2x
# bound per hook call, measured 6.1s at the default bound on stock macOS) and is the
# load-margin flake #545 documents. Same reasoning as note (2) above for the job itself;
# the watchdog writes nothing to stdout, so /dev/null loses nothing.
( sleep "$PROBE_BOUND"
# If the parent already signalled completion, the child finished within the bound: do NOT
# kill (its pid may have been recycled to an innocent process) and do NOT flag a timeout.
if [ ! -f "$_rb_done" ]; then
# Real overrun: kill the whole process group (negative pid) when we can, else the bare
# pid, and record the timeout. Re-check the done-file before the hard KILL too.
kill -TERM "-$_cmd_pid" 2>/dev/null || kill -TERM "$_cmd_pid" 2>/dev/null
: >"$_rb_fired"
sleep 1
if [ ! -f "$_rb_done" ]; then
kill -KILL "-$_cmd_pid" 2>/dev/null || kill -KILL "$_cmd_pid" 2>/dev/null
fi
fi
) >/dev/null 2>&1 &
_watch_pid=$!
wait "$_cmd_pid" 2>/dev/null
_st=$?
: >"$_rb_done" # DISARM the watchdog before reaping it — closes the pid-reuse race window
kill "$_watch_pid" 2>/dev/null
wait "$_watch_pid" 2>/dev/null
cat "$_rb_out" 2>/dev/null # replay captured stdout to OUR stdout (for the $(...) caller)
if [ -f "$_rb_fired" ]; then
rm -f "$_rb_out" "$_rb_fired" "$_rb_done" 2>/dev/null
return "$TIMEOUT_STATUS"
fi
rm -f "$_rb_out" "$_rb_fired" "$_rb_done" 2>/dev/null
return "$_st"
}
# Echo the first candidate ("cmd args") that verifies as REAL Python, or nothing.
# A candidate qualifies ONLY if the probe exits 0 AND prints exactly the marker on stdout
# (P1-a: a stub that prints the marker but exits non-zero must be rejected). Candidates in
# order; `py -3` first (the Windows launcher).
find_real_python() {
for cand in "py -3" "python3" "python"; do
# shellcheck disable=SC2086 # intentional word-split: "py -3" -> py with arg -3
set -- $cand
cmd=$1
command -v "$cmd" >/dev/null 2>&1 || continue
probe_out=$(run_bounded "$@" -c "import sys; sys.stdout.write('$MARKER')" </dev/null 2>/dev/null)
probe_status=$?
if [ "$probe_status" -eq 0 ] && [ "$probe_out" = "$MARKER" ]; then
printf '%s' "$cand"
return 0
fi
done
return 1
}
REAL_PY=$(find_real_python) || emit_passthrough_and_exit
[ -n "$REAL_PY" ] || emit_passthrough_and_exit
# is_valid_hook_json: true iff $1 parses as a JSON object containing a top-level
# "hookSpecificOutput" key. Uses the REAL Python we already found (no jq dependency, P1-c:
# substring grep false-accepts e.g. `not json "hookSpecificOutput"`). Reads candidate on stdin.
is_valid_hook_json() {
# shellcheck disable=SC2086 # $REAL_PY is "py -3" or "python3" — intentional split
set -- $REAL_PY
printf '%s' "$GUARD_OUT" | "$@" -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(1)
sys.exit(0 if isinstance(d, dict) and "hookSpecificOutput" in d else 1)
' >/dev/null 2>&1
}
# --- Supervise the guard subprocess (P1-b: time-bound it too; P1-c: validate JSON) --------
# Run the guard with the found interpreter, replaying the captured payload on its stdin,
# under the SAME wall-clock bound as the probes so a hung guard can't wedge the hot path.
# Decide what to emit:
# * guard exits 0 AND stdout is a JSON object with hookSpecificOutput -> forward verbatim.
# * anything else (non-zero, timeout, empty, non-JSON, missing key) -> guard is BROKEN;
# per the maintainer decision (§3.2.1) degrade to pass-through + exit 0, never block.
# shellcheck disable=SC2086 # $REAL_PY is "py -3" or "python3" — intentional split
set -- $REAL_PY
# Capture the guard's stderr to a temp so we can RELAY it on the success path (P2-h: the guard
# has its own no-silent advisories — absent agent_type / schema drift / unreadable manifest —
# that must surface; the launcher only suppresses stderr on its OWN degraded paths). On the
# broken path we drop it, since broken-guard noise on every hot-path call is the #454 spam.
# No predictable /tmp fallback: a guessable path is a symlink-attack surface, and a redirect
# onto an attacker-owned symlink fails -> we'd read that as a broken guard and fail OPEN
# (gemini round-6 P1). If mktemp can't give us a private file, degrade safely to pass-through.
GUARD_ERR=$(mktemp 2>/dev/null) || emit_passthrough_and_exit
GUARD_OUT=$(printf '%s' "$PAYLOAD" | run_bounded "$@" "$GUARD" 2>"$GUARD_ERR")
GUARD_STATUS=$?
if [ "$GUARD_STATUS" -eq 0 ] && is_valid_hook_json; then
# Healthy guard decision: relay its stderr advisories, then forward its JSON verbatim.
[ -s "$GUARD_ERR" ] && cat "$GUARD_ERR" >&2
rm -f "$GUARD_ERR" 2>/dev/null
printf '%s\n' "$GUARD_OUT"
exit 0
fi
# Guard broke / timed out / produced invalid output. Degrade, don't block, don't spam.
rm -f "$GUARD_ERR" 2>/dev/null
emit_passthrough_and_exit
Shipped by 1 plugin
Installing any of these installs this hook.
Reviews
Log in to leave a review.
No reviews yet — be the first.
Explore related
Other things in this space — across every part of the ecosystem, not just hooks.