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..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:d49afa7476b2bfff" +FRAMEWORK_FINGERPRINT = "sha256:974c0617cb625d21" 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..7ecd992d5 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,48 @@ 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: + # 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 + + 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. " + "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..27e217c01 --- /dev/null +++ b/tools/agent-guard/tests/test_python_version.py @@ -0,0 +1,101 @@ +# 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_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: + 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", "cause"), + [ + 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( + old_python: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + environ: dict[str, str], + which: object, + cause: str, +) -> 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 + assert cause 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