From bd38f1b27bec4b800a4683e89769f48910736071 Mon Sep 17 00:00:00 2001 From: Shahar Epstein <60007259+shahar1@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:28:03 +0300 Subject: [PATCH 1/2] fix(agent-guard): re-exec under Python 3.11+ when python3 is older Hooks invoke the guard engine as a bare `python3`, which resolves through the user's PATH. With an activated project virtualenv on Python 3.10 (a common adopter setup, e.g. Apache Airflow) the module-level `import tomllib` raised ModuleNotFoundError on every Bash call: a traceback in the UI each time, and the guard silently never ran. The engine now imports on 3.10 (tomllib is imported where it is used) and, when the interpreter is older than 3.11, re-runs itself under the newest `python3.N` (3.11+) on PATH. When none exists it exits 1 with one actionable line instead of a traceback. Every harness adapter benefits, since the check runs before `cli()` dispatches. Generated-by: Claude Code (Fable 5.1) --- .../setup_preflight/isolated_fingerprint.py | 2 +- tools/agent-guard/README.md | 2 +- tools/agent-guard/src/agent_guard/__init__.py | 39 ++++++++- .../agent-guard/tests/test_python_version.py | 87 +++++++++++++++++++ .../specs/agent-isolation-sandbox.md | 4 + 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tools/agent-guard/tests/test_python_version.py diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 11a320762..4339867d7 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:d49afa7476b2bfff" +FRAMEWORK_FINGERPRINT = "sha256:776f19772ff04175" diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md index 4f00e9713..668e97883 100644 --- a/tools/agent-guard/README.md +++ b/tools/agent-guard/README.md @@ -62,7 +62,7 @@ few milliseconds for any command that is not a guarded `gh` / `git commit` / ## 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 --directory tools/agent-guard --group dev pytest`. +- **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. When that `python3` is older — typically an activated project virtualenv — the engine re-runs itself under the newest `python3.N` (3.11+) on `PATH`, or exits 1 with an actionable message when there is none. The test suite runs under `uv run --directory tools/agent-guard --group dev 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. diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index a5d4d424c..8d3503f56 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -88,7 +88,6 @@ import shlex import subprocess import sys -import tomllib from collections.abc import Callable from pathlib import Path @@ -435,6 +434,10 @@ def _read_attribution(path: Path) -> str | None: return None except (OSError, UnicodeDecodeError) as exc: raise ValueError(f"{path}: {exc}") from exc + # Imported here, not at the top: the module must import on a pre-3.11 + # ``python3`` so ``_reexec_under_supported_python`` can run. + import tomllib + try: data = tomllib.loads(text) except tomllib.TOMLDecodeError as exc: @@ -1064,5 +1067,39 @@ def cli(argv: list[str] | None = None) -> int: return main() +_MIN_PYTHON = (3, 11) +_REEXEC_VAR = "_AGENT_GUARD_REEXEC" + + +def _reexec_under_supported_python() -> None: + """Re-run this script under a 3.11+ interpreter when ``python3`` is older. + + Hooks invoke the engine as a bare ``python3``, which resolves through the + user's ``PATH`` — often an activated project virtualenv pinned to an older + Python. Look for a versioned ``python3.N`` instead of failing; when there is + none, exit 1 with an actionable message rather than an ImportError + traceback on every shell call. + """ + if sys.version_info[:2] >= _MIN_PYTHON: + return + import shutil + + if not os.environ.get(_REEXEC_VAR): + # ponytail: probes up to python3.20; raise the bound when that ships. + for minor in range(20, _MIN_PYTHON[1] - 1, -1): + interpreter = shutil.which(f"python3.{minor}") + if interpreter: + os.environ[_REEXEC_VAR] = "1" + os.execv(interpreter, [interpreter, os.path.abspath(__file__), *sys.argv[1:]]) + found = ".".join(map(str, sys.version_info[:3])) + sys.stderr.write( + f"agent-guard: needs Python 3.11+, but python3 is {found} ({sys.executable}) " + "and no python3.11+ is on PATH. The guard is NOT running. " + "Install Python 3.11+ or put a newer python3 first on PATH.\n" + ) + raise SystemExit(1) + + if __name__ == "__main__": + _reexec_under_supported_python() raise SystemExit(cli()) diff --git a/tools/agent-guard/tests/test_python_version.py b/tools/agent-guard/tests/test_python_version.py new file mode 100644 index 000000000..037c7ea4e --- /dev/null +++ b/tools/agent-guard/tests/test_python_version.py @@ -0,0 +1,87 @@ +# 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 re-exec under a 3.11+ interpreter when ``python3`` is older.""" + +from __future__ import annotations + +import os +import shutil +import sys + +import pytest + +import agent_guard + + +class _Exec(Exception): + pass + + +def _fake_execv(path: str, argv: list[str]) -> None: + raise _Exec(path, argv) + + +@pytest.fixture +def old_python(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: + environ: dict[str, str] = {} + monkeypatch.setattr(sys, "version_info", (3, 10, 17)) + monkeypatch.setattr(os, "environ", environ) + monkeypatch.setattr(os, "execv", _fake_execv) + return environ + + +def test_supported_python_is_a_no_op() -> None: + assert agent_guard._reexec_under_supported_python() is None + + +def test_reexecs_under_newest_versioned_interpreter( + old_python: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + available = {"python3.11": "/usr/bin/python3.11", "python3.13": "/usr/bin/python3.13"} + monkeypatch.setattr(shutil, "which", available.get) + monkeypatch.setattr(sys, "argv", ["agent-guard", "--gemini"]) + with pytest.raises(_Exec) as exc: + agent_guard._reexec_under_supported_python() + path, argv = exc.value.args + assert path == "/usr/bin/python3.13" + assert argv == [path, os.path.abspath(agent_guard.__file__), "--gemini"] + assert old_python[agent_guard._REEXEC_VAR] == "1" + + +@pytest.mark.parametrize( + ("environ", "which"), + [ + pytest.param({}, lambda _: None, id="no-newer-interpreter"), + pytest.param({agent_guard._REEXEC_VAR: "1"}, lambda _: "/usr/bin/python3.13", id="already-reexeced"), + ], +) +def test_exits_with_actionable_message( + old_python: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + environ: dict[str, str], + which: object, +) -> None: + old_python.update(environ) + monkeypatch.setattr(shutil, "which", which) + with pytest.raises(SystemExit) as exc: + agent_guard._reexec_under_supported_python() + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "needs Python 3.11+" in err + assert "3.10.17" in err diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md index 70d373f67..6052c25b0 100644 --- a/tools/spec-loop/specs/agent-isolation-sandbox.md +++ b/tools/spec-loop/specs/agent-isolation-sandbox.md @@ -153,6 +153,10 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`. `git --no-pager commit`), never by a fixed argv slice, and `GuardContext.git_subcommand()` gives contributed guards the same resolution `gh_subcommand()` gives for `gh` (#1330). + Hooks invoke the engine as a bare `python3`; when that resolves to a + pre-3.11 interpreter, the engine re-runs itself under the newest + `python3.N` (3.11+) on `PATH`, and exits 1 with an actionable message + when none exists. The `commit-trailer` guard follows the project's commit-attribution convention (#1385): it denies a `Co-Authored-By:` trailer unless the convention resolved for the repository being committed to (following From f28873afbcc296a41106e6cc22e7e4c95b81e257 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 5 Oct 2026 11:33:01 +0200 Subject: [PATCH 2/2] fix(agent-guard): clear the re-exec marker once on 3.11+ The marker stayed in the environment after the re-exec succeeded, so a guard run nested under `--exec` inherited it, skipped the interpreter search and exited with a false "no python3.11+ is on PATH". Drop it once the supported interpreter is running, give the already-re-exec'd case its own message, and replace the unknown comment tag. Generated-by: Claude Opus 5 --- .../setup_preflight/isolated_fingerprint.py | 2 +- tools/agent-guard/src/agent_guard/__init__.py | 23 +++++++++++++------ .../agent-guard/tests/test_python_version.py | 20 +++++++++++++--- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 4339867d7..d0a80c26d 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:776f19772ff04175" +FRAMEWORK_FINGERPRINT = "sha256:974c0617cb625d21" diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index 8d3503f56..7ecd992d5 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -1081,17 +1081,26 @@ def _reexec_under_supported_python() -> None: traceback on every shell call. """ if sys.version_info[:2] >= _MIN_PYTHON: + # Drop the marker so commands the guard runs (``--exec``) do not + # inherit it and skip the search in a nested guard run. + os.environ.pop(_REEXEC_VAR, None) return import shutil - if not os.environ.get(_REEXEC_VAR): - # ponytail: probes up to python3.20; raise the bound when that ships. - for minor in range(20, _MIN_PYTHON[1] - 1, -1): - interpreter = shutil.which(f"python3.{minor}") - if interpreter: - os.environ[_REEXEC_VAR] = "1" - os.execv(interpreter, [interpreter, os.path.abspath(__file__), *sys.argv[1:]]) found = ".".join(map(str, sys.version_info[:3])) + if os.environ.get(_REEXEC_VAR): + sys.stderr.write( + f"agent-guard: needs Python 3.11+, but the interpreter it re-ran under is " + f"{found} ({sys.executable}). The guard is NOT running. " + "Check which python3.N is first on PATH.\n" + ) + raise SystemExit(1) + # Probes python3.20 down to python3.11; raise the upper bound when 3.21 ships. + for minor in range(20, _MIN_PYTHON[1] - 1, -1): + interpreter = shutil.which(f"python3.{minor}") + if interpreter: + os.environ[_REEXEC_VAR] = "1" + os.execv(interpreter, [interpreter, os.path.abspath(__file__), *sys.argv[1:]]) sys.stderr.write( f"agent-guard: needs Python 3.11+, but python3 is {found} ({sys.executable}) " "and no python3.11+ is on PATH. The guard is NOT running. " diff --git a/tools/agent-guard/tests/test_python_version.py b/tools/agent-guard/tests/test_python_version.py index 037c7ea4e..27e217c01 100644 --- a/tools/agent-guard/tests/test_python_version.py +++ b/tools/agent-guard/tests/test_python_version.py @@ -49,6 +49,13 @@ def test_supported_python_is_a_no_op() -> None: assert agent_guard._reexec_under_supported_python() is None +def test_supported_python_clears_the_reexec_marker(monkeypatch: pytest.MonkeyPatch) -> None: + environ = {agent_guard._REEXEC_VAR: "1"} + monkeypatch.setattr(os, "environ", environ) + agent_guard._reexec_under_supported_python() + assert agent_guard._REEXEC_VAR not in environ + + def test_reexecs_under_newest_versioned_interpreter( old_python: dict[str, str], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -64,10 +71,15 @@ def test_reexecs_under_newest_versioned_interpreter( @pytest.mark.parametrize( - ("environ", "which"), + ("environ", "which", "cause"), [ - pytest.param({}, lambda _: None, id="no-newer-interpreter"), - pytest.param({agent_guard._REEXEC_VAR: "1"}, lambda _: "/usr/bin/python3.13", id="already-reexeced"), + pytest.param({}, lambda _: None, "no python3.11+ is on PATH", id="no-newer-interpreter"), + pytest.param( + {agent_guard._REEXEC_VAR: "1"}, + lambda _: "/usr/bin/python3.13", + "the interpreter it re-ran under", + id="already-reexeced", + ), ], ) def test_exits_with_actionable_message( @@ -76,6 +88,7 @@ def test_exits_with_actionable_message( capsys: pytest.CaptureFixture[str], environ: dict[str, str], which: object, + cause: str, ) -> None: old_python.update(environ) monkeypatch.setattr(shutil, "which", which) @@ -85,3 +98,4 @@ def test_exits_with_actionable_message( err = capsys.readouterr().err assert "needs Python 3.11+" in err assert "3.10.17" in err + assert cause in err