From 28bcf22acd342fb0b1dedae4d5a6e6442ae318d4 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Mon, 6 Jul 2026 12:58:10 +1000 Subject: [PATCH 1/5] feat(agent-guard): add harness-neutral --check and --exec CLI modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any runtime without a harness-specific hook API can now enforce guard rules by wrapping command execution with the new entry points: - `--check ` — inspects the command; exits 0 (allow, silent) or 2 (deny, reason on stdout). Shell scripts can capture the reason and gate execution on the exit code. - `--exec ` — inspects then exec-replaces this process with the command on allow; exits 2 with the deny reason on stderr on deny. Suitable as a transparent PATH-shadowing wrapper or shell alias for `git` and `gh`. Both modes use the same `dispatch()` core as the Claude Code and OpenCode adapters, so guard decisions are identical across all paths. Also adds `__main__.py` so the package is runnable as `python -m agent_guard`, and documents the new enforcement path in the README under a "Harness-neutral path (any runtime)" wiring section. Validation: all 74 agent-guard tests pass; vendor-neutrality score remains 100% (agent-guard stays portable). Generated-by: Claude (claude-sonnet-4-6) --- tools/agent-guard/README.md | 55 +++++ tools/agent-guard/src/agent_guard/__init__.py | 147 ++++++++++- tools/agent-guard/src/agent_guard/__main__.py | 24 ++ tools/agent-guard/tests/test_exec_mode.py | 228 ++++++++++++++++++ 4 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 tools/agent-guard/src/agent_guard/__main__.py create mode 100644 tools/agent-guard/tests/test_exec_mode.py diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md index 21aff8113..77a066384 100644 --- a/tools/agent-guard/README.md +++ b/tools/agent-guard/README.md @@ -11,6 +11,7 @@ - [Per-command overrides](#per-command-overrides) - [Wiring](#wiring) - [OpenCode](#opencode) + - [Harness-neutral path (any runtime)](#harness-neutral-path-any-runtime) - [Contributing guards](#contributing-guards) - [Tests](#tests) @@ -39,6 +40,9 @@ so every wired harness enforces an identical rule set from one source of truth: - **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). +- **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 /agent_guard/__init__.py` (never via `uv run`) so it returns in a @@ -141,6 +145,57 @@ 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. +### 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 `** — 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 `** — 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 diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index 68aa7a199..ba812cda2 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -28,6 +28,17 @@ * :func:`opencode_main` — OpenCode ``tool.execute.before`` plugin adapter (``--opencode``): reads ``{"command", "cwd"}`` on stdin, signals deny via a non-zero exit so the plugin can throw and abort the tool call. +* :func:`check_main` — Harness-neutral check-only entry point (``--check``): + takes the command as remaining CLI args, exits ``DENY_EXIT`` with the reason + on stdout on a deny, ``ALLOW_EXIT`` silently on allow, ``USAGE_EXIT`` when no + command is supplied. Suitable for shell scripts and wrappers that inspect the + guard decision before acting. +* :func:`exec_main` — Harness-neutral check-then-exec entry point (``--exec``): + same guard check, but on allow it exec-replaces this process with the command + so the exit code and output are indistinguishable from a direct invocation. + On deny it prints the reason to stderr and exits ``DENY_EXIT``. Any harness + that can be configured to wrap commands through an executable can use this to + enforce guard rules without a harness-specific hook adapter. The engine ships two **bundled** guards — the universal ``git`` hygiene rules that apply to every project: @@ -520,6 +531,15 @@ def main() -> int: # harness whose hook blocks on a non-zero child exit): 0 = allow, DENY = block. ALLOW_EXIT = 0 DENY_EXIT = 2 +# Usage error (no command supplied to ``--check`` / ``--exec``). Deliberately +# distinct from DENY_EXIT so a wrapper testing ``$? -eq 2`` for a *policy* deny +# never mistakes a misinvocation for a block. Matches sysexits ``EX_USAGE``. +USAGE_EXIT = 64 +# Cap on ``--exec`` self-re-entry. A wrapper named e.g. ``git`` placed earlier on +# ``$PATH`` that calls ``--exec git`` will have ``os.execvp`` re-resolve the bare +# name back to itself and loop; this bound turns that runaway into a clear error. +_EXEC_DEPTH_VAR = "_AGENT_GUARD_EXEC_DEPTH" +_EXEC_DEPTH_MAX = 20 def opencode_main() -> int: @@ -552,17 +572,136 @@ def opencode_main() -> int: return ALLOW_EXIT +def check_main(argv: list[str]) -> int: + """Harness-neutral check-only entry point (``--check``). + + Takes the command to inspect as ``argv`` (the remaining arguments after the + ``--check`` flag), joins them into a command string, and runs + :func:`dispatch`. On a deny it writes the reason to *stdout* and exits + ``DENY_EXIT``; on allow it exits ``ALLOW_EXIT`` silently. + + Stdout is used (matching the ``--opencode`` convention) so callers can + capture the reason:: + + reason=$(python3 agent-guard.py --check git push origin main) + if [ $? -eq 2 ]; then echo "blocked: $reason"; exit 1; fi + git push origin main + + **Fail-open on decision, like every other entry point:** a *present* + command that matches no guard rule — or one the engine cannot evaluate + (a :func:`dispatch` error) — returns ``ALLOW_EXIT`` so a misconfigured + wrapper never hard-blocks the user. Invoking with **no command at all** is + a usage error and returns ``USAGE_EXIT`` (not ``DENY_EXIT``), so a caller + testing ``$? -eq 2`` for a policy deny never mistakes a misinvocation for a + block. + """ + if not argv: + sys.stderr.write("agent-guard --check: no command specified\n") + return USAGE_EXIT + command = shlex.join(argv) + cwd = os.getcwd() + try: + reason = dispatch(command, cwd) + except Exception as exc: # fail-open: a guard glitch never blocks the user + sys.stderr.write(f"agent-guard --check: guard engine error, allowing: {exc}\n") + return ALLOW_EXIT + if reason: + sys.stdout.write(reason + "\n") + return DENY_EXIT + return ALLOW_EXIT + + +def exec_main(argv: list[str]) -> int: + """Harness-neutral check-then-exec entry point (``--exec``). + + Takes the command to execute as ``argv`` (remaining arguments after + ``--exec``), runs :func:`dispatch`, and either exec-replaces this process + with the command (allow) or prints the deny reason to *stderr* and exits + ``DENY_EXIT`` (deny). On allow the process image is replaced via + :func:`os.execvp`, so the command's own exit code and output are + indistinguishable from a direct invocation. + + Any harness or shell integration that can substitute this as the executor + for ``git`` and ``gh`` commands enforces guard rules without a + harness-specific hook adapter:: + + # As a shell alias (project .bashrc / .zshrc). Safe: aliases are + # invisible to os.execvp, 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' + + # As a wrapper script named 'git' earlier on $PATH than the real one. + # It MUST exec the real git by absolute path — otherwise --exec would + # re-resolve the bare name 'git' through $PATH, find this wrapper again, + # and loop. Adjust the path to your real git. + #!/usr/bin/env bash + exec python3 /path/to/agent-guard.py --exec /usr/bin/git "$@" + + On allow, ``argv[0]`` is passed to :func:`os.execvp`, which resolves a bare + name through ``$PATH``; pass an absolute path when a same-named wrapper + shadows the real binary (see above). As a backstop, runaway self-re-entry is + bounded by ``_EXEC_DEPTH_MAX`` and turned into a clear error rather than an + unbounded loop. + + **Fail-open on decision, like every other entry point:** if the guard + engine itself errors (a :func:`dispatch` exception) the command is still + executed. Invoking with no command is a usage error (``USAGE_EXIT``). + """ + if not argv: + sys.stderr.write("agent-guard --exec: no command specified\n") + return USAGE_EXIT + command = shlex.join(argv) + cwd = os.getcwd() + try: + reason = dispatch(command, cwd) + except Exception as exc: # fail-open: a guard glitch never blocks the user + sys.stderr.write(f"agent-guard --exec: guard engine error, allowing: {exc}\n") + reason = None + if reason: + sys.stderr.write(f"agent-guard: {reason}\n") + return DENY_EXIT + # Backstop against a same-named PATH wrapper re-resolving into agent-guard. + depth = 0 + try: + depth = int(os.environ.get(_EXEC_DEPTH_VAR, "0")) + except ValueError: + depth = 0 + if depth >= _EXEC_DEPTH_MAX: + sys.stderr.write( + "agent-guard --exec: refusing to exec — self-re-entry depth " + f"({depth}) exceeded. A PATH wrapper is re-resolving the bare " + "command name back to agent-guard; point it at the real binary by " + "absolute path.\n" + ) + return 1 + os.environ[_EXEC_DEPTH_VAR] = str(depth + 1) + try: + os.execvp(argv[0], argv) + except OSError as exc: + sys.stderr.write(f"agent-guard --exec: {exc}\n") + return 1 + return 1 # unreachable on success (execvp replaces the process image) + + def cli(argv: list[str] | None = None) -> int: """Route to the harness adapter named on the command line. - No argument → the Claude Code ``PreToolUse`` hook (:func:`main`); the - ``--opencode`` flag → the OpenCode adapter (:func:`opencode_main`). A single - self-contained file thus serves every wired harness, so ``/magpie-setup`` - ships one script and each harness points its own hook at it. + No argument → the Claude Code ``PreToolUse`` hook (:func:`main`). + ``--opencode`` → the OpenCode adapter (:func:`opencode_main`). + ``--check `` → harness-neutral check-only (:func:`check_main`). + ``--exec `` → harness-neutral check-then-exec (:func:`exec_main`). + + A single self-contained file thus serves every wired harness, so + ``/magpie-setup`` ships one script and each harness (or wrapper) points its + own hook at it. """ args = sys.argv[1:] if argv is None else argv if args and args[0] == "--opencode": return opencode_main() + if args and args[0] == "--check": + return check_main(args[1:]) + if args and args[0] == "--exec": + return exec_main(args[1:]) return main() diff --git a/tools/agent-guard/src/agent_guard/__main__.py b/tools/agent-guard/src/agent_guard/__main__.py new file mode 100644 index 000000000..4617dc7e2 --- /dev/null +++ b/tools/agent-guard/src/agent_guard/__main__.py @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# SPDX-License-Identifier: Apache-2.0 + +import sys + +from agent_guard import cli + +sys.exit(cli()) diff --git a/tools/agent-guard/tests/test_exec_mode.py b/tools/agent-guard/tests/test_exec_mode.py new file mode 100644 index 000000000..67a9170f4 --- /dev/null +++ b/tools/agent-guard/tests/test_exec_mode.py @@ -0,0 +1,228 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the harness-neutral --check and --exec entry points. + +``--check`` inspects a command and reports allow/deny without running it. +``--exec`` inspects then exec-replaces the process on allow, so tests that +reach the exec path use subprocess so the test runner process survives. + +The point of both modes is that any harness (or shell wrapper) can call them +without a harness-specific hook adapter — the guard decisions still come from +the same :func:`agent_guard.dispatch` core that backs Claude Code and OpenCode. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +import agent_guard +from agent_guard import ( + ALLOW_EXIT, + DENY_EXIT, + USAGE_EXIT, + _EXEC_DEPTH_MAX, + _EXEC_DEPTH_VAR, + check_main, + cli, +) + +# --------------------------------------------------------------------------- +# --check mode (check_main called directly — safe, no exec) +# --------------------------------------------------------------------------- + + +def test_check_denied_coauthor(capsys: pytest.CaptureFixture[str]) -> None: + """--check should exit DENY_EXIT and print reason for a Co-Authored-By commit.""" + rc = check_main(["git", "commit", "-m", "fix\n\nCo-Authored-By: A "]) + out = capsys.readouterr().out + assert rc == DENY_EXIT + assert "commit-trailer" in out + + +def test_check_allowed_safe(capsys: pytest.CaptureFixture[str]) -> None: + """--check should exit ALLOW_EXIT silently for a non-guarded command.""" + rc = check_main(["git", "status"]) + assert rc == ALLOW_EXIT + assert capsys.readouterr().out == "" + + +def test_check_non_git_command(capsys: pytest.CaptureFixture[str]) -> None: + """--check fast-path allows commands that are not in GUARDED_HEADS.""" + rc = check_main(["ls", "-la"]) + assert rc == ALLOW_EXIT + assert capsys.readouterr().out == "" + + +def test_check_empty_argv(capsys: pytest.CaptureFixture[str]) -> None: + """--check with no argv is a usage error: USAGE_EXIT, not DENY_EXIT.""" + rc = check_main([]) + err = capsys.readouterr().err + assert rc == USAGE_EXIT + assert rc != DENY_EXIT # a misinvocation must not read as a policy block + assert "no command" in err + + +def test_check_fail_open_on_dispatch_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A guard-engine error must fail open (ALLOW_EXIT), never block the user.""" + + def _boom(_command: str, _cwd: str | None = None) -> str | None: + raise RuntimeError("guard exploded") + + monkeypatch.setattr(agent_guard, "dispatch", _boom) + rc = check_main(["git", "push", "origin", "main"]) + assert rc == ALLOW_EXIT + assert "guard engine error" in capsys.readouterr().err + + +def test_check_decision_matches_dispatch() -> None: + """--check must deny exactly what dispatch() denies (shared core).""" + command_tokens = ["git", "commit", "-m", "x\n\nCo-Authored-By: A "] + dispatch_denies = agent_guard.dispatch(" ".join(command_tokens)) is not None + check_denies = check_main(command_tokens) == DENY_EXIT + assert dispatch_denies is check_denies is True + + +def test_check_allowed_decision_matches_dispatch() -> None: + """--check allows exactly what dispatch() allows.""" + command_tokens = ["git", "log", "--oneline"] + dispatch_allows = agent_guard.dispatch(" ".join(command_tokens)) is None + check_allows = check_main(command_tokens) == ALLOW_EXIT + assert dispatch_allows is check_allows is True + + +def test_cli_routes_check_flag(capsys: pytest.CaptureFixture[str]) -> None: + """cli() should route --check to check_main.""" + rc = cli(["--check", "git", "status"]) + assert rc == ALLOW_EXIT + assert capsys.readouterr().out == "" + + +def test_cli_check_denied(capsys: pytest.CaptureFixture[str]) -> None: + """cli() --check routing should propagate deny from check_main.""" + rc = cli(["--check", "git", "commit", "-m", "x\n\nCo-Authored-By: A "]) + assert rc == DENY_EXIT + assert "commit-trailer" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# --exec mode (subprocess — exec replaces the process image) +# --------------------------------------------------------------------------- + + +def _run_exec(*args: str) -> subprocess.CompletedProcess[str]: + """Run agent-guard --exec in a child process.""" + return subprocess.run( + [sys.executable, "-m", "agent_guard", "--exec", *args], + capture_output=True, + text=True, + ) + + +def test_exec_allowed_runs_command() -> None: + """--exec should exec the command on allow; its output and exit code survive.""" + proc = _run_exec("echo", "guard-passed") + assert proc.returncode == 0 + assert "guard-passed" in proc.stdout + + +def test_exec_denied_does_not_run_command() -> None: + """--exec should exit DENY_EXIT and NOT run the command on deny.""" + proc = _run_exec("git", "commit", "-m", "x\n\nCo-Authored-By: A ") + assert proc.returncode == DENY_EXIT + # Command never ran — no git error on stdout, reason on stderr. + assert "Co-Authored-By" in proc.stderr or "commit-trailer" in proc.stderr + + +def test_exec_denied_prints_reason_to_stderr() -> None: + """--exec deny reason must go to stderr (stdout belongs to the exec'd command).""" + proc = _run_exec("git", "commit", "-m", "fix\n\nCo-Authored-By: A ") + assert proc.returncode == DENY_EXIT + assert proc.stderr.strip() != "" + assert proc.stdout.strip() == "" + + +def test_exec_allowed_exit_code_propagates() -> None: + """The exec'd command's own exit code propagates through --exec.""" + proc = _run_exec(sys.executable, "-c", "import sys; sys.exit(42)") + assert proc.returncode == 42 + + +def test_exec_empty_argv() -> None: + """--exec with no command is a usage error: USAGE_EXIT, not DENY_EXIT.""" + proc = subprocess.run( + [sys.executable, "-m", "agent_guard", "--exec"], + capture_output=True, + text=True, + ) + assert proc.returncode == USAGE_EXIT + assert proc.returncode != DENY_EXIT + assert "no command" in proc.stderr + + +def test_exec_nonexistent_command() -> None: + """--exec with a nonexistent binary should exit 1 with an OS error.""" + proc = _run_exec("__nonexistent_command_xyz__") + assert proc.returncode == 1 + assert proc.stderr.strip() != "" + + +def test_exec_recursion_guard_refuses_at_depth_cap() -> None: + """A PATH wrapper re-resolving into --exec must error out, not loop forever. + + Simulated by pre-seeding the depth counter at the cap: the next --exec of an + allowed command must refuse with exit 1 and a diagnostic, rather than exec. + """ + env = dict(os.environ) + env[_EXEC_DEPTH_VAR] = str(_EXEC_DEPTH_MAX) + proc = subprocess.run( + [sys.executable, "-m", "agent_guard", "--exec", "echo", "should-not-run"], + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 1 + assert "should-not-run" not in proc.stdout + assert "re-entry depth" in proc.stderr + + +def test_exec_depth_counter_increments_for_child() -> None: + """A normally-run command sees the depth counter bumped to 1 in its env.""" + proc = _run_exec( + sys.executable, + "-c", + f"import os; print(os.environ.get({_EXEC_DEPTH_VAR!r}))", + ) + assert proc.returncode == 0 + assert proc.stdout.strip() == "1" + + +def test_cli_routes_exec_flag() -> None: + """cli() should route --exec to exec_main (allowed → runs echo).""" + proc = subprocess.run( + [sys.executable, "-m", "agent_guard", "--exec", "echo", "cli-exec-ok"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0 + assert "cli-exec-ok" in proc.stdout From 693428ec1fcd8b27c92a27101e9e7b1edc1e9e5b Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Mon, 6 Jul 2026 18:12:38 +1000 Subject: [PATCH 2/5] remove import/update code to match --- tools/agent-guard/tests/test_exec_mode.py | 55 ++++++++++------------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/tools/agent-guard/tests/test_exec_mode.py b/tools/agent-guard/tests/test_exec_mode.py index 67a9170f4..0705b07b4 100644 --- a/tools/agent-guard/tests/test_exec_mode.py +++ b/tools/agent-guard/tests/test_exec_mode.py @@ -35,15 +35,6 @@ import pytest import agent_guard -from agent_guard import ( - ALLOW_EXIT, - DENY_EXIT, - USAGE_EXIT, - _EXEC_DEPTH_MAX, - _EXEC_DEPTH_VAR, - check_main, - cli, -) # --------------------------------------------------------------------------- # --check mode (check_main called directly — safe, no exec) @@ -52,32 +43,32 @@ def test_check_denied_coauthor(capsys: pytest.CaptureFixture[str]) -> None: """--check should exit DENY_EXIT and print reason for a Co-Authored-By commit.""" - rc = check_main(["git", "commit", "-m", "fix\n\nCo-Authored-By: A "]) + rc = agent_guard.check_main(["git", "commit", "-m", "fix\n\nCo-Authored-By: A "]) out = capsys.readouterr().out - assert rc == DENY_EXIT + assert rc == agent_guard.DENY_EXIT assert "commit-trailer" in out def test_check_allowed_safe(capsys: pytest.CaptureFixture[str]) -> None: """--check should exit ALLOW_EXIT silently for a non-guarded command.""" - rc = check_main(["git", "status"]) - assert rc == ALLOW_EXIT + rc = agent_guard.check_main(["git", "status"]) + assert rc == agent_guard.ALLOW_EXIT assert capsys.readouterr().out == "" def test_check_non_git_command(capsys: pytest.CaptureFixture[str]) -> None: """--check fast-path allows commands that are not in GUARDED_HEADS.""" - rc = check_main(["ls", "-la"]) - assert rc == ALLOW_EXIT + rc = agent_guard.check_main(["ls", "-la"]) + assert rc == agent_guard.ALLOW_EXIT assert capsys.readouterr().out == "" def test_check_empty_argv(capsys: pytest.CaptureFixture[str]) -> None: """--check with no argv is a usage error: USAGE_EXIT, not DENY_EXIT.""" - rc = check_main([]) + rc = agent_guard.check_main([]) err = capsys.readouterr().err - assert rc == USAGE_EXIT - assert rc != DENY_EXIT # a misinvocation must not read as a policy block + assert rc == agent_guard.USAGE_EXIT + assert rc != agent_guard.DENY_EXIT # a misinvocation must not read as a policy block assert "no command" in err @@ -90,8 +81,8 @@ def _boom(_command: str, _cwd: str | None = None) -> str | None: raise RuntimeError("guard exploded") monkeypatch.setattr(agent_guard, "dispatch", _boom) - rc = check_main(["git", "push", "origin", "main"]) - assert rc == ALLOW_EXIT + rc = agent_guard.check_main(["git", "push", "origin", "main"]) + assert rc == agent_guard.ALLOW_EXIT assert "guard engine error" in capsys.readouterr().err @@ -99,7 +90,7 @@ def test_check_decision_matches_dispatch() -> None: """--check must deny exactly what dispatch() denies (shared core).""" command_tokens = ["git", "commit", "-m", "x\n\nCo-Authored-By: A "] dispatch_denies = agent_guard.dispatch(" ".join(command_tokens)) is not None - check_denies = check_main(command_tokens) == DENY_EXIT + check_denies = agent_guard.check_main(command_tokens) == agent_guard.DENY_EXIT assert dispatch_denies is check_denies is True @@ -107,21 +98,21 @@ def test_check_allowed_decision_matches_dispatch() -> None: """--check allows exactly what dispatch() allows.""" command_tokens = ["git", "log", "--oneline"] dispatch_allows = agent_guard.dispatch(" ".join(command_tokens)) is None - check_allows = check_main(command_tokens) == ALLOW_EXIT + check_allows = agent_guard.check_main(command_tokens) == agent_guard.ALLOW_EXIT assert dispatch_allows is check_allows is True def test_cli_routes_check_flag(capsys: pytest.CaptureFixture[str]) -> None: """cli() should route --check to check_main.""" - rc = cli(["--check", "git", "status"]) - assert rc == ALLOW_EXIT + rc = agent_guard.cli(["--check", "git", "status"]) + assert rc == agent_guard.ALLOW_EXIT assert capsys.readouterr().out == "" def test_cli_check_denied(capsys: pytest.CaptureFixture[str]) -> None: """cli() --check routing should propagate deny from check_main.""" - rc = cli(["--check", "git", "commit", "-m", "x\n\nCo-Authored-By: A "]) - assert rc == DENY_EXIT + rc = agent_guard.cli(["--check", "git", "commit", "-m", "x\n\nCo-Authored-By: A "]) + assert rc == agent_guard.DENY_EXIT assert "commit-trailer" in capsys.readouterr().out @@ -149,7 +140,7 @@ def test_exec_allowed_runs_command() -> None: def test_exec_denied_does_not_run_command() -> None: """--exec should exit DENY_EXIT and NOT run the command on deny.""" proc = _run_exec("git", "commit", "-m", "x\n\nCo-Authored-By: A ") - assert proc.returncode == DENY_EXIT + assert proc.returncode == agent_guard.DENY_EXIT # Command never ran — no git error on stdout, reason on stderr. assert "Co-Authored-By" in proc.stderr or "commit-trailer" in proc.stderr @@ -157,7 +148,7 @@ def test_exec_denied_does_not_run_command() -> None: def test_exec_denied_prints_reason_to_stderr() -> None: """--exec deny reason must go to stderr (stdout belongs to the exec'd command).""" proc = _run_exec("git", "commit", "-m", "fix\n\nCo-Authored-By: A ") - assert proc.returncode == DENY_EXIT + assert proc.returncode == agent_guard.DENY_EXIT assert proc.stderr.strip() != "" assert proc.stdout.strip() == "" @@ -175,8 +166,8 @@ def test_exec_empty_argv() -> None: capture_output=True, text=True, ) - assert proc.returncode == USAGE_EXIT - assert proc.returncode != DENY_EXIT + assert proc.returncode == agent_guard.USAGE_EXIT + assert proc.returncode != agent_guard.DENY_EXIT assert "no command" in proc.stderr @@ -194,7 +185,7 @@ def test_exec_recursion_guard_refuses_at_depth_cap() -> None: allowed command must refuse with exit 1 and a diagnostic, rather than exec. """ env = dict(os.environ) - env[_EXEC_DEPTH_VAR] = str(_EXEC_DEPTH_MAX) + env[agent_guard._EXEC_DEPTH_VAR] = str(agent_guard._EXEC_DEPTH_MAX) proc = subprocess.run( [sys.executable, "-m", "agent_guard", "--exec", "echo", "should-not-run"], capture_output=True, @@ -211,7 +202,7 @@ def test_exec_depth_counter_increments_for_child() -> None: proc = _run_exec( sys.executable, "-c", - f"import os; print(os.environ.get({_EXEC_DEPTH_VAR!r}))", + f"import os; print(os.environ.get({agent_guard._EXEC_DEPTH_VAR!r}))", ) assert proc.returncode == 0 assert proc.stdout.strip() == "1" From 0c6f6a337d5f517dfed2c3e959bd80188e022d44 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Mon, 6 Jul 2026 18:22:49 +1000 Subject: [PATCH 3/5] fix mypy issue --- tools/agent-guard/src/agent_guard/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index ba812cda2..8fadf7f95 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -675,12 +675,12 @@ def exec_main(argv: list[str]) -> int: ) return 1 os.environ[_EXEC_DEPTH_VAR] = str(depth + 1) + # execvp replaces the process image on success; on failure it raises OSError. try: os.execvp(argv[0], argv) except OSError as exc: sys.stderr.write(f"agent-guard --exec: {exc}\n") return 1 - return 1 # unreachable on success (execvp replaces the process image) def cli(argv: list[str] | None = None) -> int: From 7688192d975ff257420c245823e630cdd79906d4 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Mon, 6 Jul 2026 18:51:05 +1000 Subject: [PATCH 4/5] this shoudl fix it --- tools/agent-guard/src/agent_guard/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index 8fadf7f95..d008c3250 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -675,12 +675,13 @@ def exec_main(argv: list[str]) -> int: ) return 1 os.environ[_EXEC_DEPTH_VAR] = str(depth + 1) - # execvp replaces the process image on success; on failure it raises OSError. + # execvp replaces the process image on success; on failure it raises OSError + # and control falls through to the explicit return below. try: os.execvp(argv[0], argv) except OSError as exc: sys.stderr.write(f"agent-guard --exec: {exc}\n") - return 1 + return 1 def cli(argv: list[str] | None = None) -> int: From e567d006a32c06529f4fc3afdf0a947a5978e214 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 8 Jul 2026 16:07:49 +0200 Subject: [PATCH 5/5] fix(agent-guard): basename-normalize command head so path-qualified invocations can't bypass the guard Addresses the review blocker on the --exec wrapper install: dispatch matched the command head on exact seg.argv[0] in {gh,git}, so a documented '--exec /usr/bin/git' wrapper (or ./git, or a model emitting an absolute path in Claude/OpenCode) took the unguarded fast path. Segment now normalises argv[0] to os.path.basename, closing the hole across all harnesses. Execution is untouched (exec_main hands the original argv to os.execvp). Adds regression tests for absolute/relative-path git heads. --- tools/agent-guard/src/agent_guard/__init__.py | 11 ++++++++++- tools/agent-guard/tests/test_guards.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index d008c3250..6cb25db2f 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -124,7 +124,16 @@ def __init__(self, tokens: list[str], raw: str) -> None: name, _, value = tokens[i].partition("=") self.env[name] = value i += 1 - self.argv: list[str] = tokens[i:] + argv = tokens[i:] + # Normalise the command head to its basename so a path-qualified + # invocation (`/usr/bin/git`, `./git`) is guarded identically to the + # bare name. Guard rules only inspect argv[0] as a command *name*; the + # real execution path is untouched (exec_main hands the original argv + # to os.execvp). Prefix wrappers like `env git` / `command git` keep + # argv[0] == "env" and remain a separate, pre-existing gap. + if argv: + argv[0] = os.path.basename(argv[0]) + self.argv: list[str] = argv self.raw = raw def override(self, *names: str) -> bool: diff --git a/tools/agent-guard/tests/test_guards.py b/tools/agent-guard/tests/test_guards.py index 690e58d71..cb864a7ec 100644 --- a/tools/agent-guard/tests/test_guards.py +++ b/tools/agent-guard/tests/test_guards.py @@ -267,3 +267,19 @@ def __init__(self, text): def read(self): return self._text + + +# Regression: a path-qualified command head (e.g. the documented +# ``--exec /usr/bin/git`` wrapper install) must be guarded identically to the +# bare name. Segment normalises argv[0] to its basename, so the guard cannot be +# bypassed by invoking the real binary by absolute or relative path. +def test_abs_path_git_commit_coauthor_denied(): + assert dispatch('/usr/bin/git commit -m "x\n\nCo-Authored-By: a "') is not None + + +def test_relative_path_git_commit_coauthor_denied(): + assert dispatch('./git commit -m "x\n\nCo-Authored-By: a "') is not None + + +def test_abs_path_plain_commit_still_allowed(): + assert dispatch('/usr/bin/git commit -m "ordinary commit"') is None