Constrain controlled CLI sessions and prove native budget overshoot
Some checks failed
Governed runtime contract / contract (push) Failing after 27s
Some checks failed
Governed runtime contract / contract (push) Failing after 27s
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
e0b3ff99a2
commit
4ae245a88f
14 changed files with 752 additions and 10 deletions
228
tests/native_cli_fixture.py
Normal file
228
tests/native_cli_fixture.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Runs inside a network namespace: deterministic HTTP responses, no inference."""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
root = Path("/work")
|
||||
settings = json.loads((root / "case.json").read_text())
|
||||
seen = []
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||
results = []
|
||||
for message in body.get("messages", []):
|
||||
if isinstance(message.get("content"), list):
|
||||
results.extend(
|
||||
{
|
||||
"id": block.get("tool_use_id"),
|
||||
"is_error": block.get("is_error", False),
|
||||
}
|
||||
for block in message["content"]
|
||||
if block.get("type") == "tool_result"
|
||||
)
|
||||
seen.append(
|
||||
{
|
||||
"path": self.path,
|
||||
"model": body.get("model"),
|
||||
"max_tokens": body.get("max_tokens"),
|
||||
"tools": [tool.get("name") for tool in body.get("tools", [])],
|
||||
"tool_results": results,
|
||||
"ambient_context_loaded": "AMBIENT_CONTEXT_SENTINEL"
|
||||
in json.dumps(body),
|
||||
}
|
||||
)
|
||||
if "count_tokens" in self.path:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"input_tokens":100}')
|
||||
return
|
||||
message_number = sum("count_tokens" not in request["path"] for request in seen)
|
||||
if settings["case"] == "tools" and message_number == 1:
|
||||
blocks = [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_allowed",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git status",
|
||||
"description": "Read fixture status",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_create",
|
||||
"name": "Edit",
|
||||
"input": {
|
||||
"file_path": "/work/target/result.txt",
|
||||
"old_string": "",
|
||||
"new_string": "fixture-created\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_denied",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "printf bypass > /work/denied-ran",
|
||||
"description": "Fixture forbidden operation",
|
||||
},
|
||||
},
|
||||
]
|
||||
stop = "tool_use"
|
||||
else:
|
||||
blocks = [{"type": "text", "text": "fixture complete"}]
|
||||
stop = "end_turn"
|
||||
usage = {
|
||||
"input_tokens": 60000 if settings["case"] == "overrun" else 100,
|
||||
"output_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
events = [
|
||||
(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_fixture_" + str(message_number),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": usage,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
for index, block in enumerate(blocks):
|
||||
if block["type"] == "text":
|
||||
start = {"type": "text", "text": ""}
|
||||
delta = {"type": "text_delta", "text": block["text"]}
|
||||
else:
|
||||
start = {**block, "input": {}}
|
||||
delta = {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(block["input"]),
|
||||
}
|
||||
events += [
|
||||
(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": start,
|
||||
},
|
||||
),
|
||||
(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": index, "delta": delta},
|
||||
),
|
||||
("content_block_stop", {"type": "content_block_stop", "index": index}),
|
||||
]
|
||||
events += [
|
||||
(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop, "stop_sequence": None},
|
||||
"usage": {**usage, "output_tokens": 10},
|
||||
},
|
||||
),
|
||||
("message_stop", {"type": "message_stop"}),
|
||||
]
|
||||
encoded = "".join(
|
||||
"event: " + name + "\ndata: " + json.dumps(value) + "\n\n"
|
||||
for name, value in events
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
(root / "target" / ".claude").mkdir(parents=True)
|
||||
(root / "target" / ".claude" / "settings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"permissions": {"allow": ["Bash"]},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{"hooks": [{"type": "command", "command": "touch /work/hook-ran"}]}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
(root / "target" / ".mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mcpServers": {
|
||||
"fixture": {"command": "/bin/sh", "args": ["-c", "touch /work/mcp-ran"]}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
(root / "target" / "CLAUDE.md").write_text("AMBIENT_CONTEXT_SENTINEL\n")
|
||||
subprocess.run(["git", "init", "-q", str(root / "target")], check=True)
|
||||
env = dict(
|
||||
os.environ,
|
||||
ANTHROPIC_BASE_URL="http://127.0.0.1:" + str(server.server_port),
|
||||
ANTHROPIC_API_KEY="fixture-no-provider-secret",
|
||||
CLAUDE_CONFIG_DIR="/work/config",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1",
|
||||
DISABLE_AUTOUPDATER="1",
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
settings["argv"],
|
||||
input="Run the deterministic fixture.",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=env,
|
||||
cwd="/work/target",
|
||||
timeout=45,
|
||||
)
|
||||
terminal = json.loads(proc.stdout)
|
||||
output = {
|
||||
"returncode": proc.returncode,
|
||||
"terminal": {
|
||||
key: terminal.get(key)
|
||||
for key in (
|
||||
"type",
|
||||
"subtype",
|
||||
"is_error",
|
||||
"total_cost_usd",
|
||||
"num_turns",
|
||||
"usage",
|
||||
"permission_denials",
|
||||
)
|
||||
},
|
||||
"requests": seen,
|
||||
"markers": {
|
||||
name: (root / name).exists()
|
||||
for name in ("hook-ran", "mcp-ran", "denied-ran")
|
||||
},
|
||||
"created_file": (root / "target" / "result.txt").read_text()
|
||||
if (root / "target" / "result.txt").exists()
|
||||
else None,
|
||||
"network_namespace": os.readlink("/proc/self/ns/net"),
|
||||
}
|
||||
print(json.dumps(output))
|
||||
finally:
|
||||
server.shutdown()
|
||||
148
tests/test_native_cli_boundary.py
Normal file
148
tests/test_native_cli_boundary.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Opt-in installed Claude binary against a namespace-local synthetic API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from llm_connect.models import RunConfig
|
||||
|
||||
from rein_aharness.adapter import AgenticClaudeCodeAdapter
|
||||
from rein_aharness.native_limits import NativeLimitError, terminal_accounting
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("REIN_REAL_CLAUDE") != "1",
|
||||
reason="opt-in installed CLI boundary proof",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["overrun", "tools"])
|
||||
def test_installed_cli_boundary(tmp_path, case):
|
||||
binary = Path(
|
||||
os.environ.get("REIN_CLAUDE_PROOF_BINARY") or shutil.which("claude") or ""
|
||||
).resolve(strict=True)
|
||||
version = subprocess.check_output([str(binary), "--version"], text=True).strip()
|
||||
assert version.startswith("2.1.266 "), (
|
||||
"revalidate the fixture for this binary version before claiming the pinned proof"
|
||||
)
|
||||
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, model="claude-sonnet-4-6")
|
||||
cap = 0.01 if case == "overrun" else 1
|
||||
argv = adapter._build_command(
|
||||
RunConfig(model_params={"max_budget_usd": cap, "max_turns": 4})
|
||||
)
|
||||
argv[0] = "/opt/claude"
|
||||
(tmp_path / "case.json").write_text(json.dumps({"case": case, "argv": argv}))
|
||||
(tmp_path / "passwd").write_text(
|
||||
f"fixture:x:{os.getuid()}:{os.getgid()}:Fixture:/work/home:/bin/sh\n"
|
||||
)
|
||||
(tmp_path / "group").write_text(f"fixture:x:{os.getgid()}:\n")
|
||||
(tmp_path / "home").mkdir()
|
||||
shutil.copyfile(
|
||||
Path(__file__).with_name("native_cli_fixture.py"), tmp_path / "fixture.py"
|
||||
)
|
||||
command = [
|
||||
"bwrap",
|
||||
"--unshare-all",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/usr",
|
||||
"/usr",
|
||||
"--ro-bind",
|
||||
"/lib",
|
||||
"/lib",
|
||||
"--ro-bind",
|
||||
"/lib64",
|
||||
"/lib64",
|
||||
"--symlink",
|
||||
"usr/bin",
|
||||
"/bin",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--dir",
|
||||
"/etc",
|
||||
"--ro-bind",
|
||||
str(tmp_path / "passwd"),
|
||||
"/etc/passwd",
|
||||
"--ro-bind",
|
||||
str(tmp_path / "group"),
|
||||
"/etc/group",
|
||||
"--dir",
|
||||
"/opt",
|
||||
"--ro-bind",
|
||||
str(binary),
|
||||
"/opt/claude",
|
||||
"--bind",
|
||||
str(tmp_path),
|
||||
"/work",
|
||||
"--chdir",
|
||||
"/work",
|
||||
"--clearenv",
|
||||
"--setenv",
|
||||
"PATH",
|
||||
"/usr/bin:/bin",
|
||||
"/usr/bin/python3",
|
||||
"/work/fixture.py",
|
||||
]
|
||||
proc = subprocess.run(command, capture_output=True, text=True, timeout=55, check=False)
|
||||
assert proc.returncode == 0, proc.stderr[-1000:]
|
||||
result = json.loads(proc.stdout)
|
||||
assert result["network_namespace"] != os.readlink("/proc/self/ns/net")
|
||||
assert result["markers"] == {
|
||||
"hook-ran": False,
|
||||
"mcp-ran": False,
|
||||
"denied-ran": False,
|
||||
}
|
||||
requests = [row for row in result["requests"] if "count_tokens" not in row["path"]]
|
||||
expected_tools = {"Read", "Write", "Edit", "Glob", "Grep", "Bash"}
|
||||
for request in requests:
|
||||
# The CLI may add its terminal EndConversation tool; it cannot add work tools.
|
||||
assert set(request["tools"]) - {"EndConversation"} <= expected_tools
|
||||
assert {"Bash", "Read", "Edit"} <= set(request["tools"])
|
||||
assert request["model"] == "claude-sonnet-4-6"
|
||||
assert not request["ambient_context_loaded"]
|
||||
terminal = result["terminal"]
|
||||
if case == "overrun":
|
||||
assert len(requests) == 1
|
||||
assert proc.returncode == 0 and result["returncode"] == 1
|
||||
assert terminal["subtype"] == "error_max_budget_usd"
|
||||
assert terminal["total_cost_usd"] == pytest.approx(0.18015)
|
||||
assert terminal["total_cost_usd"] > cap
|
||||
with pytest.raises(NativeLimitError) as caught:
|
||||
terminal_accounting(terminal, max_budget_usd=cap, max_turns=4)
|
||||
assert caught.value.cost_usd == terminal["total_cost_usd"]
|
||||
else:
|
||||
assert result["returncode"] == 0 and terminal["subtype"] == "success"
|
||||
assert len(requests) == 2
|
||||
assert {"id": "tool_allowed", "is_error": False} in requests[1]["tool_results"]
|
||||
assert {"id": "tool_denied", "is_error": True} in requests[1]["tool_results"]
|
||||
assert {"id": "tool_create", "is_error": False} in requests[1]["tool_results"]
|
||||
assert result["created_file"] == "fixture-created\n"
|
||||
usage, cost = terminal_accounting(terminal, max_budget_usd=cap, max_turns=4)
|
||||
assert usage["total_tokens"] > 0 and cost > 0
|
||||
if output_dir := os.environ.get("REIN_CLAUDE_PROOF_OUTPUT"):
|
||||
target = Path(output_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / f"{case}.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"case": case,
|
||||
"binary_version": version,
|
||||
"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
"argv": argv,
|
||||
"scope": "synthetic API in isolated network namespace; no real credential or provider request",
|
||||
**result,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
|
@ -42,7 +42,7 @@ def test_native_controls_reach_cli_and_account_usage(tmp_path, stream):
|
|||
patch(
|
||||
"rein_aharness.adapter.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
[], 0, "2.1.263 (Claude Code)\n", ""
|
||||
[], 0, "2.1.266 (Claude Code)\n", ""
|
||||
),
|
||||
),
|
||||
patch("rein_aharness.adapter.subprocess.Popen", return_value=proc) as invoke,
|
||||
|
|
@ -83,7 +83,7 @@ def test_error_preserves_only_bounded_cost():
|
|||
assert caught.value.cost_usd == 0.1 and "secret" not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2.1.216 (Claude Code)", "unrecognized"])
|
||||
@pytest.mark.parametrize("version", ["2.1.216 (Claude Code)", "2.1.265 (Claude Code)", "unrecognized"])
|
||||
def test_old_cli_refuses_before_prompt(tmp_path, version):
|
||||
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path)
|
||||
with (
|
||||
|
|
@ -93,7 +93,7 @@ def test_old_cli_refuses_before_prompt(tmp_path, version):
|
|||
),
|
||||
patch("rein_aharness.adapter.subprocess.Popen") as invoke,
|
||||
):
|
||||
with pytest.raises(NativeLimitError, match="2.1.217"):
|
||||
with pytest.raises(NativeLimitError, match="2.1.266"):
|
||||
adapter.execute_prompt(
|
||||
"task", RunConfig(model_params={"max_budget_usd": 0.25})
|
||||
)
|
||||
|
|
@ -135,3 +135,24 @@ def test_runner_forwards_task_limits(tmp_path):
|
|||
)
|
||||
assert result.ok
|
||||
assert adapter.configs[0].model_params == {"max_budget_usd": 0.25, "max_turns": 3}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
def test_controlled_session_uses_closed_tools_and_skips_ambient_config(tmp_path, stream):
|
||||
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, on_tool_event=(lambda e: None) if stream else None)
|
||||
cmd = adapter._build_command(RunConfig(model_params={"max_budget_usd": 1, "max_turns": 4}))
|
||||
assert cmd[cmd.index("--permission-mode")+1] == "dontAsk"
|
||||
assert cmd[cmd.index("--tools")+1] == "Read,Write,Edit,Glob,Grep,Bash"
|
||||
assert cmd[cmd.index("--setting-sources")+1] == ""
|
||||
assert json.loads(cmd[cmd.index("--mcp-config")+1]) == {"mcpServers": {}}
|
||||
assert "--bare" in cmd and "--strict-mcp-config" in cmd
|
||||
assert cmd[cmd.index("--disallowedTools")+1] == "mcp__*"
|
||||
|
||||
|
||||
def test_invalid_registered_tool_rule_refuses_before_prompt(tmp_path):
|
||||
from rein_aharness.profiles import ToolProfile
|
||||
adapter = AgenticClaudeCodeAdapter(workdir=tmp_path, tool_profile=ToolProfile("invalid", "test", "Read,*", "green"))
|
||||
with patch("rein_aharness.adapter.subprocess.Popen") as invoke:
|
||||
with pytest.raises(ValueError, match="builtin rule"):
|
||||
adapter.execute_prompt("task", RunConfig(model_params={"max_turns": 1}))
|
||||
invoke.assert_not_called()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue