Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:d49afa7476b2bfff"
FRAMEWORK_FINGERPRINT = "sha256:974c0617cb625d21"
2 changes: 1 addition & 1 deletion tools/agent-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 47 additions & 1 deletion tools/agent-guard/src/agent_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@
import shlex
import subprocess
import sys
import tomllib
from collections.abc import Callable
from pathlib import Path

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
101 changes: 101 additions & 0 deletions tools/agent-guard/tests/test_python_version.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions tools/spec-loop/specs/agent-isolation-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down