Guard hook templates
These are files, not snippets. They sit in
docs/guides/integrations/agents/:
download them from there, or copy a block below. magus's own repository invokes
the same files rather than keeping a private copy, so what it dogfoods is what
you get, and two tests fail if its config stops referencing them or a block here
drifts from the file.
They are a magus project of their own, so the TypeScript beside them is held to
the same gates as the rest of the workspace
(magus run lint docs/guides/integrations/agents runs tsc --noEmit, Biome,
and shellcheck).
Two hosts run these two files: Claude Code and Codex. Cursor and OpenCode each ship one self-contained file instead, on their own pages, because a host that needs three downloads to install a guard ends up without one.
Checking whether your copy is current
Once you copy a template into your host's config it is yours, and magus cannot reach it again. That is the point - you are meant to edit these - but it means a fix magus makes never arrives on its own, and nothing about your copy says how old it is. So each one carries a version line:
grep magus-guard-template ~/.claude/hooks/magus-guard-command.sh
Compare it with the version in the block below. If yours is lower or absent, re-copy - and diff rather than overwrite, because your edits are worth keeping. A missing line means the copy predates versioning entirely.
The version tracks BEHAVIOR, not wording: it moves when a template starts doing something different, not when a comment is rewritten. It is deliberately not a checksum, because these are yours to modify and a checksum would flag your own edits as drift.
This is the one part of the agent surface with no automatic staleness check.
Installed skills are generated, so magus doctor regrades them against
the binary; a copied hook template is owned by you, and this line stands in for
that. magus doctor's guard wiring check reads the marker in whatever file
your host config points at, and fails when it is stale or missing.
How they fit together
One implementation per guard surface. A host sets overrides and delegates:
| variable | what it is |
|---|---|
HOST_EVENT_PATH |
dot-path to the command or file path inside your host's event JSON |
HOST_SESSION_PATH |
dot-path to the session id inside your host's event JSON |
HOST_RESPONSE |
Go template rendering your host's reply from the verdict |
GUARD_AGENT_NAME |
the agent host name recorded on the activity event (claude-code, codex, ...) |
GUARD_UNAVAILABLE_RESPONSE |
what to print when magus is missing, so each host picks its own fail-open or fail-closed stance |
GUARD_FAILED_RESPONSE |
the same, for a magus that is found but cannot judge the input; unset, the notice is built from evidence |
GUARD_MAGUS_BIN |
absolute path to magus when it is not on PATH |
GUARD_AGENT_NAME and HOST_SESSION_PATH feed magus session hook --agent-name and
--session, which are pure attribution: they label the recorded observation and
cannot change a verdict. A host that supplies neither is judged identically and
simply records less about itself.
GUARD_MAGUS_BIN avoids the MAGUS_* prefix on purpose. That space is magus's
own configuration surface, and a variable these templates invent must not look
like a setting magus reads.
magus-guard-command.sh
The command guard. Claude Code and Codex both run this one file; each sets its overrides and execs it, so there is one implementation to reason about.
#!/usr/bin/env sh
# magus guard hook: judges ONE shell command an agent is about to run.
#
# This file is the source of truth. The docs site embeds it, magus's own
# repository invokes it, and you can download it and do the same. POSIX sh, no
# bashisms; nothing in it is magus-internal.
#
# Contract: reads the host's event as JSON on stdin, selects its command with
# jq, then pipes the command into magus session hook. It writes the host's response on
# stdout and exits 0 either way. Override any of the variables below:
#
# HOST_EVENT_PATH dot-path to the command inside your host's event
# HOST_SESSION_PATH dot-path to the session id inside your host's event
# HOST_TRANSCRIPT_PATH dot-path to your host's own log of this session
# HOST_RESPONSE Go template rendering your host's reply
# HOST_ADVISE_BRANCH the advise arm of that template
# GUARD_NO_ADVISE set it when the host has no context-injection channel, so
# an advise renders nothing rather than something it rejects
# GUARD_AGENT_NAME the agent host name recorded alongside the observation
# GUARD_MAGUS_BIN path to the binary, when it is not on PATH
# GUARD_UNAVAILABLE_RESPONSE what to print when magus cannot be found, so a
# host can choose its own fail-open or fail-closed stance
# GUARD_FAILED_RESPONSE the same, for a magus that IS found but cannot judge
# the command. Left unset, this file builds one from evidence:
# which binary it resolved, that binary's version, and the
# error it actually printed
#
# The defaults are Claude Code's event and response shape.
#
# GUARD_AGENT_NAME and the session are ATTRIBUTION, not policy. magus records them on
# its activity event so a reader can tell which host produced an observation;
# neither one can change the verdict, and a host whose event carries no session
# id records none and is judged exactly the same.
#
# GUARD_MAGUS_BIN is deliberately NOT called MAGUS_BIN: the whole MAGUS_* space is
# magus's own configuration surface, so a variable this template invents must stay
# out of it rather than look like a setting magus reads.
#
# On a missing magus this prints a visible notice rather than exiting quietly. A
# bare `command -v magus || exit 0` fails SILENTLY - the guard never runs and
# nothing says so - and an unguarded session you know about beats one you do not.
#
# The line below declares, per guard surface, how much of a verdict this file
# can carry: model (reaches the agent), human (reaches the person only), or none
# (not delivered). It is machine-read by the host-parity gate, which fails the
# build when a decision or surface exists in the guard contract that some host
# was never asked about. Keep it true to what HOST_RESPONSE actually renders.
# magus-guard-template: 10
# magus-guard-coverage: schema=1 host=claude-code surface=command deny=model advise=model pass=none
# magus-guard-coverage: schema=1 host=codex surface=command deny=model advise=none pass=none
# Plain assignment, NOT ${VAR:=default}: the response template is full of `}` and
# the first one would terminate a ${...} expansion, silently truncating it.
[ -n "$HOST_EVENT_PATH" ] || HOST_EVENT_PATH='tool_input.command'
[ -n "$HOST_SESSION_PATH" ] || HOST_SESSION_PATH='session_id'
[ -n "$HOST_TRANSCRIPT_PATH" ] || HOST_TRANSCRIPT_PATH='transcript_path'
[ -n "$GUARD_AGENT_NAME" ] || GUARD_AGENT_NAME='claude-code'
# The advise arm is split out because not every host has one. Codex's PreToolUse
# REJECTS additionalContext - it treats the key as an error and the hook then fails
# OPEN - so an advisory sent there is not merely dropped, it disarms the guard for
# that call. Codex sets GUARD_NO_ADVISE from codex-hooks.json and declares advise=none.
# A plain `[ -n ... ] ||` cannot express "deliberately empty" - an empty value looks
# unset and gets the default back - and ${VAR-default} is unusable here for the same
# `}` reason as above. So the suppression is its own flag.
if [ -n "$GUARD_NO_ADVISE" ]; then
HOST_ADVISE_BRANCH=''
else
[ -n "$HOST_ADVISE_BRANCH" ] || HOST_ADVISE_BRANCH='{{else if eq .decision "advise"}}{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":{{toJson .context}}}}'
fi
[ -n "$HOST_RESPONSE" ] || HOST_RESPONSE='{{if eq .decision "deny"}}{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":{{toJson .reason}}}}'"$HOST_ADVISE_BRANCH"'{{end}}'
# Prefer the workspace's own ./magus over PATH. A repository that builds magus, or pins a
# newer one than is installed, keeps its RULES in that binary - and an older PATH copy does
# not fail loudly when it lacks them. It does not recognize the config key that ARMS a rule,
# warns about an unknown field, and returns pass: silent non-enforcement at exit 0. Measured
# 2026-08-13, when a write into a declared notes store was allowed by a binary that predated
# the knowledge.notes key while `magus doctor` reported the guard as fine.
#
# Found by walking UP to the magusfile, not by testing ./magus alone. A hook runs in the
# host's session directory, and that is not always the workspace root: a session opened in
# a subdirectory, or opened in one checkout while the work happens in another, tests a
# ./magus that is not there and falls through to PATH. Where PATH's copy cannot load the
# workspace at all, that is the entire guard failing open - measured 2026-08-27, when a
# piped `magus affected ci` that the rules DO deny ran unjudged. Same upward search for a
# project root that every other ecosystem's runner does.
guard_root=$PWD
while [ -n "$guard_root" ] && [ -z "$GUARD_MAGUS_BIN" ]; do
if [ -f "$guard_root/magusfile.buzz" ]; then
[ -x "$guard_root/magus" ] && GUARD_MAGUS_BIN=$guard_root/magus
break
fi
guard_root=${guard_root%/*}
done
[ -n "$GUARD_MAGUS_BIN" ] || GUARD_MAGUS_BIN=$(command -v magus 2>/dev/null)
[ -n "$GUARD_UNAVAILABLE_RESPONSE" ] || GUARD_UNAVAILABLE_RESPONSE='{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"magus guard is NOT running: magus is not on PATH, so its deny and advise rules are unenforced right now. Install magus, or set GUARD_MAGUS_BIN to its path, to restore the guard."}}'
# stdin is a pipe and can only be drained once, so the event is read into a
# variable and selected from twice - the command to judge, and the session id to
# attribute it to. `// empty` keeps a host without that field at the empty
# string rather than the literal "null".
#
# Read BEFORE the availability check below, because the notices that check prints are
# held to one firing per session and the session id is what keys them. jq failing here
# leaves the session empty, which guard_notice_once handles as an unidentified session.
event=$(cat)
session=$(printf '%s' "$event" | jq -r ".$HOST_SESSION_PATH // empty" 2>/dev/null)
transcript=$(printf '%s' "$event" | jq -r ".$HOST_TRANSCRIPT_PATH // empty" 2>/dev/null)
# guard_notice_once succeeds the first time $1 fires in this session and fails on every
# repeat, so a caller writes `guard_notice_once <family> && printf ...`.
#
# The notices it holds report a BROKEN INSTALLATION. That is a fact for the person, and
# there is nothing in it an agent can act on, so a repeat is pure noise: measured at 2,741
# firings over recent sessions, 99% of them same-session repeats of text already declined.
#
# The marker lives under TMPDIR rather than in magus's own state because this runs when
# magus is missing or too broken to judge, so it cannot ask magus for anything. cksum and
# mkdir are POSIX; creating the marker is idempotent, so two concurrent tool calls race to
# the same harmless result.
#
# A host that reports no session id shares one marker aged out after GUARD_NOTICE_WINDOW
# minutes, so the first session on such a host cannot silence every session after it.
guard_notice_once() {
notice_dir=${TMPDIR:-/tmp}/magus-guard-notices
notice_key=$(printf '%s' "${session:-anon}" | cksum | cut -d' ' -f1)
notice_marker=$notice_dir/$notice_key.$1
mkdir -p "$notice_dir" 2>/dev/null || return 0
if [ -f "$notice_marker" ]; then
[ -n "$session" ] && return 1
find "$notice_marker" -mmin +"${GUARD_NOTICE_WINDOW:-120}" 2>/dev/null | grep -q . || return 1
fi
: > "$notice_marker" 2>/dev/null
return 0
}
if [ -z "$GUARD_MAGUS_BIN" ] || [ ! -x "$GUARD_MAGUS_BIN" ]; then
guard_notice_once unavailable && printf '%s' "$GUARD_UNAVAILABLE_RESPONSE"
exit 0
fi
# Attribution is BEST EFFORT; the verdict is not.
#
# --agent-name and --session postdate the current magus release, and this template is downloaded and run
# against whatever binary a reader already has. Passing them unconditionally does not degrade the
# guard, it BREAKS it: an older binary rejects the unknown flag, prints its usage to stdout, and
# exits non-zero, so the host receives no verdict at all and every deny and advise rule silently
# stops being enforced. A guard that fails because of a metadata flag has its priorities backwards.
#
# So: try with attribution, and on any failure re-run without it - exactly the call this script made
# before attribution existed. One extra process only on an older binary, and none once the flags are
# in a release.
guard() {
printf '%s' "$event" | jq -r ".$HOST_EVENT_PATH" | "$GUARD_MAGUS_BIN" session hook "$@" -o "template=$HOST_RESPONSE"
}
# guard_failure_notice states WHICH binary went silent, what version it is, and what it
# actually said - the three facts a reader otherwise spends a session collecting.
#
# The text this replaces offered two suspects, "too old for session hook, or cannot load
# this workspace", and the second is not a cause at all: the deny rules need no workspace,
# and a current binary run from an empty directory still denies. Naming a suspect the
# evidence does not support is worse than naming none, because the reader goes and checks it.
#
# It re-runs the guard to capture stderr, which the verdict path discards. One extra
# process, only on the path that is already broken - the same trade the attribution retry
# above makes. WARN lines are dropped because a config the binary is too old to parse warns
# BEFORE it fails, and that warning is a symptom of the same staleness, not the error.
guard_failure_notice() {
ver=$("$GUARD_MAGUS_BIN" version 2>/dev/null | head -n 1)
[ -n "$ver" ] || ver='version unreadable'
why=$(guard 2>&1 >/dev/null | grep -v 'WARN' | head -n 1)
[ -n "$why" ] || why='it printed no error'
printf 'magus guard is NOT running: %s (%s) could not judge this command, so its deny and advise rules are unenforced. It said: %s. Rebuild or update THAT binary to restore the guard.' \
"$GUARD_MAGUS_BIN" "$ver" "$why"
}
# A DENY exits non-zero (2) with the verdict on stdout, so a bare `||` retry would treat
# every blocked command as "this binary rejected the attribution flags" and judge it a
# second time - unattributed, and recorded twice in the activity trail. Emptiness alone
# cannot tell the cases apart either, because a pass renders empty on purpose. Both
# together can: a rejected flag prints its usage to STDERR and leaves stdout empty, while
# any real verdict that is not a pass leaves something on stdout.
verdict=$(guard --agent-name "$GUARD_AGENT_NAME" --session "$session" --transcript "$transcript" 2>/dev/null)
status=$?
if [ "$status" -ne 0 ] && [ -z "$verdict" ]; then
verdict=$(guard 2>/dev/null)
status=$?
fi
# A PASS and a BROKEN GUARD both render nothing, and telling them apart is the whole
# point of this block. A pass exits 0 with empty output because there was nothing to
# say; a binary that cannot run - too old for `session hook`, unable to load the workspace,
# half-written by a concurrent build - exits non-zero with empty output, and printing
# that as a pass silently disables every rule with nothing anywhere saying so.
#
# That silence is the failure mode this guard can least afford, because it looks
# exactly like a clean session. The same reasoning is already spelled out above for a
# MISSING binary; a broken one had been left to fail quietly, which is the case that
# actually occurs - a stale binary on PATH outlives a missing one.
#
# Fail OPEN either way. A guard that blocks work because it cannot judge it has its
# priorities backwards, and an unguarded session you know about beats one you do not.
#
# Said once per session. The line above used to repeat on every tool call, on the
# reasoning that nothing survives between two of them; guard_notice_once is what does,
# without asking the binary that just failed to run for anything. Twenty copies of
# identical text is wallpaper, and wallpaper is how a real failure goes unread.
if [ "$status" -ne 0 ] && [ -z "$verdict" ]; then
if guard_notice_once failed; then
if [ -n "$GUARD_FAILED_RESPONSE" ]; then
printf '%s' "$GUARD_FAILED_RESPONSE"
else
guard_failure_notice | jq -Rc '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:.}}'
fi
fi
exit 0
fi
printf '%s' "$verdict"
magus-guard-path.sh
The declared-output guard. Wire it to your host's file-editing tool rather than its shell tool. It explains rather than blocks: editing a generated file is wasteful, not destructive.
#!/usr/bin/env sh
# magus guard hook: judges ONE file path an agent is about to write.
#
# Companion to magus-guard-command.sh, wired to your host's file-editing tool
# rather than its shell tool. POSIX sh, no bashisms.
#
# The declared-output rule here is the one guard rule that is not a heuristic:
# magus reads every target's DECLARED outputs, so a generated file is generated
# by definition and an edit to it would be overwritten by the next run.
#
# That rule ADVISES rather than blocks. magus denies only what cannot be undone;
# a hand-edited generated file is wasteful, not destructive, since regenerating
# erases it. So it explains that the edit will be overwritten and lets the agent
# correct itself, rather than treating it as unable to learn. Every rule on this
# surface says nothing on any uncertainty - no magus, no workspace, an unclaimed
# path - because an advisory fired on a guess trains the reader to ignore it.
#
# HOST_RESPONSE renders BOTH arms even though the rules shipping today only
# advise. That is deliberate and it is why this template exists at version 2.
# These files are COPIED into a reader's config and never self-correct, so a
# deny arm added at the same time as the first denying rule would fail OPEN on
# every already-installed copy: the deny renders empty, magus exits non-zero,
# and the tail below reads empty-output-plus-nonzero as a broken guard and exits
# 0, which every host takes as allow. Shipping the arm first gives installed
# copies a window to update against a rule that is not yet firing.
#
# A host with no file-write hook still gets the command rules; it just misses
# this one. That is a coverage difference to record, not a reason to skip it.
#
# GUARD_AGENT_NAME and HOST_SESSION_PATH work exactly as they do in
# magus-guard-command.sh: attribution recorded on the activity event, never an
# input to the verdict.
#
# Coverage declaration, machine-read by the host-parity gate - see the longer
# note in magus-guard-command.sh. It records what HOST_RESPONSE RENDERS, not
# which rules currently fire, so deny=model is true the moment the arm exists.
# magus-guard-template: 10
# magus-guard-coverage: schema=1 host=claude-code surface=path deny=model advise=model pass=none
# magus-guard-coverage: schema=1 host=codex surface=path deny=model advise=none pass=none
# Plain assignment, NOT ${VAR:=default}: the response template is full of `}`
# and the first one would terminate a ${...} expansion.
[ -n "$HOST_EVENT_PATH" ] || HOST_EVENT_PATH='tool_input.file_path'
[ -n "$HOST_SESSION_PATH" ] || HOST_SESSION_PATH='session_id'
[ -n "$HOST_TRANSCRIPT_PATH" ] || HOST_TRANSCRIPT_PATH='transcript_path'
[ -n "$GUARD_AGENT_NAME" ] || GUARD_AGENT_NAME='claude-code'
# Same split, and the same reason, as in magus-guard-command.sh: a host whose
# PreToolUse rejects additionalContext fails OPEN on one, so an advisory it cannot
# take disarms the call rather than merely going unread. Codex wires BOTH surfaces
# to PreToolUse, so it suppresses here too.
if [ -n "$GUARD_NO_ADVISE" ]; then
HOST_ADVISE_BRANCH=''
else
[ -n "$HOST_ADVISE_BRANCH" ] || HOST_ADVISE_BRANCH='{{else if eq .decision "advise"}}{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":{{toJson .context}}}}'
fi
[ -n "$HOST_RESPONSE" ] || HOST_RESPONSE='{{if eq .decision "deny"}}{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":{{toJson .reason}}}}'"$HOST_ADVISE_BRANCH"'{{end}}'
# Prefer the workspace's own ./magus over PATH. A repository that builds magus, or pins a
# newer one than is installed, keeps its RULES in that binary - and an older PATH copy does
# not fail loudly when it lacks them. It does not recognize the config key that ARMS a rule,
# warns about an unknown field, and returns pass: silent non-enforcement at exit 0. Measured
# 2026-08-13, when a write into a declared notes store was allowed by a binary that predated
# the knowledge.notes key while `magus doctor` reported the guard as fine.
#
# Found by walking UP to the magusfile, not by testing ./magus alone. A hook runs in the
# host's session directory, and that is not always the workspace root: a session opened in
# a subdirectory, or opened in one checkout while the work happens in another, tests a
# ./magus that is not there and falls through to PATH. Where PATH's copy cannot load the
# workspace at all, that is the entire guard failing open - measured 2026-08-27, when a
# piped `magus affected ci` that the rules DO deny ran unjudged. Same upward search for a
# project root that every other ecosystem's runner does.
guard_root=$PWD
while [ -n "$guard_root" ] && [ -z "$GUARD_MAGUS_BIN" ]; do
if [ -f "$guard_root/magusfile.buzz" ]; then
[ -x "$guard_root/magus" ] && GUARD_MAGUS_BIN=$guard_root/magus
break
fi
guard_root=${guard_root%/*}
done
[ -n "$GUARD_MAGUS_BIN" ] || GUARD_MAGUS_BIN=$(command -v magus 2>/dev/null)
if [ -z "$GUARD_MAGUS_BIN" ] || [ ! -x "$GUARD_MAGUS_BIN" ]; then
# Prints nothing by default: for most hosts an empty response means "allow".
# Set GUARD_UNAVAILABLE_RESPONSE for a host that needs an explicit verdict.
[ -n "$GUARD_UNAVAILABLE_RESPONSE" ] && printf '%s' "$GUARD_UNAVAILABLE_RESPONSE"
exit 0
fi
# One drain of stdin, two selections from it: the path to judge, and the session
# id to attribute it to. `// empty` keeps a host without that field at the empty
# string rather than the literal "null".
event=$(cat)
session=$(printf '%s' "$event" | jq -r ".$HOST_SESSION_PATH // empty")
transcript=$(printf '%s' "$event" | jq -r ".$HOST_TRANSCRIPT_PATH // empty")
# Attribution is BEST EFFORT; the verdict is not. --agent-name and --session postdate the current magus
# release, and an older binary rejects the unknown flag outright - printing usage to stdout and
# exiting non-zero - which leaves the host with no verdict rather than an unattributed one. Try with
# attribution, fall back to the call this script made before it existed.
guard() {
printf '%s' "$event" | jq -r ".$HOST_EVENT_PATH" | "$GUARD_MAGUS_BIN" session hook --path "$@" -o "template=$HOST_RESPONSE"
}
# Same discrimination as magus-guard-command.sh, and for the same reason now that this
# surface can render a deny: a DENY exits non-zero (2) with the verdict on stdout, so a
# bare `||` retry would treat every blocked write as "this binary rejected the attribution
# flags" and judge it a second time - unattributed, and recorded twice in the activity
# trail. Emptiness alone cannot tell the cases apart either, because a pass renders empty
# on purpose. Both together can: a rejected flag prints its usage to STDERR and leaves
# stdout empty, while any real verdict that is not a pass leaves something on stdout.
verdict=$(guard --agent-name "$GUARD_AGENT_NAME" --session "$session" --transcript "$transcript" 2>/dev/null)
status=$?
if [ "$status" -ne 0 ] && [ -z "$verdict" ]; then
verdict=$(guard 2>/dev/null)
status=$?
fi
# A pass and a broken guard both render nothing; see magus-guard-command.sh for why
# telling them apart matters. Kept identical here so neither surface grows a behavior
# the other lacks - the difference is only that this one has no default message,
# because for most hosts an empty response on this surface already means "allow".
if [ "$status" -ne 0 ] && [ -z "$verdict" ]; then
[ -n "$GUARD_FAILED_RESPONSE" ] && printf '%s' "$GUARD_FAILED_RESPONSE"
exit 0
fi
printf '%s' "$verdict"
magus-guard-observe.sh
The one template that carries no verdict. Wire it to the tools that only LOOK - your host's read equivalent - and it records the path the agent reached without judging it. It prints nothing and always exits 0.
Do not point a read tool at magus-guard-path.sh instead. A read event carries
a file path just as a write event does, so the write rules would advise "you are
editing a declared output" at a file the agent merely opened. --observe is
what separates the two, and only this wrapper can set it, because only it knows
which of your host's tools look.
It declares no magus-guard-coverage line, and that absence is deliberate: a
coverage declaration states how much of a verdict a host can carry on a guard
surface, and this file carries no verdict on no surface.
#!/usr/bin/env sh
# magus observe hook: records ONE path an agent reached, and judges nothing.
#
# This file is the source of truth. The docs site embeds it, magus's own
# repository invokes it, and you can download it and do the same. POSIX sh, no
# bashisms; nothing in it is magus-internal.
#
# Wire it to the tools that only LOOK - your host's read equivalent. The guard
# templates beside this one handle the tools that ACT. Do not point a read tool
# at those: a read event carries a file path, so the write rules would advise
# "you are editing a declared output" at a file the agent merely opened.
# --observe is what separates the two, and only this wrapper can set it,
# because only it knows which of your host's tools look.
#
# Contract: reads the host's event as JSON on stdin, selects the path with jq,
# and pipes it into `magus session hook --observe`. It prints NOTHING and always exits
# 0 - see the note on that below, which is load-bearing rather than tidy.
# Override any of the variables below:
#
# HOST_EVENT_PATH dot-path to the read path inside your host's event
# HOST_SESSION_PATH dot-path to the session id inside your host's event
# HOST_TRANSCRIPT_PATH dot-path to your host's own log of this session
# GUARD_AGENT_NAME the agent host name recorded alongside the observation
# GUARD_MAGUS_BIN path to the binary, when it is not on PATH
#
# The defaults are Claude Code's event shape, matching its two siblings. A
# different host overrides the dot-paths and passes its own GUARD_AGENT_NAME,
# exactly as codex-hooks.json already does for the guard templates.
#
# NO magus-guard-coverage line, and that absence is deliberate rather than an
# oversight: a coverage declaration states how much of a VERDICT a host can
# carry on a guard surface, and this file carries no verdict on no surface. It
# never denies, never advises, and cannot change what your host does next. The
# parity gates ask that question only of artifacts that answer it.
#
# magus-guard-template: 10
# NO `set -e`, deliberately, and neither sibling uses it either.
#
# Under `set -e` a jq that is missing (127) or handed a payload it cannot parse
# aborts this script mid-way with jq's own message on stderr - on EVERY read.
# Worse, a PreToolUse hook's exit status is not advisory on every host: some
# treat a specific non-zero code as "block this tool call", so a malformed event
# could stop the agent from reading anything at all. An optional record must
# never be able to do that, so every failure below is swallowed and the script
# ends at `exit 0` on all paths.
[ -n "$HOST_EVENT_PATH" ] || HOST_EVENT_PATH='tool_input.file_path'
[ -n "$HOST_SESSION_PATH" ] || HOST_SESSION_PATH='session_id'
[ -n "$HOST_TRANSCRIPT_PATH" ] || HOST_TRANSCRIPT_PATH='transcript_path'
[ -n "$GUARD_AGENT_NAME" ] || GUARD_AGENT_NAME='claude-code'
# Prefer the workspace's own ./magus over PATH, for the same reason its two siblings do - and
# this file needs it MORE than they do, because it is silent by design. An older PATH copy
# does not know --observe at all: it rejects the flag, prints its usage to a stream this
# script discards, and exits non-zero into an `|| true` - so the observation is simply never
# recorded, forever, with nothing anywhere saying so. Measured 2026-08-14 in magus's own
# repository, where the wiring was correct, the binary was wrong, and the trail held 3252
# events and not one read.
#
# Found by walking UP to the magusfile, not by testing ./magus alone: a hook runs in the
# host's session directory, which is not always the workspace root. The command template
# carries the full reasoning.
guard_root=$PWD
while [ -n "$guard_root" ] && [ -z "$GUARD_MAGUS_BIN" ]; do
if [ -f "$guard_root/magusfile.buzz" ]; then
[ -x "$guard_root/magus" ] && GUARD_MAGUS_BIN=$guard_root/magus
break
fi
guard_root=${guard_root%/*}
done
[ -n "$GUARD_MAGUS_BIN" ] || GUARD_MAGUS_BIN=$(command -v magus 2>/dev/null)
# An absent observer is SILENT, where an absent guard is loud.
#
# The guard templates announce themselves when magus cannot be found, because an
# unenforced deny rule is a safety fact the reader needs. Nothing is unenforced
# here - there is no rule - so the same announcement would be a per-read
# interruption reporting that an optional record was not written.
if [ -z "$GUARD_MAGUS_BIN" ] || [ ! -x "$GUARD_MAGUS_BIN" ]; then
exit 0
fi
# stdin is a pipe and can only be drained once, so the event is read into a
# variable and selected from more than once. `// empty` keeps a host without one
# of these fields at the empty string rather than the literal "null", and jq's
# stderr is discarded because a payload this script cannot parse is a record it
# will not write, not news for the person trying to read a file.
#
# The path is extracted rather than piping the whole event through, because a
# payload magus does not recognize as an envelope is judged as the literal text
# it is - and for a search that carries a pattern but no path, that would record
# the entire event, query text included, as the thing the agent reached.
event=$(cat)
path=$(printf '%s' "$event" | jq -r ".$HOST_EVENT_PATH // empty" 2>/dev/null)
# Nothing to record is not a failure: a host event that names no path has no
# reach to report, and inventing one would claim a file the host never named.
# This gate comes BEFORE the remaining selections so the common no-op case - any
# tool whose event carries no read path - costs one jq rather than three.
[ -n "$path" ] || exit 0
session=$(printf '%s' "$event" | jq -r ".$HOST_SESSION_PATH // empty" 2>/dev/null)
transcript=$(printf '%s' "$event" | jq -r ".$HOST_TRANSCRIPT_PATH // empty" 2>/dev/null)
# A magus too old for --observe rejects the flag and exits non-zero. That is a
# real state worth knowing about once, but not once per read, so it is reported
# through the trail's own absence rather than through the session: if reads are
# missing from `magus session`, the binary is too old. Both streams are
# discarded because a flag-parse error would otherwise reach the host as this
# hook's response on every read.
printf '%s' "$path" | "$GUARD_MAGUS_BIN" session hook --observe \
--agent-name "$GUARD_AGENT_NAME" --session "$session" --transcript "$transcript" \
--event PreToolUse >/dev/null 2>&1
exit 0
What a template must not get wrong
Three failure modes are worth naming, because each one looks like a working guard.
A missing arm renders empty. HOST_RESPONSE carries both a deny arm and an
advise arm. A template missing one does not fail loudly; it renders nothing, and
every host reads nothing as allow. Claude Code's --path wiring once rendered
only the deny arm, so every advisory it produced was silently dropped, while the
shipped magus-guard-path.sh had the opposite gap and dropped denials.
A pass and a broken guard both render nothing. A pass exits 0 with empty
output because there was nothing to say. A binary that cannot run - too old for
session hook, unable to load the workspace, half-written by a concurrent build - exits
non-zero with empty output. Printing that as a pass disables every rule with
nothing anywhere saying so. Both scripts discriminate on status and emptiness
together, and announce the second case. The announcement names its evidence -
the binary path they resolved, that binary's version, and the first line it
printed on stderr - because the guesses it used to offer sent readers to check a
workspace that was never the problem. Set GUARD_FAILED_RESPONSE to replace it
with a fixed response of your own.
Attribution must never break a verdict. --agent-name and --session
postdate the current release, and an older binary rejects an unknown flag by
printing usage and exiting non-zero, which leaves the host with no verdict at
all. Both scripts try with attribution and retry without it, and they retry only
when the call produced no verdict - never merely because it exited non-zero,
since a deny exits 2 with the verdict on stdout.
Trying a verdict by hand
printf '%s' 'git stash' | magus session hook -o name
printf '%s' 'MAGUS.md' | magus session hook --path -o name
magus session hook -o template
The last one lists the fields available to -o template. A deny exits 2 with
the verdict on stdout; a pass and an advise exit 0. See The guard
for the rules behind the verdicts.