| <!-- SPDX-License-Identifier: Apache-2.0 |
| https://www.apache.org/licenses/LICENSE-2.0 --> |
| |
| <!-- START doctoc generated TOC please keep comment here to allow auto update --> |
| <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> |
| **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* |
| |
| - [agent-guard](#agent-guard) |
| - [Prerequisites](#prerequisites) |
| - [Guards](#guards) |
| - [Per-command overrides](#per-command-overrides) |
| - [Wiring](#wiring) |
| - [OpenCode](#opencode) |
| - [Kiro CLI](#kiro-cli) |
| - [Harness-neutral path (any runtime)](#harness-neutral-path-any-runtime) |
| - [Contributing guards](#contributing-guards) |
| - [Tests](#tests) |
| |
| <!-- END doctoc generated TOC please keep comment here to allow auto update --> |
| |
| <!-- SPDX-License-Identifier: Apache-2.0 |
| https://www.apache.org/licenses/LICENSE-2.0 --> |
| |
| # agent-guard |
| |
| **Capability:** substrate:action-guard |
| |
| **Harness:** Claude Code, OpenCode, Kiro |
| |
| 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 [`PreToolUse`](https://code.claude.com/docs/en/hooks) |
| hook on the `Bash` matcher (the default, no-argument invocation). |
| - **OpenCode** — a [plugin](https://opencode.ai/docs/plugins/) on the |
| `tool.execute.before` hook for the `bash` tool, which blocks a call by |
| throwing (`agent-guard.py --opencode`). See [Wiring](#wiring). |
| - **Kiro CLI** — a [`preToolUse`](https://kiro.dev/docs/cli/hooks) hook on the |
| `execute_bash` matcher, which blocks a call when the hook exits `2` |
| (`agent-guard.py --kiro`, reason on stderr). See [Wiring](#wiring). |
| - **Any other runtime** — the `--check` and `--exec` CLI modes let any |
| harness or shell wrapper enforce guard rules without a harness-specific hook |
| adapter. See [Harness-neutral path (any runtime)](#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 via `uv`, so it needs no built/installed environment. The test suite runs under `uv run --project tools/agent-guard pytest`. |
| - **CLIs:** `git` and `gh` — the guards shell out (via `ctx.run`) to inspect commits, branch state, and GitHub Actions runs. None otherwise. |
| - **Credentials / auth:** None. The guards read local `git` / `gh` state; `gh` must be on `PATH` for the `mark-ready` guard's Actions lookup. |
| - **Network:** None in the hot path; the `mark-ready` guard reaches `api.github.com` (via `gh`) 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](#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: |
| |
| ```bash |
| 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`: |
| |
| ```json |
| { |
| "hooks": { |
| "PreToolUse": [ |
| { |
| "matcher": "Bash", |
| "hooks": [ |
| { "type": "command", "command": "python3 -c \"import os,sys,subprocess; p=os.path.join(os.environ.get('CLAUDE_PROJECT_DIR',''),'.claude','hooks','agent-guard.py'); sys.exit(subprocess.call([sys.executable,p]) if os.path.isfile(p) else 0)\"", "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/plugin.js). OpenCode aborts a tool call whose |
| [`tool.execute.before`](https://opencode.ai/docs/plugins/) 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): |
| |
| ```bash |
| 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](https://kiro.dev/docs/cli/hooks) 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](https://kiro.dev/docs/cli/custom-agents/configuration-reference#hooks-field)): |
| |
| ```json |
| { |
| "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`](../../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. |
| |
| ### Harness-neutral path (any runtime) |
| |
| For runtimes that do not expose a pre-tool hook API (Codex CLI, Gemini CLI, |
| Cursor, Kiro, or any other harness not yet wired above), 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: |
| |
| ```bash |
| 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: |
| |
| ```bash |
| # 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 `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, or `None` to allow. |
| `ctx` is the `GuardContext`: `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 |
| |
| ```bash |
| 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. |