agent-guard
Capability: substrate:action-guard
Harness: Claude Code, OpenCode, Kiro, Gemini CLI
A deterministic pre-execution guard dispatcher. It inspects every shell command
before it runs and denies the ones that would break a hard framework
rule — protections that must not depend on the model remembering a SKILL.md
instruction.
The guard decisions live in one harness-agnostic core (dispatch()); a thin
adapter per harness translates that harness’s pre-tool hook to/from the core,
so every wired harness enforces an identical rule set from one source of truth:
- Claude Code — a
PreToolUsehook on theBashmatcher (the default, no-argument invocation). - OpenCode — a plugin on the
tool.execute.beforehook for thebashtool, which blocks a call by throwing (agent-guard.py --opencode). See Wiring. - Kiro CLI — a
preToolUsehook on theexecute_bashmatcher, which blocks a call when the hook exits2(agent-guard.py --kiro, reason on stderr). See Wiring. - Gemini CLI — a
BeforeToolhook onrun_shell_command, using--gemini(exit2, reason on stderr). The repository’s.gemini/settings.jsonwires this hook; snapshot adopters register it in their own settings. See Gemini CLI. - Any other runtime — the
--checkand--execCLI modes let any harness or shell wrapper enforce guard rules without a harness-specific hook adapter. See Harness-neutral path (any runtime).
It is stdlib-only and is invoked directly as
python3 <path>/agent_guard/__init__.py (never via uv run) so it returns in a
few milliseconds for any command that is not a guarded gh / git commit /
git push.
Prerequisites
- Runtime: Python stdlib only — the hook runs as
python3 .../agent_guard/__init__.py(3.11+), never viauv, so it needs no built/installed environment. The test suite runs underuv run --directory tools/agent-guard --group dev pytest. - CLIs:
gitandgh— the guards shell out (viactx.run) to inspect commits, branch state, and GitHub Actions runs. None otherwise. - Credentials / auth: None. The guards read local
git/ghstate;ghmust be onPATHfor themark-readyguard’s Actions lookup. - Network: None in the hot path; the
mark-readyguard reachesapi.github.com(viagh) when it checks for awaiting-approval Actions runs.
Why a hook, not a rule in a SKILL.md
A rule written in a SKILL.md is a sentence the model reads at the start of a
session and is asked to keep in mind — through forty tool calls, a
compaction, and an issue body that says something the model finds persuasive.
Most of the time it does. “Most of the time” is fine for a style preference and
useless for a rule whose violation posts to a public repository under the
operator’s name, because the cost is not evenly spread: one slip is a
notification to four maintainers who did not ask for it, or a CVE identifier
visible before the embargo lifts.
So these rules are not prose. They are Python, in a hook the harness calls
before the shell tool runs, and they get a veto. The guard sees the exact
command, decides, and either lets it through or refuses — a refused command is
not run: not posted, not retried, and not worked around by rephrasing. The
model is shown the reason and the deterministic fix (“use a backtick
`login` instead of @login”), so it corrects rather than guesses.
This is also the layer that does not care why a command was issued. A
prompt-injected instruction in an issue body and an honest mistake produce the
same gh pr comment, and the guard treats them identically — which is what you
want from something whose job is to be unpersuadable.
It is not a substitute for the sandbox and does not overlap with it. The
sandbox confines what a command can reach; the guard decides whether it
runs at all. A sandbox has nothing to say about a command that is entirely
within its rights, and a guard has nothing to say about a command reading
~/.ssh.
Guards
Bundled (shipped with the engine — universal git hygiene, on for every
project):
| Guard | Blocks | Rule it enforces |
|---|---|---|
commit-trailer |
git commit whose message contains Co-Authored-By:, unless the repository’s commit-attribution convention is co-authored-by |
AGENTS.md: agents use the project’s attribution trailer (default Generated-by:), not co-authorship |
empty-rebase |
git push --force[-with-lease] of a branch with 0 commits over its base |
an empty push to a PR head auto-closes it + revokes write |
Skill-owned (each lives in its skill’s guards/ dir, discovered the same
way — see Contributing guards):
| Guard | Owner skill | Blocks | Rule it enforces |
|---|---|---|---|
mention |
pr-management-triage |
gh pr comment / gh issue comment that @-mentions anyone other than the PR/issue author; any @-mention in gh pr edit --body[-file] |
denoise: author-directed feedback never pings maintainers; body edits stay silent. Exempt: the operator commenting on their own PR/issue (author == authenticated gh user), and the MAGPIE_ALLOW_MENTIONS=1 override |
mark-ready |
pr-management-triage |
adding ready for maintainer review while the PR head SHA has GitHub Actions runs awaiting approval |
Golden rule 1b |
security-language |
security-issue-fix |
a CVE id / security-fix language in a public gh pr create/gh pr edit title/body (not comments) |
public-PR scrubbing |
A denied command is not posted/run; the model is shown the reason and the
deterministic fix (e.g. “use a backtick `login` instead of @login”).
Per-command overrides
Each guard is overridable by a visible inline env assignment so a maintainer can consciously proceed:
MAGPIE_ALLOW_MENTIONS=1 gh pr comment 123 --body "@reviewer please take another look"
MAGPIE_ALLOW_COAUTHOR=1 git commit -m "…" # not for AI co-authorship
MAGPIE_ALLOW_MARK_READY=1 gh pr edit 123 --add-label "ready for maintainer review"
MAGPIE_ALLOW_SECURITY_LANG=1 gh pr create --title "…" # disclosure already public
MAGPIE_ALLOW_EMPTY_PUSH=1 git push --force …
MAGPIE_GUARD_OFF=1 <any command> # disable all guards once
MAGPIE_READY_LABEL overrides the label string the mark-ready guard watches
for (default ready for maintainer review).
Wiring
Install the magpie-agent-guard plugin:
/plugin marketplace add apache/magpie
/plugin install magpie-agent-guard@apache-magpie
That is the whole installation. The plugin’s manifest
(plugins/magpie-agent-guard/.claude-plugin/plugin.json)
registers the PreToolUse hook itself and resolves the engine under
${CLAUDE_PLUGIN_ROOT}, so the guard runs out of the installed plugin. No
file is copied into any repository, no settings.local.json entry is written,
and a git worktree needs no seeding — it is an ordinary checkout, and the
guard is active in it the moment the plugin is installed.
Skill-owned guards work the same way: the engine discovers every
skills/<skill>/guards in the framework tree it is running from, so a skill’s
guards take effect where they live and nothing collects or syncs them.
The guard is its own plugin rather than a hook on magpie or on each family
plugin because Claude Code merges hooks from every enabled plugin — wiring it
into the families would run it once per enabled family on every Bash call. One
dedicated owner runs it exactly once, whatever else is installed.
Snapshot adopters (/magpie-setup)
A project that vendors the framework as a snapshot rather than installing the
plugin wires the hook by hand, in the gitignored, per-machine
.claude/settings.local.json. Point it at the snapshot’s engine, not at a
per-repository copy:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.apache-magpie/tools/agent-guard/src/agent_guard/__init__.py\"", "timeout": 30 }
]
}
]
}
}
Every worktree already symlinks .apache-magpie/ to the main checkout’s
snapshot (worktree-init), so this path
resolves in a worktree without seeding anything else. /magpie-setup upgrade
refreshes the engine with the rest of the snapshot.
The user-scope secure setup installs the engine once at
~/.claude/scripts/agent-guard.py and wires it from ~/.claude/settings.json;
that path is worktree- and repository-independent by construction. See
setup-isolated-setup-install.
OpenCode
The same engine backs OpenCode via the plugin in
opencode/plugin.js. OpenCode aborts a tool call whose
tool.execute.before handler throws, so
the plugin forwards each bash command to agent-guard.py --opencode and
throws with the deny reason when the shared core denies it — the OpenCode
equivalent of a Claude PreToolUse deny.
Drop the plugin into OpenCode’s plugin directory (.opencode/plugin/ in the
project, or ~/.config/opencode/plugin/ globally):
mkdir -p .opencode/plugin
ln -s "<framework>/tools/agent-guard/opencode/plugin.js" .opencode/plugin/agent-guard.js
The plugin locates the engine at .claude/hooks/agent-guard.py under the
worktree by default — so a repo already wired for Claude Code needs no second
copy of the script — and honours MAGPIE_AGENT_GUARD=/abs/path/agent-guard.py
to point elsewhere. Because both harnesses call dispatch(), the bundled and
skill-contributed guards, the MAGPIE_* overrides, and the deny reasons are
byte-for-byte identical across the two; nothing about a guard is harness-aware.
Kiro CLI
The same engine backs Kiro CLI via its
preToolUse hook. Kiro pipes the hook event
({"tool_name": "execute_bash", "tool_input": {"command": …}, "cwd": …}) to
the hook command on stdin and blocks the tool call when that command exits
2, returning its stderr to the model — so agent-guard.py --kiro matches the
shell tool, runs the shared core, and on a deny writes the reason to stderr and
exits 2, the Kiro equivalent of a Claude PreToolUse deny.
Register it in the agent configuration’s hooks field
(reference):
{
"hooks": {
"preToolUse": [
{
"matcher": "execute_bash",
"command": "python3 \"${MAGPIE_AGENT_GUARD:-.claude/hooks/agent-guard.py}\" --kiro"
}
]
}
}
The engine is one shared, harness-agnostic file (--kiro only selects the I/O
adapter). On a repo already wired for Claude Code it lives at
.claude/hooks/agent-guard.py, so the hook reuses it; a Kiro-only adopter
(no .claude/) points MAGPIE_AGENT_GUARD at wherever the engine is installed.
Note: /magpie-setup currently installs the engine only on the Claude path — a
general installer that drops it for non-Claude-only adopters is a pending
follow-up (see docs/adapters/add-a-harness.md).
Because every harness calls dispatch(), the bundled and skill-contributed
guards, the MAGPIE_* overrides, and the deny reasons are identical to the
Claude Code and OpenCode paths — verified end-to-end: with this hook wired,
Kiro refuses a Co-Authored-By commit (quoting the commit-trailer reason and
leaving the commit uncreated) while a clean commit proceeds.
Gemini CLI
The repository’s .gemini/settings.json wires
the --gemini adapter to Gemini CLI’s BeforeTool event.
Start Gemini from the checkout root; the command uses GEMINI_PROJECT_DIR
to resolve the engine without a machine-specific path.
The setup-isolated-setup-install
skill registers the adapter in the adopter’s workspace .gemini/settings.json.
It resolves the existing extension, snapshot, or framework checkout and merges
one magpie-agent-guard entry while preserving unrelated hooks.
See the Gemini install lifecycle
for path selection, trust, and verification.
For manual registration, merge this entry into existing hooks and replace the example path with the resolved framework directory, quoted for the shell and encoded as JSON:
{
"hooks": {
"BeforeTool": [
{
"matcher": "^run_shell_command$",
"hooks": [
{
"name": "magpie-agent-guard",
"type": "command",
"command": "python3 \"/absolute/path/to/magpie/tools/agent-guard/src/agent_guard/__init__.py\" --gemini"
}
]
}
]
}
}
Gemini supplies tool_input.command and cwd on stdin.
The adapter calls dispatch() and returns exit 2 with the denial reason on
stderr; permitted commands return 0 silently.
The adapter tests cover CLI routing, preservation of core decisions, workspace-aware
Git checks, malformed input, and execution of the project settings’ hook command.
An offline probe of Gemini CLI 0.59.0 also loaded those settings through its
native settings loader and registered and executed the hook, recognizing its
denial even from a checkout path containing spaces.
This preserves the shared engine’s fail-open behavior for malformed input. A missing hook executable or interpreter can also leave the session running with a warning, so verify a known denial after installation. The hook covers shell commands; native file and MCP tools still need their own permissions, and shell execution still needs an OS sandbox. See the harness contract.
Harness-neutral path (any runtime)
For runtimes without a wired pre-tool hook adapter, the engine ships two CLI modes that allow enforcement without a harness-specific adapter:
--check <command…> — inspects the command and reports allow/deny without
executing it. Exits 0 on allow (silent), 2 on deny (reason on stdout), or
64 (usage) when no command is supplied — 64 is deliberately distinct from
the deny code so a caller testing $? -eq 2 never mistakes a misinvocation for
a policy block. Shell scripts and wrappers can inspect the exit code before
proceeding:
reason=$(python3 /path/to/agent-guard.py --check git push origin main)
if [ $? -eq 2 ]; then
echo "blocked: $reason" >&2
exit 1
fi
git push origin main
--exec <command…> — inspects the command then exec-replaces this process
with it on allow. On deny it prints the reason to stderr and exits 2. The
exec’d command’s own exit code and output are indistinguishable from a direct
invocation, making --exec suitable as a transparent wrapper:
# Shell alias in project .envrc / .bashrc. Safe: aliases are invisible to the
# execvp that --exec uses, so the bare name resolves to the real binary.
alias git='python3 /path/to/agent-guard.py --exec git'
alias gh='python3 /path/to/agent-guard.py --exec gh'
# Wrapper script named 'git' earlier on $PATH than the real one. It MUST exec
# the real git by ABSOLUTE path — passing the bare name 'git' would make --exec
# re-resolve it through $PATH, find this wrapper again, and loop. Adjust the
# path to your real git (`command -v git` with this wrapper off $PATH).
#!/usr/bin/env bash
exec python3 "${MAGPIE_AGENT_GUARD:-/path/to/agent-guard.py}" --exec /usr/bin/git "$@"
Both modes use the same dispatch() core as the Claude Code and OpenCode
adapters, so the guard decisions are identical regardless of which path you use.
Both are fail-open: a guard glitch never hard-blocks the user (and --exec
bounds any accidental wrapper recursion instead of looping forever).
Locate the engine at agent_guard/__init__.py inside the framework snapshot
(.apache-magpie/tools/agent-guard/src/agent_guard/__init__.py in an adopter
tree) or at the path /magpie-setup ships it to (.claude/hooks/agent-guard.py
for Claude Code setups — the file is the same and works for all three modes).
Contributing guards
The hook is wired once. Beyond the two bundled guards, additional guards are
discovered at runtime from every *.py in a discovered guard directory — any
directory listed in $MAGPIE_GUARD_DIRS (colon-separated), then every
skills/*/guards in the framework tree the engine runs from, then the
guards.d sibling of the running script. No settings.json change and no
install step is needed to add a guard.
A skill owns its guards by shipping them under skills/<skill>/guards/*.py.
The engine discovers them there — it resolves the framework tree it is running
from and scans every skills/*/guards in it, alongside its own bundled
guards.d. Nothing collects or copies them, so a new guard is live as soon as
the framework is. (A single self-contained copy of the engine, with no framework
tree above it, sees only its own guards.d and whatever $MAGPIE_GUARD_DIRS
names.) A guard file is import-free — it defines:
TRIGGERS— optional list of command families to pre-filter on ("gh","git:commit","git:push", …); omit to run on every guarded command.guard(ctx)— returns a deny-reason string to block, orNoneto allow.ctxis theGuardContext:ctx.argv,ctx.raw,ctx.override(*names),ctx.gh_subcommand(),ctx.opt(short, long),ctx.gh_body(...),ctx.mentions(text),ctx.positional_after(token),ctx.repo_flag(),ctx.run(args),ctx.ready_label.
A guard file that fails to import is skipped (a broken contribution never breaks
the shell). See guards.d/no_verify_commit.py for the template, and
skills/pr-management-triage/guards/ for real examples.
Tests
uv run --directory tools/agent-guard --group dev pytest
Table-driven tests feed synthetic PreToolUse events to dispatch() and assert
allow vs. deny. The gh / git lookups the guards make are monkeypatched.