magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
12 min read

magus\guard.command

magus\guard.command is the function form of magus\guard.shell, for a rule a program and an argument list cannot express. The root magusfile registers one function; the agent guard calls it on every shell command an agent is about to run, after its built-in rules, hands it the parsed command, and adds its answer to the built-in verdict.

magus ships no command rule. How your repository waits on CI, which tools an agent may reach for, and what it should be told instead are your decisions. The seam exists so they live in your tree, in Buzz, versioned with the code they govern.

import "magus";

magus\guard.command(fun (req: CommandRequest) > GuardVerdict {
    foreach (c in req.commands) {
        if (c.program == "curl" and c.args.indexOf("https://prod.example") != null) {
            return magus\guard.deny("Do not hit prod from an agent shell; use staging.");
        }
    }
    return magus\guard.allow();
});

The request

field what it is
host the host the hook wiring named itself as
session the calling session's id
command the shell line as the host sent it
description the label the caller wrote for the command; empty when its host has none
commands the programs the line runs (below); empty when the line does not parse
parent the description the calling subagent was itself spawned with; empty for a root caller
role worker when a lease binds the calling session in this checkout, else root
lease the bound job row for a worker, null for root
checkout for a line that runs git push only: the checkout's state (below); null otherwise
dir the directory the line runs in, as the host reported it; empty when it reported none
workspace the root of the workspace holding dir, empty when none does

checkout is read in the directory the push runs in (after any -C), through the version control that resolves there, and only for a push, since it costs processes: branch is the branch the checkout is on, empty when it is on none (a detached HEAD), and remoteBranches lists the remotes' branches as <remote>/<branch> as of the last fetch, so a branch never fetched is absent. It is null when that version control cannot report it, which a rule should read as unknown.

Each entry of commands:

field what it is
program the program's base name
args its arguments as the shell would pass them; a variable or substitution renders empty
repeats true inside a while or until loop, which reruns it until a condition changes; not for
path the program's file when the line names it by a path, made absolute against dir (below)

commands is what the built-in rules read: the line is parsed rather than matched, and wrappers (env, timeout, nohup, sh -c, eval, ...) are peeled to the program they run, so GH_TOKEN=x timeout 60 gh pr checks 1 arrives as one gh entry. Match on it rather than on command: a pattern over the raw line cannot tell a pipe from one inside quotes.

path is empty for a program found on PATH, and whenever the file is not known before the line runs: a word holding a variable or substitution, a relative path after a cd on the same line, a program reparsed out of a sh -c payload, or no dir to resolve it against. Symlinks are left as spelled.

magus\guard.once(key), magus\guard.count(key) and magus\job.list() work here as they do in a spawn rule, and share its store: a key means one thing to the whole policy.

magus\guard.binary() describes the magus answering the hook: path, the running executable with symlinks resolved, and stamp, the text its build passed through the linker (-X github.com/egladman/magus/internal/interp/bindings.buildStamp=<text>), empty when it passed none. magus never reads the stamp; a build and a rule agree on its format, which is how a rule can tell whether the binary judging it was built from the sources in front of it.

Strengthen only, fail open, tighten live

The rule is asked only about a command the built-in rules passed or advised on. Its deny blocks the command, its advise reaches the agent where no built-in advice already spoke and is added to one that did, and nothing it returns lifts a built-in deny. On a command magus itself served as a next, its advice stands down and its deny still holds.

A rule that raises or returns something other than a verdict judges nothing: the built-ins apply alone and the agent is told once per session that the workspace rule failed. Registering twice, from a project's magusfile, or with a non-function stops the workspace load with MGS1045.

A hook judgment answers within seven seconds of the hook starting, inside the host's own hook timeout. Loading the rules, resolving the committed rule, calling it, and calling the working tree's rule share that deadline: each phase gets its share of what is left, so one that answers early leaves its time to the ones after it. A phase that runs out denies a command that changes state, since letting it through would run it unjudged, and lets a read through with a note naming the phase and its budget. A check with no host waiting on it, such as magus job fork grading a check before the worker exists, gets two minutes instead, and one that runs out is denied as not graded.

A write to a file the guard loads, such as the root magusfile, goes through with a note when its judgment runs out of time and no policy change is pending, or the committed rule answered: refusing it would leave a slow committed rule with no way to be fixed. With an uncommitted policy change and no answer from the committed rule, it is denied like any other write.

A hook the host stops waiting for leaves a mark, made before the rules load, and the session's next judgment reports how many of its checks never finished (guard-unfinished). The host stopped waiting when the hook process it started is gone, or when the judgment ran past the hook timeout the host's harness declares in mgs_getLimits. A host whose harness declares none is not taken to have given up on a slow judgment. A workspace cannot set that notice.

When a file the root magusfile's load read differs from the checked-out commit, the rule runs from both the working tree and the committed sources, files it imports included, and the stricter answer stands, as for magus\guard.spawn. An agent's edit can tighten the policy that grades it and cannot loosen it.

Naming a rule

deny and advise take an optional {rule: str} record naming the rule that answered:

return magus\guard.deny("Lead the description with its intent.", {"rule": "pull-request-text"});

A named verdict reports as workspace:pull-request-text, the id a magus\guard.shell rule carries, so the activity trail, magus doctor and a reader tell it apart from every other workspace rule. An unnamed one reports as workspace:command, workspace:write or workspace:spawn. A named command or write deny stores its full text under a grd ref, as a built-in deny does, and a repeat in the same session is one line: denied again [workspace:pull-request-text]: and the reason's first line, with the ref. Unlike a built-in rule, a workspace rule has no generated page, so neither form links to one. The name is kebab-case and is none of command, write or spawn; any other name raises MGS1045 where the rule calls deny or advise, so that rule judges nothing. When the approved and the working-tree rule answer the same decision, the merged verdict keeps the first named rule.

When the magus judging the call cannot load the tree

Failing open assumes the rule could have run. When the magus answering the hook cannot load the workspace at all, no rule in the magusfile was asked, so a pass from the built-ins says little. If it is older than the tree (the magusfile calls a name that build predates) or the checkout holds no ./magus, and the magusfile visibly registers a guard rule, the guard denies every call that changes state: file edits, subagent spawns, pushes, the magus verbs that write shared state, shell commands it cannot show are read-only, and the magus MCP tools that write (client, and diff with an op other than state or thread). Reads still run, as do git status and the fix itself, so the session can repair what denied it. A read-only scout's lease may still run magus job exec, magus job exit and magus buzz --record. Getting the tree back to one a binary can be built from still runs too, each command alone on its line: git cherry-pick, git merge, git rebase or git revert with --abort or --continue, and git worktree add; the checkout's integrator may also rebuild. While a magusfile import names a file that is not there (BZZ2001), putting that file back runs with an advisory instead of a deny: git mv onto it, git restore or git checkout -- of it, or a write to it; the bootstrap runs for that failure too. The deny is filed under the stale-binary rule: it prints the cause and the one likeliest command, and the fallbacks sit behind its full verdict: ref.

The remedy depends on who is asking:

  • The main session or a person rebuilds with ./magus run go-build .. Where that binary cannot load the tree either, or the checkout holds none, they bootstrap with the command the full verdict names.
  • A worker holding a lease never builds a binary. There is one per base: the main session builds it in the root and places a copy in the worker's checkout with hack/dev/bootstrap-worktree.buzz, so the deny tells the worker to ask for that.

A failure that is not a stale binary, such as a typo in a magusfile, still denies only the calls a previously loaded policy gated, under the policy-unloaded rule, and still names the failing line.

Two cases still fail open. A magus too old to run magus shell at all never produces a verdict, so the host's hook reports its own error and the call goes ahead; magus doctor and magus agent harness verify report that before a session starts. And no magus on PATH leaves nothing to answer.

What it costs

The guard reads its rules from the root magusfile alone, not the whole workspace, and the rules that need the workspace open it only when they apply. On a clean tree a rule adds one VCS status to a hook call; measured in this repository, a magus shell hook takes about 120ms with the rules registered, against about 1.6s when every call loaded the workspace. Only while a file the root load read is uncommitted does a call load the committed root magusfile as well, reading just the changed files through the VCS and the rest from disk: about 100ms more. The three-second limit on resolving the committed side, which denies when it runs out, therefore has an order of magnitude to spare.

File writes: magus\guard.write

magus\guard.write(fun (req: WriteRequest) > GuardVerdict) is the same seam for a file an agent writes through its host's edit tools, after the built-in path rules. It is strengthen only, fails open, and runs from both sides exactly as the command rule does.

field what it is
path the file as the host named it
workspace the root of the workspace holding path, empty when none does
content a whole-file write's new content; empty for an edit
oldText, newText an edit's replaced text and its replacement; empty for a whole-file write
host, session, parent, role, lease the caller, as on a command request

The texts are read from the host's payload by shape. An edit shape magus does not read leaves all three empty, which a rule should take as unknown rather than as an empty file.

Writing a repository policy

Keep the rules in their own file, import it from the root magusfile, and register one function per seam. magus's own repository does this in hack/policy/guard.buzz:

import "magus";
import "./hack/policy/guard" as agentpolicy;
magus\guard.command(agentpolicy\judge);
magus\guard.spawn(agentpolicy\judgeSpawn);
magus\guard.write(agentpolicy\judgeWrite);

It holds rules magus does not ship, because they are that repository's preference:

  • Deny waiting on CI. gh run watch, the --watch form of gh pr checks and gh run view, and a CI or merge-queue read that a while loop or watch repeats are refused with the one command that answers every open pull request.
  • Advise batching, once per session. Reading one pull request's checks is advised to ask for the whole board; reading the merge queue's runs or labels through GitHub is advised to ask magus queue ls.
  • Only an integrator gates. magus affected ci and magus run ci are denied to a subagent (one a lease binds, or one spawned with a title) unless its title's role is integrate. A person or the orchestrator, which no lease binds and no spawn titled, runs it freely, and anyone may run the --plan form, which runs nothing.
  • Name a new branch in full from a detached HEAD. git push <remote> HEAD:<name> is denied when HEAD is detached and the checkout has no <remote>/<name>, since git refuses that push; the reason names HEAD:refs/heads/<name>.
  • A code-changing worker gets its own checkout. A spawn titled <parent>/<role> <job> with a change role (feat, fix, refactor, perf, docs, test, chore) and no isolation is denied.
  • Each checkout runs its own binary. A magus named by a path that sits at the root of one checkout of magus, run against another, is denied, as is go -C into another checkout of magus for a verb its targets cover; the bootstrap link into a checkout with no binary yet passes. magus run against a workspace that is not a checkout of magus is magus used as a tool, and passes.
  • Say when ./magus is stale. go-build links a digest of each source the guard's verdicts and the workspace load come from into the binary; a command judged by a ./magus whose sources have since changed content, or by one linked without the stamp, is advised once per state to rebuild, with the bootstrap escape when the host declarations moved.

Three habits keep a policy like it maintainable:

  • Test the rule without a guard session. Each judge copies the request into local records and calls a decide function that takes the once-per-session gate and any file it would read as arguments, so in-file test blocks run under magus buzz -t --embedded. A test cannot build the host's request objects, so keep the logic on your own records.
  • Deny only what you can prove. A deny that misfires stops an agent doing its job; an advisory that misfires is noise. Key a deny on the parsed program and its operands, never on prose.
  • Say what to do instead. The reason is the agent's next command, so lead with it.
guardagentsshellcommandspolicymagusfilehooksstrengthen-only
Last updated (e321b3ff)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. Workspace in full.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. Project in full.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. Magusfile in full.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. Target in full.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). Op in full.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. Spell in full.

Buzz

The language magusfiles are written in (the .buzz engine). Buzz in full.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. Affected in full.

CI

An ordinary target you compose yourself with ctx.needs; magus does not hardcode its stages. It is the anchor magus affected ci keys off, and ci strips the rw charm. CI in full.

Agent

A model working through an agent host such as Claude Code, Codex, Cursor or OpenCode. magus treats an agent as one more caller of its CLI, MCP tools and console, held to the same checks as a person. Agent in full.

Tool

An action a model can call through its host: a name, a description, a typed input and a result. magus serves its tools over MCP, each a read of the workspace model or a run of the CLI it already has. Tool in full.

Harness

The deterministic scaffolding around the work on a change, for a person or an agent: compiler errors, formatters, linters, checks, gates, leases and hooks, placed at points a person chose. Around an agent's loop, magus supplies one and keeps it outside. Harness in full.

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it. Session in full.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the daemon, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. Job in full.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate. Run in full.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it. Lease in full.

Advisor

One read-only check from the advice suite: it reads the changeset through magus and writes one titled section of findings. The same advisors run as a pull request comment in CI and inside magus diff --impact locally. Advisor in full.

Conventions

Placeholders

Angle brackets mark a value you replace with your own; never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.