Table of Contents generated with DocToc
agent-guard
Capability: substrate:action-guard
Harness: Claude Code, OpenCode
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.
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 --project tools/agent-guard 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.
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: |
AGENTS.md: agents use a Generated-by: trailer, never co-author |
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 |
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
The guard is registered as a PreToolUse hook on the Bash matcher in
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-guard.py\"", "timeout": 30 }
]
}
]
}
}
/magpie-setup ships agent_guard/__init__.py as a single self-contained file
into the adopter tree (.claude/hooks/agent-guard.py) and into the user-scope
secure setup (~/.claude/scripts/agent-guard.py); /magpie-setup upgrade,
verify, and the setup-isolated-setup-install / …-update skills keep it and
the settings.json entry in sync. See those skills for the exact steps.
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.
Contributing guards
The hook is wired once. Beyond the two bundled guards, additional guards are
discovered at runtime from every *.py in a guards.d directory — the
guards.d sibling of the running script, plus any directory listed in
$MAGPIE_GUARD_DIRS (colon-separated). No settings.json change is needed to
add a guard.
A skill owns its guards by shipping them under skills/<skill>/guards/*.py;
/magpie-setup collects every skills/*/guards/*.py (plus the engine’s bundled
guards.d) into the adopter’s .claude/hooks/guards.d/ (and the user-scope
~/.claude/scripts/guards.d/). 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 --project tools/agent-guard pytest
Table-driven tests feed synthetic PreToolUse events to dispatch() and assert
allow vs. deny. The gh / git lookups the guards make are monkeypatched.