The guard
What the guard is
The name follows established access-control terms (NIST SP 800-162,
https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-162.pdf).
Each agent host's hook is the policy enforcement point: it intercepts the tool
call and applies the verdict it gets back. magus's guard, magus session hook, is the policy decision point: it decides allow, advise, or deny from
facts the repository already carries, such as declared outputs, the cache,
the graph, VCS state, and job leases. The workspace's rules, built in plus
anything declared in the root magusfile, are its policy. One sentence may
borrow "shield" as an analogy (Alshiekh et al., Safe Reinforcement Learning
via Shielding, AAAI 2018,
https://cdn.aaai.org/ojs/11797/11797-13-15325-1-2-20201228.pdf): a shield is a
monitor that vetoes a learning agent's proposed actions against a spec, and
the guard plays that role for magus, never choosing an action itself. magus
never calls a model and never owns the host's loop; see
Prior art and terminology for
how this differs from a content-safety guardrail or an agent harness.
Most agent hosts can run a hook before executing a shell command or writing a
file. magus supplies the rule evaluation; the host supplies the hook that calls
it. magus session hook reads one command or one path, applies the rules, and
returns a neutral verdict. A harness descriptor merges opaque host-config
fragments that already name the shipped scripts or plugin; Magus does not
inject a reserved command or a reply codec. Portable hosts can render the
neutral verdict themselves.
Wiring is per host: Claude Code, Codex, Cursor, OpenCode, or any host that can run a command. The rules below are the same everywhere, because they come from one binary.
Deny only what cannot be undone
magus explains everything else. Say that plainly, because the temptation runs the other way: a guard that can prove something is wrong wants to block it.
A whole-tree git reset --hard destroys uncommitted and untracked work,
including a concurrent agent's, and nothing brings it back. magus denies that.
A hand-edited generated file only wastes your time, because regenerating erases
it, so magus explains instead - even though it knows from the target's own
declarations that the file is generated. Blocking there would treat you as
unable to learn something one magus describe file away. An agent told why an
edit was futile does not repeat it; an agent whose call was rejected has only
lost a turn.
The deny triggers
magus denies a call on any one of four independent grounds.
It cannot be undone. The destructive whole-tree VCS operations.
It writes into the working tree outside magus. Codegen, a formatter with
-w, --write or --fix, go mod tidy, build output landing on a tracked
path. This is the firm one, and the only one with no judgment in it. A write
that skips magus is not merely slower: the target that owns that path now
reports drift it did not cause, the cache holds a result for a tree that no
longer exists, and affected tracking has no record that anything moved. Reading
through the wrong tool costs a cache hit; writing through the wrong tool
corrupts the workspace's account of itself.
It has an exact working equivalent. A raw go test is harmless and
reversible, so it fails the first two tests. magus denies it because the
replacement is complete, which makes the deny free. Where no equivalent exists
the rule may only advise: magus has no raw-text search, so a repo-wide grep
gets an explanation, and an earlier attempt to deny it was reverted. Denying
grep was wrong because the deny removed a capability with nothing to route to,
not because grep is safe.
It breaks a provenance guarantee. The first three judge the write - whether it can be taken back, whether it bypassed the tool, whether it was redundant. This one judges what the write does to the checkout: the artifact's value depends on a guarantee about who authored it, and undoing the write does not restore the guarantee.
Its instances are a write into a declared notes store and a read receipt an agent mints for itself. Both refuse an agent authoring a human's statement, and the notes store is the one worth reading out in full. A note is the one thing in the knowledge graph that is not derived from the workspace: a doc comes from markdown, a rationale from a comment, a symbol from an index, an author from git, and rebuilding the graph recovers every one of them. A note's content originates with a person, nothing in the repository corroborates it later, and no rebuild recovers it. One agent-written note does not damage that note; it damages a reader's ability to trust any note without checking blame, and a note of uncertain authorship is worthless rather than merely weaker.
That trigger licenses less than it might appear. It is not "the file is important", and it is not a general provenance rule - source files carry authorship too, and writing them is the job. It applies only where the artifact has no other corroboration, which is what makes authorship its entire value.
What magus denies
The guard parses the shell rather than pattern-matching the string, so it reads
the command being RUN: an environment prefix, env -u GOROOT ..., a launcher,
or bash -c '...' all reach the same verdict as the bare command.
-
Destructive whole-tree VCS operations:
git stash,git reset --hard,git checkout .,git restore .,git clean -f, andgit worktree remove, which destroys another tree's uncommitted and untracked work rather than this one's - in a repository running several checkouts that is routinely another session's, and it is in no commit to recover from. Reading a stash is exempt (git stash list,git stash show), as isgit stash create, which returns a commit object without touching the working tree or the stash stack. Every backend magus drives is covered, in its own spelling:hg purgeandhg clean(and Sapling's),hg revert --all,hg update --clean,sl goto --clean,jj abandon,jj restorewith no paths, andjj workspace forget. Naming paths is what separates a scoped revert from a whole-tree one in every dialect, and the scoped form advises rather than denies.This list read "these rules are git-shaped ... their commands are not matched today" until 2026-09-08, excused by jj keeping an operation log that makes its equivalents undoable. That is true of jj and was generalized to Mercurial without argument: hg has no operation log, and
hg purgedeletes untracked files with no backup at all. So the users with no protection were the ones whose backend gave them the least. -
Raw language tools:
go test,go build,go mod tidy,cargo build,gofmt -w,prettier --write, and the rest. The match is the base PROGRAM a registered spell op renders plus the leading argv it renders with it, so the denied spelling is the one a spell would actually launch. A tool a spell reaches through a runner is therefore matched under the runner:uv run pytestandpnpm exec eslint .deny, while barepytest,eslintandruffpass, because no spell renders those as the program. That is silence rather than endorsement - a target still covers the work. The reason names the escalation ladder: a top-level target first, then a single spell op (magus run go::go-test <project>), which still runs through magus, and--dry-runto see the exact command either would run. Read-only invocations pass:gofmt -landgofmt -dreport without writing, so they bypass nothing. -
Staging everything:
git add -A,git add .,git add -u. A magus target writes its declared outputs as it runs, so a tree is routinely dirty with generated files you did not edit; sweeping them into a commit about something else is how a focused change becomes unreviewable. -
Piping or redirecting magus's own output:
| tail,> file,>> file,2>&1. The equivalent is exact --o name|json|template=returns the field the filter was reaching for, and every run persists its full log, so a failure prints that path with the ref. A pipe additionally replaces the exit status with the last stage's, somagus affected ci | tailreports tail's success and a failing gate reads as exit 0.magus query output <ref>is the one exemption: a raw captured tool log has no schema to project.A text filter aimed at the file a BACKGROUNDED run was captured to denies on the same ground, and it is the shape that gets past the rule above: nothing on
grep -n cause: <capture> | head -8is a magus invocation. The files are the host's task capture (<id>.output) and a persisted run log (.magus/logs/<hex>.log). A failure prints[fail],cause:,output:,inspect:andreproduce:together, so a grep for the cause drops the ref that reads the rest, two lines below it. A range print (sed -n '1,200p') counts: it cuts by position and the block is wherever the run left it. Reading the file whole does not:cat <capture>and an editor tool's read are both fine. Backgrounding the run as-o jsonl --tee <file>makes the capture a contract, after whichjqover it is composition. -
Writing into the declared notes store (
knowledge.notes.shared), however the write is spelled. A file write into the store is caught on the path surface;magus notes editreading piped prose is a command, so it is caught here. The reason names both alternatives:magus memory putfor a workspace decision an agent may record, andmagus notes editfor a person to write the note themselves. The opt-in is the key in the repository's ownmagus.yaml, and the rule is armed from that moment - before the store holds a single note, because otherwise an agent could author its first note and the deny would switch on afterwards. A declaration made anywhere else (an explicit--config, user-global config) is in effect in every workspace on the machine, so it arms this rule only where the store already exists. -
Minting a read receipt (
magus diff --ack). A receipt records that a PERSON read a change, so there is no spelling of it an agent may use. The guard is wired into agent hosts, so every command reaching it came from an agent by construction and a person at a terminal never meets this rule. The reason routes tomagus diff --impact, which names every changed file carrying no receipt, and says to hand that list back rather than stamp it. -
In-place stream edits:
sed -i,sed --in-place. The flag is not portable and the two spellings destroy each other's work: GNU readssed -i 's/x/y/' fas an edit, BSD and macOS read that same script as the backup suffix, and the portable-lookingsed -i '' ...makes GNU edit nothing. The command that worked where it was written mangles the file on the next machine, by writing, so the damage lands before anyone reads a diff. Every host driving this guard has a structured editor tool that applies an exact replacement and reports what changed. Reading with sed is untouched. A scripted substitute-and-write is the same edit by another route and denies with it:perl -iandruby -ioutright, and apythonornodeone-liner whose substitution (re.sub,.replace() is followed on the line by a.write(. Deliberately narrow - an interpreter that only WRITES a file is ordinary authoring and passes - so a one-liner that writes before it substitutes slips through, and the rule is a habit rail rather than a fence. -
Running magus from a copy of the workspace in a temp or scratchpad directory (
cd /tmp/... && magus ..., including via a variable assigned earlier on the same line). The verdict would describe a tree nobody ships: generated files land in the copy, the cache splits, and duplicated spell sources trip MGS1002. To work on a different workspace, pass--root <path>. Acdinto a SIBLING CHECKOUT of this repository - a linked worktree, or the main checkout reached from inside one - denies on the same ground, recognized by reading the shared git directory rather than by the path's name: that tree's./maguswas linked from ITS sources and its cache is keyed to ITS tree, so the verdict describes neither checkout. That reason outranks the generalcddeny below, because "do not cd" understates aiming at another tree of this repository. -
cd, including a barecd <dir>,cd <dir> && ..., a subshell(cd ... && ...), andbash -c 'cd ...'. Magus takes the project as an argument; a host shell tool that needs a different directory for one call has a working_directory (or cwd) field. A different workspace is--root <path>, not acd. The advise that used to cover onlycd <dir> && magus ...was tuned out: agents kept prefixingcdon every call, which relocates later commands on the line and re-fires shell chpwd hooks. -
Workspace-declared shell rules (
magus\guard.shell({...})in the root magusfile): additive deny or advise entries matched against the same parsed invocations the built-ins see (programplus optional arg subset). They strengthen only - a built-in deny always wins; a workspace deny may escalate a built-in advise or a pass; a workspace advise fills silence only. They cannot disable a compiled rule. Rule ids are recorded asworkspace:<name>on the verdict. Declared at magusfile load time, never mid-session, and deliberately notmagus.yaml(runtime knobs) and not a host harness (those are harness spells selected withmagus\harness.provider; several hosts are fine when you bounce between tools). Optionaldialectselects the mvdan/sh parser variant for outer parse when judging rules; the last declared non-empty dialect wins.magus\guard.bashremains as a deprecated alias that defaults dialect to bash. -
A workspace spawn rule (
magus\guard.spawn(fun)in the root magusfile): one Buzz function called on every subagent spawn and continuation with a normalized request, answering allow, advise or deny. Strengthen only, like the shell rules. magus ships none; see magus\guard.spawn. -
A workspace command rule (
magus\guard.command(fun)in the root magusfile): one Buzz function called on every shell command the rules above let through, with the parsed programs it runs, answering allow, advise or deny. The function form ofmagus\guard.shell, for a rule a program-and-args match cannot express. magus ships none; magus\guard.command walks through writing a repository policy.magus\guard.write(fun)is the same seam for the files an agent writes through its host's edit tools.
| dialect | parser |
|---|---|
posix |
POSIX shell |
bash |
GNU Bash (default) |
mksh |
MirBSD Korn shell |
zsh |
Z shell |
bats |
Bash Automated Testing System |
Example:
magus\guard.shell({
name: "no-curl-prod",
decision: "deny",
program: "curl",
args: ["https://prod.example/health"],
reason: "Do not hit prod from an agent shell; use staging or a magus job.",
})
magus\guard.shell({
name: "prefer-terraform-target",
decision: "advise",
program: "terraform",
reason: "Prefer the workspace terraform target: magus run plan <project>.",
})
import "ghcr.io/egladman/magus/spells/cursor";
import "ghcr.io/egladman/magus/spells/codex";
import "ghcr.io/egladman/magus/spells/claude-code" as claude;
import "ghcr.io/egladman/magus/spells/opencode";
magus\harness.provider(cursor)
magus\harness.provider(codex)
magus\harness.provider(claude)
magus\harness.provider(opencode)
Then magus agent harness apply (no --id) writes every wired host's fragments.
-
Writing into the workspace's magus cache dir (
.magus/by default), on either surface and under every role, unbound sessions included. That directory holds the files the guard's own verdicts are computed from: theleasemarker naming which lease this checkout is bound to, the fire-once advisory markers, the touched-project set, and the served-next journal whose entries pre-authorize commands, plus the activity trail, the run logs, the outputs and the locks. An agent that edits any of it rewrites the evidence it is graded by, and no later verdict says so, which is why this one is not scoped to a boundary: what it protects is whether a boundary was checked at all. It ranks above the lease rules, so a worker whosewrite_pathshappen to cover the directory reads what the directory IS rather than a verdict about whose it is. The path surface catches an editor tool's write; the command surface catches a redirect (>,>>,tee) and the coreutils that take a path as an operand (rm,mv,cp,mkdir,touch,truncate,chmod,sed -i). The reason names the verbs instead:magus job exec <id>to take a lease,magus cleanfor the outputs,magus query output <ref>for a captured log. Reading is untouched, socaton a log passes. A cache dir relocated byMAGUS_CACHE_DIRorcache.diris matched at its resolved location, and the literal.magus/name is matched as well, so a command that spells it is refused even where the resolution is unavailable. magus's own commands are not judged here: everymagus runwrites in that directory, and the rule reads redirect targets and coreutil operands, never amagusargv. -
Rewriting your own job row, while holding a lease: taking another job's lease with
magus job exec <other-id>, verifying a result withmagus job wait, and themagus_jobtool's row writes, whichever channel they arrive on. Every lease-scoped rule below reads that row, so an agent that can rewrite it grades itself against a boundary nobody handed it from the next call on. A session holding no lease is untouched entirely, whether that is an orchestrator or a person in their own checkout, because those are the parties that write rows.Over MCP the operations divide the same way. A write naming a row other than the one this checkout holds is refused, and so is a write to the holder's own row, with one exception: dropping declarations the row already carries, which is how a holder releases a path, passes through to the store. Giving a path back cannot widen a role, and whether a particular shrink is legitimate is the store's judgment rather than the guard's. Recording the base a lease landed on passes, because it is a procedure the write surface demands. Reading is untouched everywhere: the tool's list op,
magus job execwith no argument, andmagus ls jobs.The MCP form is judged because it is the same write through a different transport; a rule holding on one channel would move the traffic rather than stop it. magus reads its OWN tool name out of whatever the host prefixed it with, and the parameters out of its own tool schema, so no host's vocabulary enters the rule. The call is normalized to a command line before any rule sees it, so what the activity trail records is what was graded.
What magus explains
An advise verdict carries context your host injects, and all four documented hosts
now deliver it to the model. What differs is WHEN. Claude Code and Codex attach it
to the call that is about to run, as additionalContext on the same pre-tool
event that carries a deny. Cursor and OpenCode have no message channel on a
gating event, so the explanation rides the post-tool event instead and lands with
the call's result. Codex used to be sent nothing at all, on a reading of its hook
contract its own current reference contradicts; the keys that make Codex mark a
hook run failed are continue, stopReason and suppressOutput.
-
git commitandgit add <paths>: classify the dirty tree first. Deliberate staging is the replacement the rule above points at, so it is never denied. -
A path-scoped
git checkout -- <paths>orgit restore: regenerated output is a declared target output, and reverting it because you did not hand-edit it is what makes CI fail on drift. -
A repo-wide text search (
grep -r,rg,find -name): the graph answers structural questions from declared sources -magus refsfor a code symbol,magus queryfor a domain entity. -
A dependency re-resolution (
go get,pnpm add,cargo update,uv lock,pip-compile): theupdatecharm is what grants that write inside magus, and it is deliberately not part ofrw-rwcovers output reproducible from a clean checkout,updatecovers state that depends on what a registry or a vulnerability feed serves today. Applying a lockfile (npm ci,pnpm install --frozen-lockfile) re-resolves nothing and passes.go mod tidyis the one that denies rather than advises, because a spell op renders it, and its deny reason carries the sameupdateroute - routing into magus without naming the charm would send you to a target that refuses the write. -
A tree-identity read (
git rev-parse HEAD,git describe,git stash create):magus vcs checkpointprints the revision plus a digest of the uncommitted patch, which identifies a dirty tree where the revision alone cannot, and records it on the activity trail. The layout questionsgit rev-parsealso answers (--show-toplevel,--git-dir,--abbrev-ref) pass, because a checkpoint does not replace them. -
time magus ...,timeout 5m magus ..., andmagus ... && echo done: magus already reports each target's duration and verdict, already takes--timeout, and already reports success through its exit status. -
A chained
magus run- a secondrunoraffectedafter any of;,&&or||: targets compose throughctx.needs, so running the LAST one usually pulls the rest in, and each extra invocation reloads the workspace. Only the dependency graph knows whether the two are genuinely independent, which is why this advises rather than denies. -
The
cigate, under a lease whose ledger row declares a narrowervalidation: the gate runs once per branch, in the orchestrator's tree, after every unit lands, and the deny hands back the narrow target this worker was assigned. A caller naming no lease, a lease with no live row, a row that declared no validation, and a row whose validation namesciare all unaffected. See what the guard enforces under a lease.
Everything else about the command itself passes. Two rules then read state outside the command line, and speak only into the silence the rules above leave:
- The CI gate, when this workspace's run log shows it has already run several times in the last two hours at real cost: it runs everything the diff reaches by construction, and it is the target worth saving for the end. The rule reads the command, so running a narrower target draws silence rather than an advisory arguing with what it just asked for.
- A binary older than the guard rules in the tree: appended to every verdict, including a deny, because a stale binary's verdicts are all suspect rather than only the ones that matched.
- A graph read (
magus refs,query,explain,path) against a symbol index older than the sources it describes. The command's own output says the same thing under the answer, which is the half that works on every host with nothing wired; this one arrives a call earlier.
Focus: the read boundary
A command that READS a path outside the project a session is working in draws a
focus advisory. The focus of a session is the project holding its working
directory, everything that project declares depends_on transitively, any
project nested inside it, and the files directly at the workspace root plus
.claude/skills: the declaration, the config, and the instructions every
project resolves through, whatever the cwd. Not the siblings, and not the
projects that depend on it: magus affected runs that direction, from a change
outward to what it could break, and focus runs the other one, from where you
stand back to what you legitimately need.
It is the read half of a boundary whose write half is a job's write_paths,
and the two catch different failures. A write outside your write paths collides with
another agent, and the diff eventually shows it. A read outside it collides with
nothing and leaves no trace: it spends tokens on a tree nobody asked about, and
it carries a sibling's practices and code quality into work that never chose
them. Nothing downstream can tell that happened.
The rule reads the operands of the commands that read a file or search a tree
(cat, head, tail, sed, wc, grep, rg, find, and their neighbors),
and says nothing about anything else: a rule that fired on an interpreter or a
build tool would be guessing at what the program does with its arguments. A
pattern is not a path, so grep's first operand is skipped; an operand that
resolves outside the workspace is a different rule's business; a path no project
owns has no boundary it could be outside of.
It ADVISES by default and DENIES only under a lease this checkout holds
(magus job exec <id>), because a hard read boundary needs somebody to have
declared one. The job's read_paths names the paths whose projects it may read; it
falls back to write_paths, since a worker leased to edit a project was pointed
at that project. Widening is that field and nothing else. There is no
environment variable that turns the rule off, because a variable would be set
once, in a wrapper, by the first worker it inconvenienced, and nothing afterwards
would say the boundary had stopped being checked.
magus describe file <path> answers the same question before a read rather than
after one: each entry carries focus: out when it falls outside, and the report
names the focus it judged against.
Advisories are said once
The advisories that carry a standing fact rather than a correction to the command in front of you are held to one firing per session: the graph-beats-grep hint, the classify-before-staging reminder, the index-staleness advisory, the enroll-a-lease notice an unleased write draws, the host-wiring notice, the terminal-lease notice, and the repository-scoped path rules above. The focus advisory is held twice over: once per PATH, because a second out-of-focus file is a second fact, and once per session for the full explanation, so every firing after the first is one line. The second identical paragraph teaches nothing, and this page's standard says why that matters - a check that is red by default is a check people learn to ignore, taking the real failures with it.
Denials are never silenced. A refusal explains itself every time it refuses; it
is the one verdict the caller cannot see past. What changes is the length: the
first time a rule denies in a session you get its whole reason, then
nothing ran (N commands) when the line held several, then see: and the
rule's page. Each later deny from that rule is one line naming the rule and what
it catches, the same nothing ran line, and a ref to the full verdict:
denied again [whole-tree]: a whole-tree VCS reset, checkout, restore or clean, which cannot be undone
nothing ran (2 commands)
full verdict: magus query output grd1a2b3c4d5e6f7a8b
see: https://eli.gladman.cc/magus/reference/rules/whole-tree/
The ref is stored in the activity trail under the grd prefix, and the line is
a breadcrumb with the id deny-verdict, so magus session hints counts how
often it is read.
The advisories that correct the command itself - a cd before magus, a time
wrapper, a chained run - are exempt too, because a second firing reports a
second mistake.
A session is identified by the session_id your host reports, on the flag or in
the envelope. A host that reports none is not silenced forever: those notices
expire on a two-hour clock instead, so the next session is told again. The state
is one empty marker file per session and kind under the cache directory, swept
after a week.
The file surface
printf '%s' '<file>' | magus session hook --path judges a file path rather
than a command. --path is a switch and takes no value: the path arrives on
stdin exactly as a command does. Three of its rules are definitive rather
than heuristic, because each reads DECLARATIONS: the
generated-output rule classifies the path against every target's declared
outputs, the notes rule against the declared notes store, and the lease rule
against what concurrent leases declared they own (see
leases). The first advises, because a hand-edited generated
file is wasteful rather than destructive; the other two deny, on the provenance
trigger and on a collision no later rule can outrank.
The lease rule denies only what a DECLARATION settles - an enrolled lease writing
onto another live lease's owned paths, onto its own forbidden paths, outside
every entry in its own owned paths, at all when its row is read_only, or at all
before it has registered the base it landed on. An empty owned list on a row that
is not read_only is a boundary nobody wrote rather than one of size zero, and
scopes nothing. Four cases the rule cannot decide that way advise instead:
- The ledger exists but will not parse. It says no boundary was checked rather than blocking on a file it cannot read, because a lease whose boundary silently stopped being checked looks exactly like one nobody declared.
- A write onto another live lease's owned paths by a writer magus cannot attribute to a live lease - naming none, naming an id it cannot parse, or naming a valid id with no live row. That is the same collision the enrolled case denies, and it advises because magus cannot tell "not in the fleet" from "in it and not saying so", and blocking a person in their own checkout is the worse of the two ways to be wrong. It also records the write against the owner, so the lease whose file just moved can find out by asking the ledger.
- An id that is not a valid lease id (at most 128 characters of
A-Za-z0-9-_./:). The call is graded as if it named no lease and told so, rather than rejected: an id magus cannot parse is one it cannot look up either, and erroring would block a tool call over metadata. - A lease whose registered base is not the checkpoint it was handed. An orchestrator may have rebased the plan deliberately, which magus cannot tell from a worker that wandered; what it can do is keep the divergence from staying silent until the merge finds it.
Every one of those denials names the ACTOR who can move the boundary, and it is
never the reader: "your orchestrator can widen these write paths; you cannot. Report it as
an unresolved risk and stop." The texts they replace ended by naming the
magus_job tool, meaning "ask the orchestrator", and two independent readers
took it as permission and widened their own row with it.
A fourth rule DENIES and is not about the boundary at all: a write to a harness descriptor's own guard wiring. The descriptor declares the configuration path that switches its guard on for the host's next session, so a bound lease cannot edit that path regardless of what its write paths say. An unbound session gets a once-per-session advisory instead, because rewiring a host is exactly what an orchestrator or a person does, and what they are owed is the sentence saying which descriptor owns the file. The failure is silent either way, since a disarmed guard and a clean session produce identical output, which is the whole reason the rule exists.
The rest are heuristics on the path, and each only fills a silence the
definitive rules leave: a cross-host instruction file (AGENTS.md, CLAUDE.md)
is where a workspace decision goes to be invisible to the next checkout, an
installed skill is generated and the next --force install erases the edit, and
a new source directory is a structural choice worth making deliberately rather
than by where a file happened to land.
Two more fire only inside magus's own checkout, identified by the markers only
its sources carry, and are inert in every other workspace: a
write to a shipped skill body or the MCP tool registry routes through the
authoring method those files are maintained by, and a write to a generator input
(a .proto, a Buzz host module descriptor) says to regenerate in the same
commit. Both name paths and a target that belong to this repository, which a
shipped verdict may not otherwise do; the gate is what makes them legitimate,
because outside this repository neither can fire at all.
One rule reads the environment rather than the path. A process carrying spawn ancestry that writes while naming no lease, in a workspace whose ledger holds no live row, is told how to enroll. The ancestry is a claim any local process can set, so it may teach and may not judge: it can only ever turn silence into an advisory, never deny, and never change what another rule decided.
All of them say nothing on any uncertainty. A rule fired on a guess trains the reader to ignore it, and a deny fired on a guess blocks real work.
Wire this to your host's file-editing tool, not its shell tool.
The verdict contract
The input arrives however your host can produce it: as raw text on stdin, or as
the host's own JSON event. magus reads tool_input.command,
tool_input.file_path, session_id and hook_event_name out of an envelope
directly, so a host that writes one needs neither jq nor --path - a payload
carrying a file path is judged as a write.
The verdict leaves through the standard output arm: -o json for a
schema-versioned envelope, -o yaml, -o name for the bare decision word, or
-o template=<go-template> to render your host's response dialect. Bare
-o template lists the fields.
printf '%s' 'git stash' | magus session hook -o json
printf '%s' 'go test ./...' | magus session hook -o name
printf '%s' 'MAGUS.md' | magus session hook --path -o name
A deny exits 2 with the verdict on stdout; a pass and an advise exit 0. An empty event passes, but one the hook cannot read fails closed as a deny: nothing was judged, so the call is blocked rather than cleared.
A fourth decision, ask, means only the person can clear the call. It also exits 2,
so a glue that reads nothing but the exit status still stops. magus returns it only to a
caller that passes --renders-ask, the claim that its reply puts the call in front of
the person or refuses it. Every other caller gets deny with a reason saying the hook
predates approval prompts, because a glue older than the decision renders an ask as
nothing and its host reads nothing as allow. The shipped templates and the OpenCode
plugin pass the flag; a HOST_RESPONSE you wrote yourself does not. Today the push gate
is its one source: a push at a commit no passing gate covers, from a session no job
lease binds, gets ask, and the reason names the commit and the gate state. The gate
covers every backend magus drives: git push, hg push, sl push (with or without
--to) and jj git push, relocated with git -C, hg -R, sl -R or jj -R too. It
matches the gate to the revision by content hash (git and Mercurial nodes, jj commit
ids); a revision it cannot match that way, such as a Mercurial local revision number or
a jj change id, makes it stand down rather than guess. The
host's own approval prompt shows it, and approving publishes the commit. Claude
Code and Cursor prompt from the hook. Codex and OpenCode hooks cannot, so their
harnesses write a native prompt rule (.codex/rules/magus.rules, and
permission.bash in opencode.json) and the hook answers that prompt: allow for a
push a gate covers, ask for an ungated one. Where no prompt can happen, those two
refuse the push and name the person's own terminal. A session bound to a job lease
always gets deny, because workers do not publish. Nothing the agent types clears
an ask, and every shipped template refuses a decision it does not know rather
than allowing it.
Every verdict carries lease: the ledger row it was graded under, absent when the
call named none. --lease <id> sets it and magus.lease in $BAGGAGE is the
default, so a shell that exported one for a whole session is still overridable per
call. The field answers a question nothing else on the wire did, which
declaration decided this, and two of the three ways to get it wrong used to look
identical to a guarded session:
- An id this workspace's ledger does not DECLARE is an error verdict: decision
deny, exit 2, naming the id. Every lease-scoped rule reads that row, so a typo'd id was graded by nothing at all while the session believed itself inside a boundary. An id that will not PARSE is the other case and stays an advisory: magus cannot look it up either, and blocking a tool call over unreadable metadata is the failure the fail-open contract is written against. - An id naming a row in a terminal state (
pass,fail,no_return) prints one notice per session saying its rules are inert. That is true and was invisible: the lease-scoped rules only ever read live rows.
Commands magus suggested
A result's next breadcrumbs are complete commands magus computed, and a command
this session was served is PRE-AUTHORIZED: no advisory fires on it, and the
role-scoped rules (the gate, version control, the rebind rule, the focus deny)
stand down. Refusing magus's own suggestion is the tool disagreeing with itself in
front of a reader who has no way to tell which half to believe.
The workspace-wide denies never yield: whole-tree VCS, a pipe or redirect of magus's own output, a raw language tool, a relocated checkout. Those protect everyone rather than a role.
The clearance is narrow by construction. It covers one command per line, matched
argv for argv against what was served, with only the binary's spelling normalized
(./magus, magus, an absolute path from a hook template are one command). It
expires after twenty further servings, it is per session, and a journal that is
not there clears nothing. The journal is one JSON line per served breadcrumb
({"ts":<unix ms>,"id":"<breadcrumb id>","argv":[...]}) under the cache
directory, beside the advisory markers and keyed by the same hashed session id, so
a person asking why a call passed can read what was served. A producer with no
session id writes to the anonymous journal instead, which a CLI run always is, and
the guard reads both. The guard's activity event records which breadcrumb
cleared the call, so uptake per breadcrumb stays a query rather than a guess.
The obligation this creates sits UPSTREAM, not here: next is computed for the
acting role, and a test grades every breadcrumb the tree can serve through the
guard under each role, so a suggestion magus would refuse cannot ship.
A host integration is therefore a few lines of configuration you own, with no host-specific code in magus.
Not a security boundary
It reads a command string and returns an opinion. That catches a habit and does
nothing against intent. TestGuardKnownHoles records what it misses: a command
inside a script file, a program name from $(...) or a variable, a shell alias,
an adapter behind make.
You own the hook script and its response template. Edit them so denials stop
arriving, and you have configured your tool, the same way you can turn off every
rule in .eslintrc.
magus affected ci is the gate that holds. It is committed, it passes through
review, and no local config edit changes what it runs.
Ask where a config came from rather than who can edit it. One you wrote is
yours. One that arrived in a cloned repository is a stranger's code your host
may run - the same standing risk as that repo's Makefile or git hooks, and
older than agents. Read it before you run it.
What magus records
Every magus session hook invocation with a readable command or path appends one
agent_command event to the local Activity Trail. This is product telemetry for
improving agent support: which host tool an agent selected, whether it reached a
magus surface or a raw command, and which guidance would move that workflow onto
magus. It is not a security feature and never an execution gate - recording is
best effort, local, and cannot change a verdict.
The hook writes a normalized request and response as content-addressed blobs rather than the opaque host event. The request is schema-versioned and carries only the stable fields:
{
"schema_version": 1,
"host": "claude-code",
"session": "abc123",
"event": "PreToolUse",
"tool": "Bash",
"command": "magus run test ."
}
For a file-edit hook, path replaces command. The response carries the same
schema version plus decision and, where applicable, reason, context, or a
stable denial rule identifier.
host and session also sit on the event row itself, not only in the blob, so
a view can group a page of observations without fetching a payload per row.
No local process can discover which agent host started it, so the wrapper passes
the name in with magus session hook --agent-name; a wrapper that does not leaves the
field empty rather than guessing. An MCP call has no wrapper to ask and is
attributed from its HTTP User-Agent instead.
agent_command means observed invocation, not successful execution. A pre-tool
hook runs before the host decides whether to call the tool, so an OUTCOME_OK
event means magus recorded and evaluated the observation - not that a shell
process started, exited zero, or ran at all. Direct MCP calls stay
mcp_tool_call events for that reason: their wrapper sees the actual result.
Events live at <cache-dir>/activity/events.jsonl with blobs under
<cache-dir>/activity/blobs, under the trail's existing bounded retention
(10,000 newest events, unreferenced blobs collected on rotate). magus keeps the
commands and paths themselves, because they are the evidence that shows where
adoption breaks down, so keep credentials out of a command line. Inspect them
through the authenticated Activity view. There is no network exporter, no
scoring system, and no instrumentation inside a magus target or a Buzz execution
path.
A host without a hook cannot be observed: no local CLI can discover commands another process did not report. The coverage boundary is explicit rather than guessed.
Recurring guard friction
The trail is evidence, not automatic self-modification. magus doctor's
recurring-guard-denials check is read-only. It deduplicates repeated stable
denial rules (three times in one host session, or the same pattern across two
sessions) and reports the facts: rule, surface, denial count, session count, and
followed rate. It proposes no destination; a human decides whether to discard
the pattern, adapt a local skill or host harness, or report an upstream issue.
magus doctor
Buzz harness spells (preferred)
A harness spell is selected by import. The shipped ones are not compiled into the
binary: they are published as OCI artifacts, imported by registry path, and pinned by
digest in magus.lock (see Remote spells), so
moving to a newer harness is a lock change, not an upgrade. To adapt one without
touching Magus source:
-
Copy
spells/harness/<id>/from the magus repository into the workspace, e.g.harness/<id>/. Keep the spell's stable host id (mgs_getName()). -
In
magus.yaml, replace the registry path with the copy. The import in the root magusfile andmagus\harness.provider(...)stay exactly as they are:spells: ghcr.io/egladman/magus/spells/<id>: path: harness/<id> -
Edit the workspace Buzz (matchers, managed fragments, guard command).
-
magus agent harness applythenmagus agent harness verify. Commit themagus.yamlchange and the forked spell together.
That is the ownership switch: Magus ships the default spell; the declaration in
magus.yaml chooses which tree apply reads, and it is the one line a reviewer sees.
See the magus-workspace-rules skill section "Adapting a Buzz harness".
A later magus run request in the same host session is shown as a follow-up,
not a success: pre-tool hooks cannot observe execution or an exit status. After
a person makes a durable decision, use the existing workspace-rules loop to
create a memory decision and, when appropriate, a stamped local skill. A host
harness update is not itself a memory decision. Never relax a compiled guard
locally. To strengthen one for THIS workspace, declare an additive
magus\guard.shell({...}) in the root magusfile (deny or advise matched on
parsed program + args) and commit it; that path cannot disable a built-in.
magus\guard.bash is deprecated; use guard.shell instead.
One payload shape is recorded and never judged. A hook event carrying a prompt
rather than a command or a file path is a lease handoff: it appends an
agent_spawn event and returns pass without evaluating a rule, because there
is no command and no path to judge, and a prompt that merely mentions a denied
command would otherwise block the lease that describes it. See
Leases. The spawn rules that do run read session state, and a
workspace's own magus\guard.spawn function
reads the normalized request; none of them runs the command rules over the prose.
Measuring adoption
The point of the grep-to-query nudge is to move a number: how often agents reach
for the knowledge graph versus a raw text search. magus agent adoption reports
it from shell commands: the graph-to-grep ratio, the file reads a
targeted read would beat, and the top repo-wide greps whose pattern is a real
identifier, each with the graph command its shape routes to (magus refs for a
symbol, magus query for a diagnostic code or a Buzz op).
magus analyzes commands; it never reads a host's session logs, so extraction is
yours. For Claude Code, whose sessions are JSONL under ~/.claude/projects/:
cat ~/.claude/projects/*/*.jsonl \
| jq -r '.message.content[]? | select(.type=="tool_use" and .name=="Bash")
| (.input.command | split("\n")[0])' \
| magus agent adoption
A 1:20 ratio means the graph is barely used. The levers that move it are an
easier query grammar to reach for (kind=x, id=~re) and the advisory that
translates a caught grep into the graph command its pattern shape routes to.