Files
planning-with-files/tests/test_adapter_python_isolation.py

225 lines
8.8 KiB
Python

"""Regression coverage for shell-adapter Python import isolation."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SH = shutil.which("sh")
# Use the same Git for Windows installation, not a WSL bash launcher.
BASH = shutil.which("bash", path=str(Path(SH).parent)) if os.name == "nt" and SH else shutil.which("bash")
CODEX_HOOKS = REPO / ".codex" / "hooks"
GEMINI_HOOKS = REPO / ".gemini" / "hooks"
COPILOT_HOOKS = REPO / ".github" / "hooks" / "scripts"
PYTHON_SHELL_HOOKS = (
GEMINI_HOOKS / "before-agent.sh",
GEMINI_HOOKS / "session-start.sh",
GEMINI_HOOKS / "session-end.sh",
COPILOT_HOOKS / "session-start.sh",
COPILOT_HOOKS / "pre-tool-use.sh",
COPILOT_HOOKS / "error-occurred.sh",
)
CODEX_PYTHON_SHELL_HOOKS = (
CODEX_HOOKS / "pre-tool-use.sh",
CODEX_HOOKS / "session-start.sh",
CODEX_HOOKS / "user-prompt-submit.sh",
)
def shell_python_is_usable() -> bool:
if not BASH:
return False
result = subprocess.run(
[
str(BASH),
"-c",
'PY=$(command -v python3 || command -v python); [ -n "$PY" ] && "$PY" -I -c "import json"',
],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
@unittest.skipUnless(shell_python_is_usable(), "shell Python is not usable")
class AdapterPythonIsolationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.cwd = Path(self._tmp.name)
(self.cwd / "task_plan.md").write_text(
"# Task Plan\n## Current Phase: hardening\n### Phase 1: Work\n"
"**Status:** in_progress\n",
encoding="utf-8",
)
(self.cwd / "json.py").write_text(
"open('PLANTED_json', 'w').close()\nraise RuntimeError('shadow loaded')\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def run_hook(self, script: Path, input_data: str = "{}") -> dict:
result = subprocess.run(
# Gemini executes Bash-shebang scripts directly; Copilot declares
# these hooks in its "bash" field. POSIX sh can be dash on Linux.
[str(BASH), str(script)],
cwd=str(self.cwd),
env=dict(os.environ, PYTHONUTF8="1"),
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
self.assertEqual(0, result.returncode, result.stderr)
return json.loads(result.stdout.lstrip("\ufeff"))
def test_gemini_json_encoding_ignores_project_json_module(self) -> None:
for name in ("before-agent.sh",):
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_json"
marker.unlink(missing_ok=True)
payload = self.run_hook(GEMINI_HOOKS / name)
self.assertFalse(marker.exists(), f"{name} imported project json.py")
self.assertIn("hardening", json.dumps(payload))
def test_copilot_python_paths_ignore_project_json_module(self) -> None:
cases = (
("session-start.sh", "{}"),
("pre-tool-use.sh", "{}"),
("error-occurred.sh", '{"error":{"message":"fixture failure"}}'),
)
for name, input_data in cases:
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_json"
marker.unlink(missing_ok=True)
payload = self.run_hook(COPILOT_HOOKS / name, input_data)
self.assertFalse(marker.exists(), f"{name} imported project json.py")
self.assertIn("additionalContext", payload.get("hookSpecificOutput", {}))
def test_copilot_unicode_error_survives_isolated_python(self) -> None:
message = "中文 العربية русский Grüße"
# Escaped JSON becomes real Unicode inside the Python parser. Isolated
# mode ignores PYTHONUTF8, so a Windows locale encoding loses this event.
payload = self.run_hook(
COPILOT_HOOKS / "error-occurred.sh",
json.dumps({"error": {"message": message}}, ensure_ascii=True),
)
context = payload.get("hookSpecificOutput", {}).get("additionalContext", "")
self.assertIn(message, context)
@unittest.skipUnless(SH, "sh is not available")
class CodexPythonIsolationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.cwd = Path(self._tmp.name)
(self.cwd / "task_plan.md").write_text(
"# Task Plan\n### Phase 1: Work\n**Status:** in_progress\n",
encoding="utf-8",
)
(self.cwd / "progress.md").write_text("# Progress\n", encoding="utf-8")
(self.cwd / "hashlib.py").write_text(
"open('PLANTED_hashlib', 'w').close()\nraise RuntimeError('shadow loaded')\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def run_hook(self, name: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
hook_cwd = self.cwd if cwd is None else cwd
env = dict(os.environ, PYTHONUTF8="1")
env["PYTHON_BIN"] = sys.executable
env["PWF_PLAN_ROOT"] = str(hook_cwd)
return subprocess.run(
[str(SH), str(CODEX_HOOKS / name)],
cwd=str(hook_cwd),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
def test_codex_shell_python_ignores_project_hashlib_module(self) -> None:
cases = (
("user-prompt-submit.sh", "ACTIVE PLAN", "stdout"),
("pre-tool-use.sh", "# Task Plan", "stderr"),
("session-start.sh", "ACTIVE PLAN", "stdout"),
)
for name, expected, stream in cases:
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_hashlib"
marker.unlink(missing_ok=True)
result = self.run_hook(name)
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse(marker.exists(), f"{name} imported project hashlib.py")
self.assertIn(expected, getattr(result, stream))
def test_codex_unicode_plan_root_survives_isolated_python(self) -> None:
project = self.cwd / "中文"
project.mkdir()
shutil.copyfile(self.cwd / "task_plan.md", project / "task_plan.md")
for name in ("user-prompt-submit.sh", "session-start.sh"):
with self.subTest(hook=name):
result = self.run_hook(name, cwd=project)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("ACTIVE PLAN", result.stdout)
self.assertIn("# Task Plan", result.stdout)
class AdapterPythonIsolationContractTests(unittest.TestCase):
def test_every_adapter_python_invocation_uses_isolated_mode(self) -> None:
for script in PYTHON_SHELL_HOOKS:
with self.subTest(hook=script.relative_to(REPO)):
text = script.read_text(encoding="utf-8")
invocations = [
line
for line in text.splitlines()
if "$($PYTHON" in line or "| $PYTHON" in line
]
self.assertTrue(invocations, f"no Python invocation found in {script}")
for line in invocations:
self.assertIn(
"$PYTHON -I -X utf8 ",
line,
f"non-isolated Python invocation in {script.relative_to(REPO)}: {line}",
)
def test_every_codex_shell_python_invocation_uses_isolated_mode(self) -> None:
variables = ('"$PYTHON_BIN"', '"$PWF_PYTHON"', '"$PIN_PYTHON"', '"$_tp_candidate"')
for script in CODEX_PYTHON_SHELL_HOOKS:
with self.subTest(hook=script.relative_to(REPO)):
text = script.read_text(encoding="utf-8")
invocations = [
line
for line in text.splitlines()
if any(variable in line for variable in variables)
and (" -c " in line or (".py\"" in line and " -f " not in line))
]
self.assertTrue(invocations, f"no Python invocation found in {script}")
for line in invocations:
self.assertIn(
" -I -X utf8 ",
line,
f"non-isolated Python invocation in {script.relative_to(REPO)}: {line}",
)
if __name__ == "__main__":
unittest.main()