mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(eval): bind the resolved node to a fresh sandbox path, not one under /usr #2607 bound the resolved `node` to /usr/local/bin/node, but that path lives inside the /usr tree that _runtime_mount_args already read-only-binds wholesale. The second real workflow_dispatch run on the self-hosted runner (https://github.com/abhigyanpatwari/GitNexus/actions/runs/29840270554) failed immediately in the bubblewrap preflight: "bwrap: Can't create file at /usr/local/bin/node: Read-only file system" -- bwrap can't create a new mount-point file inside a tree it already bound read-only when the real path doesn't already exist there on the host, which is exactly the self-hosted case this bind exists to fix. Introduces SANDBOX_NODE (/opt/claude/node), a fresh path outside every tree _runtime_mount_args binds, following the same pattern SANDBOX_CLAUDE and SANDBOX_PYTHON3 already use. Updates the two real call sites (sanitized_graph.py, runner_sessions.py) to use the constant instead of the hardcoded literal, so the fix can't drift out of sync with itself again, and re-exports it from runner.py alongside the other SANDBOX_* names for the real-bwrap tests that reference it directly. Adds a real-bwrap test (gated behind GITNEXUS_REQUIRE_BWRAP_CANARY, same as the existing ones) that copies a real node binary to a path outside every bound tree and actually launches bwrap against it -- an argv-construction test alone can't catch a bwrap-level "Read-only file system" error, only a real invocation can, and that's exactly the gap that let #2607's version of this fix through review looking correct. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(eval): don't let the new real-bwrap test's node-mock break bwrap's own resolution CI caught this immediately: the new test_real_bubblewrap_runs_node_from_outside_the_bound_trees monkeypatched shutil.which to return None for anything but "node", but prepare_sandbox's own bwrap/claude resolution (_resolve_executable) goes through shutil.which too -- so the test broke bwrap discovery before the sandbox it's supposed to exercise could even be built ("SandboxError: required executable is unavailable: bwrap"). Delegate to the real shutil.which for every other name instead of blanket-returning None. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1158 lines
41 KiB
Python
1158 lines
41 KiB
Python
"""Unit tests for workflow benchmark sessions, arms, transcripts, and phase boundaries."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from workflow_bench import evolve, runner, runner_sessions, runtime_mounts
|
|
from workflow_bench.evolution import skill_fingerprint
|
|
from workflow_bench.process_control import ManagedProcessResult
|
|
from workflow_bench.runner import snapshot_plan_docs
|
|
|
|
|
|
def fake_cli_result(
|
|
stdout: str,
|
|
*,
|
|
returncode: int = 0,
|
|
stderr: str = "",
|
|
overflow: bool = False,
|
|
):
|
|
return ManagedProcessResult(
|
|
state="exited",
|
|
returncode=returncode,
|
|
stdout_tail=stdout,
|
|
stderr_tail=stderr,
|
|
duration_s=0.1,
|
|
stdout_capture=stdout.encode(),
|
|
stdout_capture_overflow=overflow,
|
|
)
|
|
|
|
|
|
VALID_REPORT = (
|
|
'{"type": "result", "session_id": "s", "num_turns": 3, "total_cost_usd": 0.1, "duration_ms": 1000,'
|
|
' "usage": {"input_tokens": 1, "cache_creation_input_tokens": 2,'
|
|
' "cache_read_input_tokens": 3, "output_tokens": 4}}'
|
|
)
|
|
|
|
|
|
def report_variant(**extra):
|
|
data = json.loads(VALID_REPORT)
|
|
data.update(extra)
|
|
return json.dumps(data)
|
|
|
|
|
|
def session_record(**overrides):
|
|
base = {
|
|
"input_tokens": 10,
|
|
"cache_creation_input_tokens": 1,
|
|
"cache_read_input_tokens": 2,
|
|
"output_tokens": 5,
|
|
"cost_usd": 0.1,
|
|
"duration_s": 1.0,
|
|
"num_turns": 2,
|
|
"ok": True,
|
|
"session_id": "sess",
|
|
"error_kind": None,
|
|
"error_detail": None,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def bench_args(**overrides):
|
|
base = {
|
|
"claude_bin": "claude",
|
|
"timeout": 5,
|
|
"model": None,
|
|
"base_url": None,
|
|
"auth_token": None,
|
|
"permission_mode": None,
|
|
}
|
|
base.update(overrides)
|
|
return argparse.Namespace(**base)
|
|
|
|
|
|
def event_stream(*events: dict, result_overrides: dict | None = None) -> str:
|
|
result = json.loads(VALID_REPORT)
|
|
result.update(result_overrides or {})
|
|
return "\n".join(json.dumps(event) for event in (*events, result)) + "\n"
|
|
|
|
|
|
def skill_events(skill_input: dict, *, tool_id: str = "skill-1", is_error: bool = False) -> list[dict]:
|
|
return [
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": tool_id,
|
|
"name": "Skill",
|
|
"input": skill_input,
|
|
}
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"type": "user",
|
|
"message": {
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": tool_id,
|
|
"is_error": is_error,
|
|
"content": "loaded",
|
|
}
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def fake_sandbox(root: Path) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
claude_bin="claude",
|
|
clone=root,
|
|
private_root=root,
|
|
command_prefix=[],
|
|
command_prefix_for=lambda **_kwargs: [],
|
|
settings_json="{}",
|
|
transcript_projects=root / "transcripts",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stdout", "expected_ok"),
|
|
[
|
|
(VALID_REPORT, True),
|
|
("", False), # empty output
|
|
("not json", False), # malformed JSON
|
|
('{"session_id": "s", "num_turns": 3}', False), # missing usage entirely
|
|
('{"usage": {"input_tokens": 1}}', False), # usage missing required fields
|
|
],
|
|
)
|
|
def test_run_claude_fails_closed_on_bad_reports(monkeypatch, tmp_path, stdout, expected_ok):
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stdout))
|
|
rec = runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5)
|
|
assert rec["ok"] is expected_ok
|
|
|
|
|
|
def test_parent_event_stream_rejects_non_finite_json_constants():
|
|
with pytest.raises(ValueError, match="malformed parent-captured event JSON"):
|
|
runner_sessions._parse_parent_event_stream(b'{"type":"assistant","score":NaN}\n')
|
|
|
|
|
|
def test_run_claude_forwards_the_named_model_to_every_session(monkeypatch, tmp_path):
|
|
captured: list[str] = []
|
|
|
|
def fake_run(command, **kwargs):
|
|
captured.extend(command)
|
|
return fake_cli_result(VALID_REPORT)
|
|
|
|
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
|
runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
model="claude-sonnet-4-20250514",
|
|
)
|
|
assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514"
|
|
|
|
|
|
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
|
|
# Outside --bare, the built-in toolset defaults to everything (subagents,
|
|
# WebFetch, Task, ...) and --allowedTools only pre-approves within that —
|
|
# it does not narrow it. --tools is what actually restricts the set, so a
|
|
# non-bare arm session must pass it or it silently gets a far wider
|
|
# toolset than intended.
|
|
captured: list[str] = []
|
|
|
|
def fake_run(command, **kwargs):
|
|
captured.extend(command)
|
|
return fake_cli_result(VALID_REPORT)
|
|
|
|
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
|
runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
bare=False,
|
|
allowed_tools=["Read", "Edit", "Bash", "Skill"],
|
|
)
|
|
tools_idx = captured.index("--tools")
|
|
assert captured[tools_idx + 1 : tools_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
|
|
allowed_idx = captured.index("--allowedTools")
|
|
assert captured[allowed_idx + 1 : allowed_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
|
|
|
|
|
|
def test_run_claude_omits_tools_flag_under_bare(monkeypatch, tmp_path):
|
|
# --bare already hard-restricts to Bash/Edit/Read on its own (a Claude
|
|
# Code design choice, not something --tools/--allowedTools can widen or
|
|
# narrow further), so bare sessions must not also pass --tools.
|
|
captured: list[str] = []
|
|
|
|
def fake_run(command, **kwargs):
|
|
captured.extend(command)
|
|
return fake_cli_result(VALID_REPORT)
|
|
|
|
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
|
runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
bare=True,
|
|
allowed_tools=["Read", "Edit", "Bash", "Skill"],
|
|
)
|
|
assert "--tools" not in captured
|
|
assert "--allowedTools" in captured
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("proc", "expected_kind"),
|
|
[
|
|
(fake_cli_result(VALID_REPORT), None),
|
|
(fake_cli_result(VALID_REPORT, returncode=1, stderr="boom"), "session-error"),
|
|
(fake_cli_result(report_variant(is_error=True)), "session-error"),
|
|
(fake_cli_result(report_variant(subtype="error_max_turns")), "session-error"),
|
|
(fake_cli_result(""), "session-error"), # malformed report
|
|
],
|
|
)
|
|
def test_run_claude_records_error_kind(monkeypatch, tmp_path, proc, expected_kind):
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: proc)
|
|
rec = runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5)
|
|
assert rec["error_kind"] == expected_kind
|
|
|
|
|
|
def test_run_claude_keeps_raw_subtype_and_stderr_tail(monkeypatch, tmp_path):
|
|
proc = fake_cli_result(VALID_REPORT, returncode=1, stderr="rate limit hit")
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: proc)
|
|
rec = runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5)
|
|
assert rec["error_detail"] == {
|
|
"subtype": None,
|
|
"returncode": 1,
|
|
"process_state": "exited",
|
|
"stderr_tail": "rate limit hit",
|
|
"stdout_tail": VALID_REPORT,
|
|
"process_detail": None,
|
|
"event_stream_error": None,
|
|
}
|
|
|
|
|
|
def test_run_claude_surfaces_stdout_tail_on_empty_stderr(monkeypatch, tmp_path):
|
|
# A session can exit non-zero with an EMPTY stderr (e.g. a pre-flight
|
|
# sandbox failure before any model turn ever runs) -- stdout_tail is then
|
|
# the only place the actual event stream is visible, so it must not be
|
|
# dropped just because stderr had nothing to say.
|
|
proc = fake_cli_result(VALID_REPORT, returncode=1, stderr="")
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: proc)
|
|
rec = runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5)
|
|
assert rec["error_detail"]["stderr_tail"] == ""
|
|
assert rec["error_detail"]["stdout_tail"] == VALID_REPORT
|
|
|
|
|
|
def test_run_arm_labels_completed_but_unverified_runs_verify_failed(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(runner, "run_claude", lambda *a, **k: session_record())
|
|
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (False, "failed"))
|
|
sandbox = fake_sandbox(tmp_path)
|
|
rec = runner.run_arm(
|
|
"baseline",
|
|
{"prompt": "p", "verify": "exit 1"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
)
|
|
assert rec["ok"] is True
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "verify-failed"
|
|
|
|
|
|
def test_run_arm_keeps_session_error_kind_over_verify(monkeypatch, tmp_path):
|
|
dead = session_record(ok=False, error_kind="session-error", error_detail={"subtype": "error_max_turns"})
|
|
monkeypatch.setattr(runner, "run_claude", lambda *a, **k: dict(dead))
|
|
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (True, "ok"))
|
|
sandbox = fake_sandbox(tmp_path)
|
|
rec = runner.run_arm(
|
|
"baseline",
|
|
{"prompt": "p", "verify": "exit 0"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
)
|
|
assert rec["ok"] is False
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "session-error"
|
|
|
|
|
|
def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, tmp_path):
|
|
read_only = runner.allowed_agent_tools(implementation=False)
|
|
implementation = runner.allowed_agent_tools(implementation=True)
|
|
no_mcp = runner.allowed_agent_tools(implementation=True, include_mcp=False)
|
|
|
|
assert read_only == [*runner.BUILTIN_AGENT_TOOLS, *runner.GITNEXUS_READ_ONLY_TOOLS]
|
|
assert implementation == [
|
|
*runner.BUILTIN_AGENT_TOOLS,
|
|
*runner.GITNEXUS_READ_ONLY_TOOLS,
|
|
*runner.GITNEXUS_MUTATING_TOOLS,
|
|
]
|
|
assert no_mcp == list(runner.BUILTIN_AGENT_TOOLS)
|
|
assert not any(tool.startswith("mcp__") for tool in no_mcp)
|
|
|
|
captured: list[dict[str, object]] = []
|
|
|
|
def fake_run_claude(*args, **kwargs):
|
|
captured.append(dict(kwargs))
|
|
return session_record()
|
|
|
|
monkeypatch.setattr(runner, "run_claude", fake_run_claude)
|
|
monkeypatch.setattr(runner, "run_verify", lambda *args, **kwargs: (True, "ok"))
|
|
sandbox = fake_sandbox(tmp_path)
|
|
for arm in ("workflow", "review", "workflow_direct", "baseline_nomcp"):
|
|
runner.run_arm(
|
|
arm,
|
|
{"prompt": "p", "verify": "true"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
)
|
|
|
|
assert captured[0]["allowed_tools"] == read_only # planning
|
|
assert captured[1]["allowed_tools"] == read_only # review
|
|
assert captured[2]["allowed_tools"] == implementation
|
|
assert captured[3]["allowed_tools"] == list(runner.BUILTIN_AGENT_TOOLS)
|
|
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
|
|
assert captured[3]["disallowed_tools"] == ["Skill", "mcp__gitnexus"]
|
|
|
|
# --bare hard-disables the Skill tool and every mcp__* tool regardless of
|
|
# --allowedTools (a Claude Code design choice, not something the harness
|
|
# can override) -- every arm here except baseline_nomcp needs Skill
|
|
# and/or MCP tools, so only baseline_nomcp may still run under --bare.
|
|
assert captured[0]["bare"] is False # workflow: planning session
|
|
assert captured[1]["bare"] is False # review
|
|
assert captured[2]["bare"] is False # workflow_direct
|
|
assert captured[3]["bare"] is True # baseline_nomcp
|
|
|
|
|
|
def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tmp_path):
|
|
runtime = tmp_path / "gitnexus"
|
|
shared = tmp_path / "gitnexus-shared"
|
|
for directory in (
|
|
runtime / "dist" / "cli",
|
|
runtime / "node_modules",
|
|
runtime / "vendor",
|
|
runtime / "hooks" / "claude",
|
|
shared / "dist",
|
|
):
|
|
directory.mkdir(parents=True)
|
|
(runtime / "dist" / "cli" / "index.js").write_text("")
|
|
(runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("")
|
|
(runtime / "package.json").write_text(json.dumps({"version": runner.PINNED_GITNEXUS_VERSION}))
|
|
(runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True)
|
|
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
|
|
monkeypatch.setattr(runtime_mounts, "HARNESS_ROOT", tmp_path)
|
|
|
|
config = json.loads(runner.sandbox_mcp_config())
|
|
server = config["mcpServers"]["gitnexus"]
|
|
command_line = [server["command"], *server["args"]]
|
|
|
|
assert runner.SANDBOX_GITNEXUS_ENTRYPOINT in command_line
|
|
assert not any(value.startswith("/workspace/") for value in command_line)
|
|
assert f"GITNEXUS_HOME={runner.SANDBOX_GITNEXUS_REGISTRY}" in command_line
|
|
assert "GITNEXUS_MCP_ALLOWED_REPOS=/workspace" in command_line
|
|
assert "GITNEXUS_MCP_DEFAULT_REPO=/workspace" in command_line
|
|
mounts = runner.trusted_gitnexus_runtime_mounts()
|
|
assert [(mount.source, mount.target) for mount in mounts] == [
|
|
(runtime / "dist", f"{runner.SANDBOX_GITNEXUS}/dist"),
|
|
(runtime / "package.json", f"{runner.SANDBOX_GITNEXUS}/package.json"),
|
|
(runtime / "node_modules", f"{runner.SANDBOX_GITNEXUS}/node_modules"),
|
|
(runtime / "vendor", f"{runner.SANDBOX_GITNEXUS}/vendor"),
|
|
(shared / "dist", f"{runner.SANDBOX_GITNEXUS_SHARED}/dist"),
|
|
(shared / "package.json", f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"),
|
|
(runtime / "hooks" / "claude", f"{runner.SANDBOX_GITNEXUS}/hooks/claude"),
|
|
]
|
|
package = json.loads((runtime / "package.json").read_text())
|
|
assert package["version"] == runner.PINNED_GITNEXUS_VERSION
|
|
|
|
mounted_sources = {mount.source for mount in mounts}
|
|
mounted_targets = {mount.target for mount in mounts}
|
|
assert runtime not in mounted_sources
|
|
assert shared not in mounted_sources
|
|
for forbidden in (".env", ".env.example", ".npmrc", ".git", ".gitnexus", "src", "test", "tests", "skills"):
|
|
assert runtime / forbidden not in mounted_sources
|
|
assert f"{runner.SANDBOX_GITNEXUS}/{forbidden}" not in mounted_targets
|
|
assert shared / forbidden not in mounted_sources
|
|
assert f"{runner.SANDBOX_GITNEXUS_SHARED}/{forbidden}" not in mounted_targets
|
|
|
|
# Only hooks/claude is exposed, not the whole hooks/ directory (which also
|
|
# has an unrelated hooks/antigravity/ tree) and not the runtime root itself.
|
|
assert runtime / "hooks" not in mounted_sources
|
|
assert runtime / "hooks" / "antigravity" not in mounted_sources
|
|
assert f"{runner.SANDBOX_GITNEXUS}/hooks" not in mounted_targets
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
|
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
|
)
|
|
def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp_path):
|
|
clone = tmp_path / "clone"
|
|
clone.mkdir()
|
|
mounts = runner.trusted_gitnexus_runtime_mounts()
|
|
|
|
required = [
|
|
runner.SANDBOX_GITNEXUS_ENTRYPOINT,
|
|
f"{runner.SANDBOX_GITNEXUS}/package.json",
|
|
f"{runner.SANDBOX_GITNEXUS}/node_modules",
|
|
f"{runner.SANDBOX_GITNEXUS}/vendor",
|
|
f"{runner.SANDBOX_GITNEXUS_SHARED}/dist/index.js",
|
|
f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json",
|
|
f"{runner.SANDBOX_GITNEXUS}/hooks/claude/resolve-analyze-cmd.cjs",
|
|
]
|
|
forbidden = [
|
|
f"{runner.SANDBOX_GITNEXUS}/{relative}"
|
|
for relative in (".env", ".env.example", ".npmrc", ".git", ".gitnexus", "src", "test", "tests", "skills")
|
|
] + [
|
|
f"{runner.SANDBOX_GITNEXUS_SHARED}/{relative}"
|
|
for relative in (".env", ".env.example", ".npmrc", ".git", ".gitnexus", "src", "test", "tests", "skills")
|
|
]
|
|
visibility_script = (
|
|
"const fs=require('fs');"
|
|
f"for(const p of {json.dumps(required)}) fs.accessSync(p,fs.constants.R_OK);"
|
|
f"for(const p of {json.dumps(forbidden)}) "
|
|
"if(fs.existsSync(p))throw new Error('unexpected checkout path: '+p);"
|
|
)
|
|
|
|
with runner.prepare_sandbox(
|
|
clone=clone,
|
|
claude_bin=Path(sys.executable),
|
|
read_only_mounts=mounts,
|
|
preflight=True,
|
|
) as sandbox:
|
|
visibility = sandbox.run(
|
|
[runner.SANDBOX_NODE, "-e", visibility_script],
|
|
timeout=10,
|
|
)
|
|
imported = sandbox.run(
|
|
[runner.SANDBOX_NODE, runner.SANDBOX_GITNEXUS_ENTRYPOINT, "--version"],
|
|
timeout=10,
|
|
)
|
|
# --version never reaches the `analyze` command, which is loaded via a
|
|
# lazy dynamic import and is the only path that pulls in
|
|
# resolve-invocation.ts's module-load-time require of hooks/claude/
|
|
# resolve-analyze-cmd.cjs. Require the compiled analyze module
|
|
# directly so this canary actually exercises that chain.
|
|
analyze_imported = sandbox.run(
|
|
[runner.SANDBOX_NODE, "-e", f"require('{runner.SANDBOX_GITNEXUS}/dist/cli/analyze.js')"],
|
|
timeout=10,
|
|
)
|
|
|
|
assert visibility.ok, visibility.stderr_tail
|
|
assert imported.ok, imported.stderr_tail
|
|
assert analyze_imported.ok, analyze_imported.stderr_tail
|
|
assert imported.stdout_tail.strip() == runner.PINNED_GITNEXUS_VERSION
|
|
|
|
|
|
def test_isolated_mcp_registry_contains_only_the_sandbox_clone(tmp_path):
|
|
worktree = tmp_path / "clone"
|
|
metadata = worktree / ".gitnexus" / "gitnexus.json"
|
|
metadata.parent.mkdir(parents=True)
|
|
metadata.write_text(
|
|
json.dumps(
|
|
{
|
|
"indexedAt": "2026-07-18T00:00:00Z",
|
|
"lastCommit": "a" * 40,
|
|
"stats": {"files": 1},
|
|
}
|
|
)
|
|
)
|
|
|
|
mount = runner.isolated_gitnexus_registry_mount(worktree, tmp_path)
|
|
registry_file = mount.source / "registry.json"
|
|
registry = json.loads(registry_file.read_text())
|
|
|
|
assert mount.target == runner.SANDBOX_GITNEXUS_REGISTRY
|
|
assert mount.source.stat().st_mode & 0o777 == 0o700
|
|
assert registry_file.stat().st_mode & 0o777 == 0o600
|
|
assert registry == [
|
|
{
|
|
"indexedAt": "2026-07-18T00:00:00Z",
|
|
"lastCommit": "a" * 40,
|
|
"name": "benchmark-target",
|
|
"path": "/workspace",
|
|
"stats": {"files": 1},
|
|
"storagePath": "/workspace/.gitnexus",
|
|
}
|
|
]
|
|
|
|
|
|
def test_resolved_implementation_without_repository_work_fails_closed():
|
|
rec = {"resolved": True, "error_kind": None, "error_detail": None}
|
|
runner.enforce_work_evidence(
|
|
rec,
|
|
arm="workflow_direct",
|
|
before_digest="same",
|
|
after_digest="same",
|
|
)
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "no-work-produced"
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="sandbox patch streaming uses POSIX executable paths")
|
|
def test_capture_patch_materializes_only_the_bounded_prefix(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "--quiet", str(repo)], check=True)
|
|
changed = repo / "changed.txt"
|
|
changed.write_text("before\n")
|
|
subprocess.run(["git", "-C", str(repo), "add", "changed.txt"], check=True)
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(repo),
|
|
"-c",
|
|
"user.name=test",
|
|
"-c",
|
|
"user.email=test@invalid",
|
|
"commit",
|
|
"--quiet",
|
|
"-m",
|
|
"base",
|
|
],
|
|
check=True,
|
|
)
|
|
orig_sha = subprocess.run(
|
|
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
changed.write_text("changed line\n" * 100_000)
|
|
|
|
class LocalSandbox:
|
|
def run(self, command, **kwargs):
|
|
translated = [
|
|
str(repo) + item.removeprefix("/workspace") if item.startswith("/workspace/") else item
|
|
for item in command
|
|
]
|
|
completed = subprocess.run(
|
|
translated,
|
|
cwd=repo,
|
|
env=dict(kwargs["env"]),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return ManagedProcessResult(
|
|
state="exited",
|
|
returncode=completed.returncode,
|
|
stdout_tail=completed.stdout,
|
|
stderr_tail=completed.stderr,
|
|
duration_s=0.0,
|
|
)
|
|
|
|
patch = runner.capture_patch(LocalSandbox(), repo, orig_sha)
|
|
|
|
assert len(patch) == runner.MAX_PATCH_BYTES
|
|
materialized = next(repo.glob(".wfbench-artifact-*/final.patch"))
|
|
assert materialized.stat().st_size == runner.MAX_PATCH_BYTES
|
|
|
|
|
|
def test_workflow_never_starts_work_after_failed_or_invalid_planning(monkeypatch, tmp_path):
|
|
sandbox = fake_sandbox(tmp_path)
|
|
calls: list[str] = []
|
|
|
|
def failed_plan(prompt, *args, **kwargs):
|
|
calls.append(prompt)
|
|
return session_record(ok=False, error_kind="session-error", error_detail="planning failed")
|
|
|
|
monkeypatch.setattr(runner, "run_claude", failed_plan)
|
|
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (True, "ok"))
|
|
failed = runner.run_arm(
|
|
"workflow",
|
|
{"prompt": "p", "verify": "exit 0"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
)
|
|
assert len(calls) == 1
|
|
assert failed["resolved"] is False
|
|
assert failed["plan_produced"] is False
|
|
|
|
calls.clear()
|
|
monkeypatch.setattr(
|
|
runner,
|
|
"run_claude",
|
|
lambda prompt, *args, **kwargs: calls.append(prompt) or session_record(),
|
|
)
|
|
invalid = runner.run_arm(
|
|
"workflow",
|
|
{"prompt": "p", "verify": "exit 0"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
)
|
|
assert len(calls) == 1
|
|
assert invalid["resolved"] is False
|
|
assert invalid["error_kind"] == "plan-evidence-invalid"
|
|
|
|
|
|
def test_skill_invocation_is_detected_from_parent_event_stream(monkeypatch, tmp_path):
|
|
stream = event_stream(*skill_events({"command": "gitnexus-work"}))
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stream))
|
|
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
)
|
|
|
|
assert rec["ok"] is True
|
|
assert rec["skill_invoked"] is True
|
|
assert rec["error_kind"] is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"skill_input",
|
|
[
|
|
{"skill": "gitnexus-work"},
|
|
{"command": "/gitnexus-work execute the plan"},
|
|
{"name": "gitnexus-work direct-mode"},
|
|
],
|
|
)
|
|
def test_skill_invocation_parses_supported_exact_identifier_fields(skill_input):
|
|
assert (
|
|
runner_sessions.skill_was_invoked_events(
|
|
skill_events(skill_input),
|
|
"gitnexus-work",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"skill_input",
|
|
[
|
|
{"skill": "gitnexus-work-extra"},
|
|
{"skill": "prefix-gitnexus-work"},
|
|
{"command": "/gitnexus-work-extra execute"},
|
|
{"command": "other-skill", "args": "please use gitnexus-work"},
|
|
{"name": "other-skill", "description": "gitnexus-work"},
|
|
{"args": "gitnexus-work"},
|
|
],
|
|
)
|
|
def test_skill_invocation_rejects_prefix_suffix_and_argument_mentions(skill_input):
|
|
assert (
|
|
runner_sessions.skill_was_invoked_events(
|
|
skill_events(skill_input),
|
|
"gitnexus-work",
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_skill_invocation_scans_through_eof_and_rejects_later_malformed_json(monkeypatch, tmp_path):
|
|
stream = event_stream(*skill_events({"skill": "gitnexus-work"})) + '{"truncated":'
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stream))
|
|
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
)
|
|
|
|
assert rec["ok"] is False
|
|
assert rec["error_kind"] == "session-error"
|
|
assert "malformed parent-captured event JSON" in rec["error_detail"]["event_stream_error"]
|
|
|
|
|
|
def test_matching_skill_requires_one_later_successful_result(monkeypatch, tmp_path):
|
|
failed_stream = event_stream(*skill_events({"skill": "gitnexus-work"}, is_error=True))
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(failed_stream))
|
|
failed = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
)
|
|
assert failed["skill_invoked"] is False
|
|
assert failed["error_kind"] == "skill-not-invoked"
|
|
|
|
missing_result = event_stream(skill_events({"skill": "gitnexus-work"})[0])
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(missing_result))
|
|
missing = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
)
|
|
assert missing["skill_invoked"] is None
|
|
assert missing["error_kind"] == "evidence-unverified"
|
|
assert "no tool result" in missing["error_detail"]
|
|
|
|
|
|
def test_skill_evidence_rejects_duplicate_tool_use_and_result_ids():
|
|
request, result = skill_events({"skill": "gitnexus-work"})
|
|
duplicate_request = json.loads(json.dumps(request))
|
|
with pytest.raises(ValueError, match="duplicate tool-use id"):
|
|
runner_sessions.skill_was_invoked_events(
|
|
[request, duplicate_request, result],
|
|
"gitnexus-work",
|
|
)
|
|
|
|
error_result = json.loads(json.dumps(result))
|
|
error_result["message"]["content"][0]["is_error"] = True
|
|
with pytest.raises(ValueError, match="duplicate tool result"):
|
|
runner_sessions.skill_was_invoked_events(
|
|
[request, result, error_result],
|
|
"gitnexus-work",
|
|
)
|
|
|
|
|
|
def test_agent_writable_home_transcript_cannot_forge_skill_evidence(monkeypatch, tmp_path):
|
|
forged = tmp_path / ".claude" / "projects" / "forged" / "s.jsonl"
|
|
forged.parent.mkdir(parents=True)
|
|
forged.write_text(event_stream(*skill_events({"skill": "gitnexus-work"})))
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(VALID_REPORT))
|
|
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
transcript_projects=tmp_path / ".claude" / "projects",
|
|
)
|
|
|
|
assert rec["skill_invoked"] is False
|
|
assert rec["error_kind"] == "skill-not-invoked"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"arm",
|
|
["workflow", "workflow_direct", "ce_workflow", "ce_workflow_direct"],
|
|
)
|
|
def test_every_skill_implementation_session_rechecks_fingerprint_immediately(
|
|
monkeypatch,
|
|
tmp_path,
|
|
arm,
|
|
):
|
|
plan = tmp_path / "docs" / "plans" / "plan.md"
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
def fake_run(prompt, *args, **kwargs):
|
|
if "deliverable" in prompt:
|
|
plan.parent.mkdir(parents=True, exist_ok=True)
|
|
plan.write_text("plan")
|
|
return session_record()
|
|
|
|
def fingerprint(*args, **kwargs):
|
|
calls.append((args[1], kwargs["phase"]))
|
|
raise ValueError("implementation changed the evaluated skill fingerprint")
|
|
|
|
monkeypatch.setattr(runner, "run_claude", fake_run)
|
|
monkeypatch.setattr(runner, "require_skill_fingerprint", fingerprint)
|
|
monkeypatch.setattr(runner, "run_verify", lambda *args, **kwargs: (True, "ok"))
|
|
rec = runner.run_arm(
|
|
arm,
|
|
{"prompt": "p", "verify": "true"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=fake_sandbox(tmp_path),
|
|
expected_skill_digest="trusted",
|
|
)
|
|
|
|
assert calls == [(arm, "implementation")]
|
|
assert rec["ok"] is False
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "implementation-evidence-invalid"
|
|
|
|
|
|
def test_direct_implementation_skill_mutation_fails_before_verification(monkeypatch, tmp_path):
|
|
skill = tmp_path / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
|
|
skill.parent.mkdir(parents=True)
|
|
skill.write_text("trusted prompt")
|
|
expected = skill_fingerprint(tmp_path, "workflow_direct")
|
|
order: list[str] = []
|
|
real_fingerprint_check = runner.require_skill_fingerprint
|
|
|
|
def mutating_session(*args, **kwargs):
|
|
order.append("session")
|
|
skill.write_text("model replaced prompt")
|
|
return session_record()
|
|
|
|
def tracked_fingerprint(*args, **kwargs):
|
|
order.append("fingerprint")
|
|
return real_fingerprint_check(*args, **kwargs)
|
|
|
|
def verify(*args, **kwargs):
|
|
order.append("verify")
|
|
return True, "ok"
|
|
|
|
monkeypatch.setattr(runner, "run_claude", mutating_session)
|
|
monkeypatch.setattr(runner, "require_skill_fingerprint", tracked_fingerprint)
|
|
monkeypatch.setattr(runner, "run_verify", verify)
|
|
rec = runner.run_arm(
|
|
"workflow_direct",
|
|
{"prompt": "p", "verify": "true"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=fake_sandbox(tmp_path),
|
|
expected_skill_digest=expected,
|
|
)
|
|
|
|
assert order == ["session", "fingerprint", "verify"]
|
|
assert rec["ok"] is False
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "implementation-evidence-invalid"
|
|
|
|
|
|
def test_parent_event_stream_is_persisted_private_redacted_and_digest_bound(monkeypatch, tmp_path):
|
|
secret = "sk-ant-transcript-secret"
|
|
bearer = "Authorization: Bearer bearer-postmortem-secret"
|
|
structural_secret = "token-in-authorization-value"
|
|
password = "password-in-structured-field"
|
|
events = skill_events({"command": "gitnexus-work"})
|
|
events.insert(
|
|
1,
|
|
{
|
|
"type": "assistant",
|
|
"message": {"content": [{"type": "text", "text": secret}]},
|
|
},
|
|
)
|
|
events.insert(
|
|
2,
|
|
{
|
|
"type": "assistant",
|
|
"message": {"content": [{"type": "text", "text": bearer}]},
|
|
},
|
|
)
|
|
events.insert(
|
|
3,
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"metadata": {
|
|
"Authorization": f"Bearer {structural_secret}",
|
|
"password": password,
|
|
},
|
|
}
|
|
]
|
|
},
|
|
},
|
|
)
|
|
stream = event_stream(*events)
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stream))
|
|
output = tmp_path / "run-output"
|
|
output.mkdir()
|
|
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
transcript_output_dir=output,
|
|
transcript_output_prefix="task-workflow-run0",
|
|
transcript_secrets=(secret,),
|
|
)
|
|
|
|
artifact_meta = rec["transcript_artifact"]
|
|
artifact = output / artifact_meta["path"]
|
|
assert artifact_meta["path"] == "transcripts/task-workflow-run0-s.jsonl"
|
|
assert artifact.stat().st_mode & 0o777 == 0o600
|
|
assert artifact.parent.stat().st_mode & 0o777 == 0o700
|
|
assert secret not in artifact.read_text()
|
|
assert "bearer-postmortem-secret" not in artifact.read_text()
|
|
assert structural_secret not in artifact.read_text()
|
|
assert password not in artifact.read_text()
|
|
assert artifact.read_text().count("[REDACTED]") >= 4
|
|
assert all(isinstance(json.loads(line), dict) for line in artifact.read_text().splitlines())
|
|
assert artifact_meta["bytes"] == artifact.stat().st_size
|
|
assert artifact_meta["sha256"] == hashlib.sha256(artifact.read_bytes()).hexdigest()
|
|
assert artifact_meta["source"] == "parent-captured-stream-json"
|
|
|
|
# PR #2566 P1 regression: sum_sessions forwards the producer's 4-key record
|
|
# (including `source`) into transcript_artifacts, and a --seed-results / gen>=2
|
|
# run JSON round-trips it through the proposer evidence preflight. That preflight
|
|
# once required exactly {path, sha256, bytes} and aborted every seeded run with
|
|
# SandboxError. Round-trip real producer output through the real preflight so the
|
|
# producer/validator schema can never drift apart again.
|
|
seeded = json.loads(json.dumps(runner_sessions.sum_sessions([rec])))
|
|
evolve._preflight_transcript_artifacts([{"transcript_artifacts": seeded["transcript_artifacts"]}])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
(0.0, 0.0),
|
|
(1.25, 1.25),
|
|
(3, 3.0),
|
|
(None, None),
|
|
("free", None),
|
|
(True, None),
|
|
(-1.0, None),
|
|
(float("nan"), None),
|
|
(float("inf"), None),
|
|
],
|
|
)
|
|
def test_measured_cost_distinguishes_absent_from_zero(raw, expected):
|
|
# A measured $0 stays 0.0; an absent/garbage cost becomes None so it can
|
|
# never be scored as a real zero the promotion gate ranks on.
|
|
assert runner_sessions.measured_cost(raw) == expected
|
|
|
|
|
|
def test_missing_skill_invocation_fails_closed(monkeypatch, tmp_path):
|
|
read_events = skill_events({"skill": "other-skill"}, tool_id="read-1")
|
|
monkeypatch.setattr(
|
|
runner_sessions,
|
|
"run_managed",
|
|
lambda *a, **k: fake_cli_result(event_stream(*read_events)),
|
|
)
|
|
rec = runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5, expected_skill="gitnexus-work")
|
|
assert rec["ok"] is False
|
|
assert rec["skill_invoked"] is False
|
|
assert rec["error_kind"] == "skill-not-invoked"
|
|
|
|
|
|
def test_overflowed_parent_capture_is_ineligible_evidence(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(
|
|
runner_sessions,
|
|
"run_managed",
|
|
lambda *a, **k: fake_cli_result(VALID_REPORT, overflow=True),
|
|
)
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
expected_skill="gitnexus-work",
|
|
)
|
|
assert rec["ok"] is False
|
|
assert rec["skill_invoked"] is None
|
|
assert rec["error_kind"] == "session-error"
|
|
assert "exceeds" in rec["error_detail"]["event_stream_error"]
|
|
|
|
|
|
def test_final_result_event_must_be_last(monkeypatch, tmp_path):
|
|
stream = VALID_REPORT + "\n" + json.dumps({"type": "assistant", "message": {"content": []}}) + "\n"
|
|
monkeypatch.setattr(runner_sessions, "run_managed", lambda *a, **k: fake_cli_result(stream))
|
|
rec = runner.run_claude(
|
|
"task",
|
|
tmp_path,
|
|
claude_bin="claude",
|
|
timeout=5,
|
|
)
|
|
|
|
assert rec["ok"] is False
|
|
assert rec["error_kind"] == "session-error"
|
|
assert "not the last event" in rec["error_detail"]["event_stream_error"]
|
|
|
|
|
|
def test_snapshot_plan_docs_detects_one_modified_plan_and_rejects_ambiguous_output(tmp_path):
|
|
plans = tmp_path / "docs" / "plans"
|
|
plans.mkdir(parents=True)
|
|
first = plans / "one.md"
|
|
first.write_text("before")
|
|
before = snapshot_plan_docs(tmp_path)
|
|
first.write_text("after")
|
|
assert runner.new_plan_doc(tmp_path, before) == first
|
|
|
|
second = plans / "two.md"
|
|
second.write_text("new")
|
|
first.write_text("changed again")
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
runner.new_plan_doc(tmp_path, before)
|
|
|
|
|
|
def test_plan_output_rejects_unchanged_deleted_and_symlink_paths(tmp_path):
|
|
plans = tmp_path / "docs" / "plans"
|
|
plans.mkdir(parents=True)
|
|
plan = plans / "one.md"
|
|
plan.write_text("same")
|
|
before = snapshot_plan_docs(tmp_path)
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
runner.new_plan_doc(tmp_path, before)
|
|
|
|
plan.unlink()
|
|
with pytest.raises(ValueError, match="deleted"):
|
|
runner.new_plan_doc(tmp_path, before)
|
|
|
|
target = tmp_path / "outside.md"
|
|
target.write_text("outside")
|
|
plan.symlink_to(target)
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
runner.new_plan_doc(tmp_path, {})
|
|
|
|
|
|
def test_setup_skill_fingerprint_change_fails_closed(tmp_path):
|
|
for skill in ("gitnexus-plan", "gitnexus-work"):
|
|
path = tmp_path / ".claude" / "skills" / skill / "SKILL.md"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(f"{skill} original")
|
|
expected = skill_fingerprint(tmp_path, "workflow")
|
|
(tmp_path / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md").write_text("replaced by setup")
|
|
|
|
with pytest.raises(ValueError, match="task setup changed the evaluated skill fingerprint"):
|
|
runner.require_skill_fingerprint(tmp_path, "workflow", expected, phase="task setup")
|
|
|
|
|
|
def test_planning_cannot_change_source_tests_or_downstream_skill(monkeypatch, tmp_path):
|
|
for skill in ("gitnexus-plan", "gitnexus-work"):
|
|
path = tmp_path / ".claude" / "skills" / skill / "SKILL.md"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(f"{skill} original")
|
|
source = tmp_path / "source.py"
|
|
source.write_text("original")
|
|
expected = skill_fingerprint(tmp_path, "workflow")
|
|
calls: list[str] = []
|
|
|
|
def adversarial_plan(prompt, *args, **kwargs):
|
|
calls.append(prompt)
|
|
plans = tmp_path / "docs" / "plans"
|
|
plans.mkdir(parents=True)
|
|
(plans / "authorized.md").write_text("plan")
|
|
source.write_text("pre-implemented by planning")
|
|
(tmp_path / ".claude" / "skills" / "gitnexus-work" / "SKILL.md").write_text("weakened")
|
|
return session_record()
|
|
|
|
monkeypatch.setattr(runner, "run_claude", adversarial_plan)
|
|
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (True, "ok"))
|
|
sandbox = fake_sandbox(tmp_path)
|
|
|
|
rec = runner.run_arm(
|
|
"workflow",
|
|
{"prompt": "p", "verify": "true"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
expected_skill_digest=expected,
|
|
enforce_phase_boundary=True,
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert rec["ok"] is False
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "plan-evidence-invalid"
|
|
assert "unauthorized workspace path" in rec["error_detail"]
|
|
|
|
|
|
@pytest.mark.parametrize("arm", ["review", "ce_review"])
|
|
@pytest.mark.parametrize(
|
|
("attack", "expected_detail"),
|
|
[
|
|
("workspace", "unauthorized workspace path"),
|
|
("skill", "changed the evaluated skill fingerprint"),
|
|
],
|
|
)
|
|
def test_review_phase_rejects_workspace_or_skill_mutation(
|
|
monkeypatch,
|
|
tmp_path,
|
|
arm,
|
|
attack,
|
|
expected_detail,
|
|
):
|
|
source = tmp_path / "source.py"
|
|
source.write_text("original")
|
|
expected_skill_digest = "expected-skill-fingerprint"
|
|
|
|
def adversarial_review(prompt, *args, **kwargs):
|
|
(tmp_path / "review-output.md").write_text("review findings")
|
|
if attack == "workspace":
|
|
source.write_text("review silently changed source")
|
|
return session_record()
|
|
|
|
observed_skill_digest = "tampered-skill-fingerprint" if attack == "skill" else expected_skill_digest
|
|
monkeypatch.setattr(runner, "run_claude", adversarial_review)
|
|
monkeypatch.setattr(
|
|
runner,
|
|
"skill_fingerprint",
|
|
lambda worktree, checked_arm: observed_skill_digest,
|
|
)
|
|
monkeypatch.setattr(runner, "run_verify", lambda *a, **k: (True, "ok"))
|
|
sandbox = fake_sandbox(tmp_path)
|
|
|
|
rec = runner.run_arm(
|
|
arm,
|
|
{"prompt": "p", "verify": "true"},
|
|
tmp_path,
|
|
bench_args(),
|
|
sandbox=sandbox,
|
|
expected_skill_digest=expected_skill_digest,
|
|
enforce_phase_boundary=True,
|
|
)
|
|
|
|
assert rec["ok"] is False
|
|
assert rec["resolved"] is False
|
|
assert rec["error_kind"] == "review-evidence-invalid"
|
|
assert expected_detail in rec["error_detail"]
|
|
|
|
|
|
def _git(repo, *args, check=True):
|
|
return subprocess.run(["git", "-C", str(repo), *args], check=check, capture_output=True, text=True)
|
|
|
|
|
|
def _git_commit(repo, message):
|
|
_git(
|
|
repo,
|
|
"-c",
|
|
"user.name=test",
|
|
"-c",
|
|
"user.email=test@invalid",
|
|
"commit",
|
|
"--quiet",
|
|
"--allow-empty",
|
|
"-m",
|
|
message,
|
|
)
|
|
return _git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
|
|
|
|
def test_make_worktree_clone_has_no_tags_but_keeps_all_branches(tmp_path):
|
|
# oracle_assets.MAX_CLONE_REFS refuses to sanitize a clone with more than
|
|
# 1024 refs; this repo's own history has 1000+ release-candidate tags, so
|
|
# a plain `git clone` of it (inheriting every tag) trips that cap on every
|
|
# benchmark session. make_worktree must not carry tags into its throwaway
|
|
# clone, but callers pass a bare SHA or "HEAD" as `ref` (never a branch
|
|
# name -- see evolve.py:476, runner.py:1037, sanitized_graph.py:345), so
|
|
# branch-fetching itself must stay untouched: a commit reachable only from
|
|
# a non-default branch must still resolve via the existing
|
|
# checkout(ref) -> checkout(origin/{ref}) fallback.
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
_git(repo, "init", "--quiet")
|
|
_git(repo, "checkout", "--quiet", "-b", "main")
|
|
_git_commit(repo, "base")
|
|
_git(repo, "tag", "v1.0.0-rc.1")
|
|
|
|
_git(repo, "checkout", "--quiet", "-b", "other")
|
|
other_sha = _git_commit(repo, "only on other")
|
|
_git(repo, "checkout", "--quiet", "main")
|
|
|
|
clones = tmp_path / "clones"
|
|
clones.mkdir()
|
|
target = runner.make_worktree(repo, other_sha, clones)
|
|
|
|
tags = _git(target, "tag").stdout.split()
|
|
assert tags == [], f"clone must carry no tags, found: {tags}"
|
|
|
|
current = _git(target, "rev-parse", "HEAD").stdout.strip()
|
|
assert current == other_sha
|