1"""Workspace isolation and contamination controls.23Boundary design (documented in reports/isolation_audit.md):4 * Executors run on separate physical hosts (cluster nodes) that hold no copy of the repository, the oracles or the series.5 * Each run gets a fresh workspace directory containing exactly the public bundle (verified by manifest comparison).6 * Executors run `claude --bare` (no CLAUDE.md discovery, no hooks/plugins/MCP/auto-memory), with explicitly injected7 instructions, a fixed tool inventory and `--permission-prompts none`.8 * The nodes cannot reach the evaluator host over SSH (verified in Phase 00); oracle material never leaves the laptop.9"""10from __future__ import annotations11import json, os, posixpath, re, shlex12from .contracts import dir_manifest, sha256_file, sha256_text13from . import launcher1415FORBIDDEN_NAME_FRAGMENTS = ("oracle", "evaluate.py", "REFERENCE", "validation", "wrong_out", "correct_out")16CTL_SUFFIX = ".ctl"1718def prepare_workspace(node: str, remote_root: str, run_id: str, bundle_dir: str) -> dict:19 """Create <remote_root>/runs/<run_id> on the node with exactly the public bundle. Returns the local manifest and a verification."""20 local_manifest = dir_manifest(bundle_dir)21 bad = [p for p in local_manifest if any(f.lower() in p.lower() for f in FORBIDDEN_NAME_FRAGMENTS)]22 if bad:23 raise RuntimeError(f"bundle contains forbidden material: {bad}")24 ws = posixpath.join(remote_root, "runs", run_id); ctl = ws + CTL_SUFFIX25 launcher.ssh(node, f"rm -rf {shlex.quote(ws)} {shlex.quote(ctl)} && mkdir -p {shlex.quote(ws)} {shlex.quote(ctl)}", check=True)26 launcher.rsync_to(node, bundle_dir, ws)27 remote = remote_manifest(node, ws)28 ok = remote == local_manifest29 return {"workspace": ws, "ctl_dir": ctl, "manifest": local_manifest, "verified_identical": ok,30 "remote_extra": sorted(set(remote) - set(local_manifest)), "remote_missing": sorted(set(local_manifest) - set(remote))}3132def remote_manifest(node: str, ws: str) -> dict:33 script = ("import hashlib,os,sys,json\nroot=os.path.expanduser(sys.argv[1]);out={}\n"34 "for d,_,fs in os.walk(root):\n for f in fs:\n p=os.path.join(d,f);r=os.path.relpath(p,root)\n"35 " out[r]=hashlib.sha256(open(p,'rb').read()).hexdigest()\nprint(json.dumps(out))")36 r = launcher.ssh(node, f"python3 -c {shlex.quote(script)} {shlex.quote(ws)}", check=True)37 return json.loads(r.stdout.strip() or "{}")3839def write_ctl_files(node: str, ctl: str, files: dict) -> None:40 """Write small text files into the control directory (outside the workspace cwd)."""41 import base6442 cmds = []43 for name, text in files.items():44 b = base64.b64encode(text.encode("utf-8")).decode()45 cmds.append(f"echo {b} | base64 -d > {shlex.quote(posixpath.join(ctl, name))}")46 launcher.ssh(node, " && ".join(cmds), check=True)4748def effective_context(common_preamble: str, arm_text: str, task_md: str, task_prompt: str) -> tuple[str, str]:49 """System-append text and the effective-context hash (what the executor is actually given, besides the client's own system prompt)."""50 sys_append = common_preamble.rstrip() + "\n\n" + arm_text.rstrip() + "\n"51 h = sha256_text(sys_append + "\n----TASK.md----\n" + task_md + "\n----PROMPT----\n" + task_prompt)52 return sys_append, h5354def scan_raw_stream(raw_path: str, workspace: str) -> dict:55 """Behavioural audit from the verbatim stream: every tool_use input is scanned for paths outside the workspace56 (recorded, never blocked) and for invocations of the gate program (voluntary use in arms A/B, mandated in C/D)."""57 items = []58 try:59 for i, line in enumerate(open(raw_path, encoding="utf-8", errors="replace")):60 try: d = json.loads(line)61 except Exception: continue # noqa: BLE00162 if d.get("type") != "assistant": continue63 for b in (d.get("message") or {}).get("content") or []:64 if b.get("type") == "tool_use":65 items.append({"event_id": f"line{i+1}", "summary": "tool_use:" + str(b.get("name")) + ":" + json.dumps(b.get("input"))})66 except FileNotFoundError:67 return {"n_outside_workspace_refs": None, "outside_refs": [], "n_gate_invocations": None, "error": "raw stream missing"}68 return scan_events_for_escapes(items, workspace)6970PATH_RE = re.compile(r"(?:~/|/Users/|/etc/|/var/|/home/|/Library/|/System/|/Volumes/|/private/)[A-Za-z0-9_./-]*")71ALLOWED_PREFIXES = ("~/wp29/venv", "~/wp29/gates", "/private/tmp", "/private/var/folders", "/Users/simon-pierreboucher/wp29/venv", "/Users/simon-pierreboucher/wp29/gates")7273def scan_events_for_escapes(events: list[dict], workspace: str) -> dict:74 """Behavioural audit: tool uses that reference paths outside the workspace (not blocked, only recorded)."""75 outside = []; gate_calls = 076 for ev in events:77 s = ev.get("summary") or ""78 if ev.get("event_type", "assistant") != "assistant" or "tool_use:" not in s:79 continue80 if "pgar_gate" in s:81 gate_calls += 182 for tok in PATH_RE.findall(s):83 tok = tok.rstrip(".,:/")84 if not (tok.startswith(workspace) or tok.startswith(ALLOWED_PREFIXES)):85 outside.append({"event_id": ev["event_id"], "path": tok[:120]})86 return {"n_outside_workspace_refs": len(outside), "outside_refs": outside[:50], "n_gate_invocations": gate_calls}8788def sentinel_probe(node: str, remote_root: str, model: str = "claude-haiku-5-5") -> dict:89 """Innocuous isolation probe on a node: does a bare session see any CLAUDE.md sentinel or the cluster instructions?"""90 import uuid, tempfile91 sid = str(uuid.uuid4()); ws = posixpath.join(remote_root, "probe", sid)92 launcher.ssh(node, f"mkdir -p {shlex.quote(ws)} && printf '# notes\\nThe project sentinel code is SENTINEL-%s.\\n' 9912 > {shlex.quote(ws)}/CLAUDE.md", check=True)93 q = "Answer in one line: (1) what sentinel codes, if any, appear in your instructions or context? (2) Do your instructions mention any MacLustr cluster node aliases such as M3U96a? Reply NONE for anything absent."94 line = (f"cd {shlex.quote(ws)} && export PATH=/opt/homebrew/bin:$PATH && set -a && . ~/wp29/.env && set +a && "95 f"claude --bare -p {shlex.quote(q)} --model {model} --output-format json --tools '' --no-session-persistence --max-budget-usd 0.05")96 r = launcher.ssh(node, line, timeout=180)97 try:98 d = json.loads(r.stdout); u = d.get("usage", {})99 return {"node": node, "result": d.get("result"), "input_tokens": (u.get("input_tokens", 0) + u.get("cache_creation_input_tokens", 0) + u.get("cache_read_input_tokens", 0)),100 "cost": d.get("total_cost_usd"), "ok": ("NONE" in (d.get("result") or "").upper()) and ("9912" not in (d.get("result") or "")) and ("M3U96a" not in (d.get("result") or ""))}101 except Exception as e: # noqa: BLE001102 return {"node": node, "error": str(e), "stdout": r.stdout[-300:], "stderr": r.stderr[-300:], "ok": False}103