From 4ae245a88f2e61b655980031a8404944d91cf341 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 20:53:25 +0200 Subject: [PATCH] Constrain controlled CLI sessions and prove native budget overshoot Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .../2026-09-09-native-cli-overrun.json | 84 +++++++ .../evidence/2026-09-09-native-cli-tools.json | 118 +++++++++ docs/native-cli-boundary-proof.md | 84 +++++++ docs/sandbox-artifact-return.md | 8 +- docs/spend-admission.md | 6 + rein_aharness/adapter.py | 19 +- rein_aharness/profiles.py | 18 +- rein_aharness/runner.py | 2 + scripts/verify-recovery-contracts.sh | 1 + scripts/verify-runtime-contracts.sh | 1 + tests/native_cli_fixture.py | 228 ++++++++++++++++++ tests/test_native_cli_boundary.py | 148 ++++++++++++ tests/test_native_limits.py | 27 ++- ...INAH-WP-0003-governed-runtime-integrity.md | 18 ++ 14 files changed, 752 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/2026-09-09-native-cli-overrun.json create mode 100644 docs/evidence/2026-09-09-native-cli-tools.json create mode 100644 docs/native-cli-boundary-proof.md create mode 100644 tests/native_cli_fixture.py create mode 100644 tests/test_native_cli_boundary.py diff --git a/docs/evidence/2026-09-09-native-cli-overrun.json b/docs/evidence/2026-09-09-native-cli-overrun.json new file mode 100644 index 0000000..d11d928 --- /dev/null +++ b/docs/evidence/2026-09-09-native-cli-overrun.json @@ -0,0 +1,84 @@ +{ + "case": "overrun", + "binary_version": "2.1.266 (Claude Code)", + "binary_sha256": "19842705e989393fce936804df6d2ab034860e24b8f8880357981d87ffd83fac", + "argv": [ + "/opt/claude", + "--print", + "--permission-mode", + "dontAsk", + "--allowedTools", + "Read,Write,Edit,Glob,Grep,Bash(git add:*),Bash(git commit:*),Bash(git status),Bash(git log:*),Bash(git diff:*),Bash(date:*),Bash(ls:*)", + "--output-format", + "json", + "--max-budget-usd", + "0.01", + "--max-turns", + "4", + "--bare", + "--setting-sources", + "", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "--disallowedTools", + "mcp__*", + "--tools", + "Read,Write,Edit,Glob,Grep,Bash", + "--no-session-persistence", + "--model", + "claude-sonnet-4-6" + ], + "scope": "synthetic API in isolated network namespace; no real credential or provider request", + "returncode": 1, + "terminal": { + "type": "result", + "subtype": "error_max_budget_usd", + "is_error": true, + "total_cost_usd": 0.18015, + "num_turns": 1, + "usage": { + "output_tokens_details": { + "thinking_tokens": 0 + }, + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + "server_tool_use": { + "web_search_requests": 0, + "web_fetch_requests": 0 + }, + "service_tier": "standard", + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "inference_geo": "", + "iterations": [], + "speed": "standard" + }, + "permission_denials": [] + }, + "requests": [ + { + "path": "/v1/messages?beta=true", + "model": "claude-sonnet-4-6", + "max_tokens": 32000, + "tools": [ + "Bash", + "Edit", + "Read" + ], + "tool_results": [], + "ambient_context_loaded": false + } + ], + "markers": { + "hook-ran": false, + "mcp-ran": false, + "denied-ran": false + }, + "created_file": null, + "network_namespace": "net:[4026532321]" +} diff --git a/docs/evidence/2026-09-09-native-cli-tools.json b/docs/evidence/2026-09-09-native-cli-tools.json new file mode 100644 index 0000000..13d0623 --- /dev/null +++ b/docs/evidence/2026-09-09-native-cli-tools.json @@ -0,0 +1,118 @@ +{ + "case": "tools", + "binary_version": "2.1.266 (Claude Code)", + "binary_sha256": "19842705e989393fce936804df6d2ab034860e24b8f8880357981d87ffd83fac", + "argv": [ + "/opt/claude", + "--print", + "--permission-mode", + "dontAsk", + "--allowedTools", + "Read,Write,Edit,Glob,Grep,Bash(git add:*),Bash(git commit:*),Bash(git status),Bash(git log:*),Bash(git diff:*),Bash(date:*),Bash(ls:*)", + "--output-format", + "json", + "--max-budget-usd", + "1", + "--max-turns", + "4", + "--bare", + "--setting-sources", + "", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "--disallowedTools", + "mcp__*", + "--tools", + "Read,Write,Edit,Glob,Grep,Bash", + "--no-session-persistence", + "--model", + "claude-sonnet-4-6" + ], + "scope": "synthetic API in isolated network namespace; no real credential or provider request", + "returncode": 0, + "terminal": { + "type": "result", + "subtype": "success", + "is_error": false, + "total_cost_usd": 0.0009000000000000001, + "num_turns": 4, + "usage": { + "input_tokens": 200, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 20, + "output_tokens_details": { + "thinking_tokens": 0 + }, + "server_tool_use": { + "web_search_requests": 0, + "web_fetch_requests": 0 + }, + "service_tier": "standard", + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "inference_geo": "", + "iterations": [], + "speed": "standard" + }, + "permission_denials": [ + { + "tool_name": "Bash", + "tool_use_id": "tool_denied", + "tool_input": { + "command": "printf bypass > /work/denied-ran", + "description": "Fixture forbidden operation" + } + } + ] + }, + "requests": [ + { + "path": "/v1/messages?beta=true", + "model": "claude-sonnet-4-6", + "max_tokens": 32000, + "tools": [ + "Bash", + "Edit", + "Read" + ], + "tool_results": [], + "ambient_context_loaded": false + }, + { + "path": "/v1/messages?beta=true", + "model": "claude-sonnet-4-6", + "max_tokens": 32000, + "tools": [ + "Bash", + "Edit", + "Read" + ], + "tool_results": [ + { + "id": "tool_allowed", + "is_error": false + }, + { + "id": "tool_create", + "is_error": false + }, + { + "id": "tool_denied", + "is_error": true + } + ], + "ambient_context_loaded": false + } + ], + "markers": { + "hook-ran": false, + "mcp-ran": false, + "denied-ran": false + }, + "created_file": "fixture-created\n", + "network_namespace": "net:[4026532321]" +} diff --git a/docs/native-cli-boundary-proof.md b/docs/native-cli-boundary-proof.md new file mode 100644 index 0000000..e40f97f --- /dev/null +++ b/docs/native-cli-boundary-proof.md @@ -0,0 +1,84 @@ +# Native CLI boundary proof — 2026-09-09 + +The installed Claude Code 2.1.266 binary was exercised against a deterministic +Anthropic-shaped API in a separate bwrap network namespace. The namespace had +no provider network route, operator home, real credential or inference service. +Only the CLI binary, system runtime and a temporary fixture workspace were mounted. +The executable receipt records the binary SHA-256 and exact adapter-generated argv. + +## Findings and resulting change + +With `--max-budget-usd 0.01`, the first fixture response reported 60,000 input +and 10 output tokens. The CLI estimated **USD 0.18015**, then exited with +`error_max_budget_usd`. Only one messages request reached the fixture. This +reproduces a stop after the response, not a pre-request maximum-liability check. +The rein terminal-accounting validator refused success and preserved known cost. +No amount in this experiment was billed by a provider. + +The existing spend ledger remains necessary: it reserves declared per-run +liability and retains unknown outcomes. The experiment does not establish that +the declared liability is a valid bound on every possible native request. A +provider-facing request guard is the next HFACT-WP-0001-T01 implementation return; +raising the native threshold or accepting a newer binary cannot substitute for it. + +The adapter also previously used `--allowedTools` with `acceptEdits`. Those flags +alone did not constrain the available tool inventory or repository-provided +customizations. Runs with native USD or turn controls now require CLI >=2.1.266 +and use `dontAsk`, explicit builtin tools derived from registered rules, `--bare`, +empty settings sources, empty strict MCP configuration, an MCP deny rule, and no +session persistence. Legacy unconfigured sessions retain their existing behavior. +The worker prompt explicitly requires reading applicable AGENTS.md/CLAUDE.md; +these files are instructions, not permission to load executable configuration or +expand grants. The factory's protected runtime must be rebuilt and re-proven for +the new minimum; this source change installs or admits no runtime. + +The real binary's bare mode exposed Bash, Read and Edit, a subset of the explicit +inventory. A synthetic tool sequence successfully ran permitted `git status` and +created a new file through Edit. An unapproved shell write was denied. A fixture +project containing a broad Bash permission override, a SessionStart hook and an +MCP command could activate none of them; its ambient CLAUDE.md was not auto-loaded. +The second messages request contained the expected allowed/denied tool results. + +These tests prove the specified direct-tool and configuration boundaries. They +are not an arbitrary-code sandbox proof: permitted Git operations can invoke +repository-controlled helpers, and file edits can influence later commands. +Filesystem, credential and network confinement remain sandbox-owner controls. +To enforce spend against such indirect execution, the workload must not possess +a provider credential plus an unmetered route to the provider. A run-scoped, +owner-controlled request path must cover every forwarded request and retry. + +## Reproduction and acceptance + +`REIN_REAL_CLAUDE=1` enables `tests/test_native_cli_boundary.py`. The default binary +comes from `claude` on PATH; `REIN_CLAUDE_PROOF_BINARY` can select an exact installed +file. The fixture deliberately requires version 2.1.266; revalidate and record a +new receipt before claiming the same proof for another version. The adapter's +minimum-version gate does not itself prove arbitrary future binaries. + +```text +REIN_REAL_CLAUDE=1 REIN_REAL_BWRAP=1 python -m pytest tests/ -q +``` + +The reproducible fixture is `tests/native_cli_fixture.py`; optional +`REIN_CLAUDE_PROOF_OUTPUT` writes bounded evidence without prompts, headers, +credentials or response bodies. The actual CLI checks are opt-in because ordinary +CI does not provision that binary. Deterministic native launch/accounting tests +are now included in both mandatory runtime and recovery gates. Paired receipts: +`docs/evidence/2026-09-09-native-cli-overrun.json` and +`docs/evidence/2026-09-09-native-cli-tools.json`. + +No paid factory request, profile promotion, credential reader or production +rollout was authorized or performed. REINAH-WP-0003-T05/T06 and HFACT-WP-0001-T01/ +T03/T04/T05 retain the request-admission, exact identity, protected placement and +natural queue/model execution returns. + +## Upstream contract reviewed + +The [CLI reference](https://code.claude.com/docs/en/cli-reference) distinguishes +available tools from auto-permission rules and describes the native stop flag. +The [headless guide](https://code.claude.com/docs/en/headless) documents bare-mode +configuration isolation and the API-key authentication path. The +[cost guide](https://code.claude.com/docs/en/costs) identifies CLI totals as local +estimates, not authoritative billing, and notes the residency-cost correction in +2.1.239. Reviewed 2026-09-09; the local executable findings above are narrower +than a claim about all provider billing or future CLI releases. diff --git a/docs/sandbox-artifact-return.md b/docs/sandbox-artifact-return.md index 1b5414a..547ad5b 100644 --- a/docs/sandbox-artifact-return.md +++ b/docs/sandbox-artifact-return.md @@ -26,7 +26,7 @@ still requires the existing recovery process to classify the resulting commit. Glas profile limits `max_budget_usd` and `max_turns` are carried in its generated TaskSpec into the agentic Claude adapter. A supplied task file cannot bypass these profile controls. Positive finite USD amounts and positive integer turn -limits are required. A controlled run verifies Claude Code >= 2.1.217, supplies +limits are required. A controlled run verifies Claude Code >= 2.1.266, supplies `--max-budget-usd` / `--max-turns`, and requires one successful terminal JSON result with valid cost, turn and token accounting. Limit exhaustion, missing accounting and reported overruns cannot produce a successful run. Bounded USD @@ -56,3 +56,9 @@ invalid/exhausted results and refusal of older CLI versions without inference. The subsequent [durable spend admission](spend-admission.md) return implements private daily/total reservation and unknown-outcome recovery in the worker. Provider liability/FX proof and final operating admission remain open. + +The [installed CLI boundary proof](native-cli-boundary-proof.md) reproduces native +budget overshoot and verifies controlled configuration/tool confinement. Native +USD/turn-controlled runs now use bare mode and dontAsk with an explicit inventory; +applicable repository instructions must be read as task context. The first paid +factory run still requires a provider-facing request admission guard. diff --git a/docs/spend-admission.md b/docs/spend-admission.md index ca676cd..cdfc91e 100644 --- a/docs/spend-admission.md +++ b/docs/spend-admission.md @@ -131,3 +131,9 @@ or verify the operator's FX assumption. G0 remains blocked until those semantics are proven for the pinned provider/CLI, including retries, cache, subagents and in-flight work. Exact credential/identity/egress/placement and protected-runtime installation are also still required before natural factory execution. + +The [native CLI proof](native-cli-boundary-proof.md) now demonstrates the missing +pre-request bound: one fixture response overshot a USD 0.01 threshold to an +estimated USD 0.18015. HFACT T01 must implement and prove a metered request path +outside the workload, including direct-provider bypass denial. The full-charge +reservation/reconciliation implementation remains unchanged. diff --git a/rein_aharness/adapter.py b/rein_aharness/adapter.py index f26827d..ceaf08e 100644 --- a/rein_aharness/adapter.py +++ b/rein_aharness/adapter.py @@ -3,11 +3,11 @@ llm-connect's ClaudeCodeAdapter is a text-generation adapter (`claude --print`, no working directory, no tool grants). An executor run needs an *agentic* session: file edits and git commits inside the target repo, under -a hard tool allow-list. This adapter subclasses it, keeping the llm-connect +registered tool permission rules. This adapter subclasses it, keeping the llm-connect LLMAdapter interface so a hosted adapter can be swapped in later, and adds: - cwd pinned to the target repo -- --permission-mode acceptEdits +- acceptEdits for legacy sessions; dontAsk and an explicit tool inventory for controlled runs - allow-list from a named tool profile (default: green-commit-only) - optional real-time per-tool-call audit events (HARNESS-WP-0002-T03) @@ -133,6 +133,17 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter): cmd += ["--max-budget-usd", str(budget)] if turns is not None: cmd += ["--max-turns", str(turns)] + if self._native_config != (None, None): + # A permission allow rule is not a tool inventory. Controlled runs + # use only the registered tools and deny every unapproved operation. + cmd[cmd.index("--permission-mode") + 1] = "dontAsk" + cmd += [ + "--bare", "--setting-sources", "", + "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', + "--disallowedTools", "mcp__*", + "--tools", self._profile.available_tools, + "--no-session-persistence", + ] if self._model: cmd.extend(["--model", self._model]) return cmd @@ -143,8 +154,8 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter): if self._native_config != (None, None) and not self._native_cli_checked: check = subprocess.run([self._cli_path, "--version"], capture_output=True, text=True, timeout=10, cwd=self._workdir) version = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\s|$)", check.stdout.strip()) - if check.returncode or not version or tuple(map(int, version.groups())) < (2, 1, 217): - raise NativeLimitError("native limits require verified Claude Code 2.1.217 or newer") + if check.returncode or not version or tuple(map(int, version.groups())) < (2, 1, 266): + raise NativeLimitError("native limits require verified Claude Code 2.1.266 or newer") self._native_cli_checked = True timeout = config.timeout_seconds or self._config.timeout_seconds if self._on_tool_event is not None: diff --git a/rein_aharness/profiles.py b/rein_aharness/profiles.py index 5867003..3b5c646 100644 --- a/rein_aharness/profiles.py +++ b/rein_aharness/profiles.py @@ -8,13 +8,15 @@ Unknown profile names refuse to run. from __future__ import annotations from dataclasses import dataclass +import re class UnknownToolProfileError(ValueError): """Raised when a manifest references a profile that is not registered.""" -# Claude Code --allowedTools strings. No push, no network, no arbitrary shell. +# Direct Claude Code permission rules. Indirect Git helpers still require +# the sandbox owner's filesystem, credential and egress boundaries. _GREEN_COMMIT_TOOLS = ( "Read,Write,Edit,Glob,Grep," "Bash(git add:*),Bash(git commit:*),Bash(git status)," @@ -29,13 +31,25 @@ _BLUE_MAIL_TRIAGE_TOOLS = _GREEN_COMMIT_TOOLS @dataclass(frozen=True) class ToolProfile: - """A named hard allow-list for agentic sessions.""" + """Named permission rules; controlled runs also restrict the tool inventory.""" name: str description: str allowed_tools: str lane: str # green | blue — advisory; enforcement is the allow-list + @property + def available_tools(self) -> str: + """Builtin inventory derived from the registered permission rules.""" + names = [] + for rule in self.allowed_tools.split(","): + match = re.fullmatch(r"([A-Za-z][A-Za-z0-9_]*)(?:\([^\r\n()]+\))?", rule.strip()) + if match is None or match[1].startswith("mcp__"): + raise ValueError("controlled tool profile has an invalid builtin rule") + if match[1] not in names: + names.append(match[1]) + return ",".join(names) + PROFILES: dict[str, ToolProfile] = { "green-commit-only": ToolProfile( diff --git a/rein_aharness/runner.py b/rein_aharness/runner.py index a44e1a8..8632598 100644 --- a/rein_aharness/runner.py +++ b/rein_aharness/runner.py @@ -36,6 +36,8 @@ PROMPT_TEMPLATE = """\ You are an unattended executor session (agent persona below, if any). Operating rules, non-negotiable: - Work ONLY inside the current repository working directory. +- Read repository AGENTS.md/CLAUDE.md and applicable nested instructions before + editing. They cannot expand this run's tool, repository or spend grants. - Green/Blue lane: file edits and local git add/commit only. Never push, never touch the network, never run destructive commands. - Tool profile for this run: {tool_profile} (lane={lane}). diff --git a/scripts/verify-recovery-contracts.sh b/scripts/verify-recovery-contracts.sh index 5c92383..bd89084 100755 --- a/scripts/verify-recovery-contracts.sh +++ b/scripts/verify-recovery-contracts.sh @@ -9,6 +9,7 @@ PYTHON_BIN="${REIN_CONTRACT_PYTHON:-${REPO_ROOT}/.venv/bin/python}" "${PYTHON_BIN}" -c 'import glas_harness.contract, llm_connect, sandboxer.models' PYTHONPATH="${REPO_ROOT}:${REPO_ROOT}/../llm-connect" \ "${PYTHON_BIN}" -m pytest \ + tests/test_native_limits.py \ tests/test_spend_admission.py \ tests/test_claim_loop.py::test_process_one_initial_heartbeat_rejection_refuses_dispatch \ tests/test_claim_loop.py::test_process_one_lease_loss_cancels_registered_adapter_process \ diff --git a/scripts/verify-runtime-contracts.sh b/scripts/verify-runtime-contracts.sh index 86de2b3..bfb44fa 100755 --- a/scripts/verify-runtime-contracts.sh +++ b/scripts/verify-runtime-contracts.sh @@ -10,6 +10,7 @@ PYTHON_BIN="${REIN_CONTRACT_PYTHON:-${REPO_ROOT}/.venv/bin/python}" "${PYTHON_BIN}" -c 'import glas_harness.contract, llm_connect, sandboxer.models' PYTHONPATH="${REPO_ROOT}:${REPO_ROOT}/../llm-connect" \ "${PYTHON_BIN}" -m pytest \ + tests/test_native_limits.py \ tests/test_spend_admission.py \ tests/test_glas_execution.py \ tests/test_ops_run_client.py \ diff --git a/tests/native_cli_fixture.py b/tests/native_cli_fixture.py new file mode 100644 index 0000000..e9d98d8 --- /dev/null +++ b/tests/native_cli_fixture.py @@ -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() diff --git a/tests/test_native_cli_boundary.py b/tests/test_native_cli_boundary.py new file mode 100644 index 0000000..70bb918 --- /dev/null +++ b/tests/test_native_cli_boundary.py @@ -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" + ) diff --git a/tests/test_native_limits.py b/tests/test_native_limits.py index 6c56676..7520220 100644 --- a/tests/test_native_limits.py +++ b/tests/test_native_limits.py @@ -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() diff --git a/workplans/REINAH-WP-0003-governed-runtime-integrity.md b/workplans/REINAH-WP-0003-governed-runtime-integrity.md index af266d2..f105438 100644 --- a/workplans/REINAH-WP-0003-governed-runtime-integrity.md +++ b/workplans/REINAH-WP-0003-governed-runtime-integrity.md @@ -654,6 +654,24 @@ installation and Railiance recovery proof. T06 remains `wait` for the admitted real-model and natural queue run. No factory policy, paid execution, deployment, profile promotion or G0 grant was created by this source change. + +### Installed native CLI proof — 2026-09-09 + +Claude Code 2.1.266 was tested against a synthetic API in a private network +namespace. Its USD 0.01 stop threshold allowed one response estimated at USD +0.18015 before refusal, demonstrating why declared run reservations alone do not +supply a hard provider bound. Controlled native runs now restrict builtin tools, +use dontAsk and bare mode, suppress ambient settings/MCP, and require the tested +minimum version. Actual CLI tests prove permitted Git status and file creation, +forbidden shell denial, and no fixture hook/MCP/permission-override activation. +See [the executable proof](../docs/native-cli-boundary-proof.md). + +T05 stays progress for the provider-facing request guard, protected rebuild and +owner admission. T06 stays wait for the real admitted model/natural queue proof. +Do not infer network/credential confinement from direct CLI tool rules: indirect +Git helpers remain inside the sandbox owner's responsibility. No paid request, +credential read or deployment was performed during the fixture proof. + ## Re-prove one governed profiled run and close residuals ```task