#!/usr/bin/env python3 """mutation_boundary.py — cognitive-stage mutation boundary (D5, #284). D5.1-D5.3 tightening (2026-09-09): D5.1 CONTENT FINGERPRINTS, not porcelain-line diffs. Pre-existing dirty files can hide further mutations if we only compare "which paths changed" — a file already modified before the stage can be modified again during it, producing the same porcelain line. The boundary now captures a content hash per tracked/untracked file and compares hashes; a change to an already-dirty file is detected. D5.2 EXPLICIT PER-STAGE ALLOWLIST, not artifact_dir-wide. The executor passes exactly the paths the stage may write: its declared artifact file, its event/log files, telemetry. NOT the whole artifact_dir (which would let an OUTWARD worker silently modify another stage's artifact or the task-tree authority). D5.3 VERIFY ON EVERY TERMINAL PATH. The mutation check runs for COMPLETED, GATE_FAILED, GATE_RETRY, FAILED, TIMEOUT and INTERRUPTED attempts — a worker that mutates source and then fails must still have that mutation identified. Clean-worktree requirement (D5.1 corollary): the canonical baseline requires a clean worktree before cognitive execution. If the worktree is dirty at capture time, the caller must decide (abort or accept-and-fingerprint). This module fingerprints regardless, so even a dirty worktree can't hide in-stage mutations. """ from __future__ import annotations import hashlib import os import subprocess class MutationError(RuntimeError): """Raised when a cognitive stage mutated source outside its authority.""" def _git(*args: str, cwd: str) -> str: result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30) return result.stdout.strip() def capture_worktree_state(repo_root: str) -> dict: """D5.1: content fingerprints per file, not just porcelain lines. Captures a {relative_path: content_hash} map for every tracked and untracked file in the repo (excluding .git/), plus HEAD. Called before cognition. """ state: dict = {} try: state["head"] = _git("rev-parse", "HEAD", cwd=repo_root) state["worktree_clean"] = _git("status", "--porcelain", cwd=repo_root) == "" # All files (tracked + untracked, excluding .git) files = _git("ls-files", "--cached", "--others", "--exclude-standard", cwd=repo_root) fingerprints = {} for f in files.splitlines(): fp = os.path.join(repo_root, f) if os.path.isfile(fp): try: h = hashlib.sha256(open(fp, "rb").read()).hexdigest() fingerprints[os.path.normpath(f)] = h except OSError: fingerprints[os.path.normpath(f)] = "UNREADABLE" state["fingerprints"] = fingerprints except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired) as e: state["error"] = str(e) return state def verify_no_source_mutation(repo_root: str, before: dict, allowed_paths: list[str]) -> list[str]: """D5.1+D5.2: content-hash comparison + explicit allowlist. Returns violations (empty = clean). allowed_paths: EXPLICIT relative paths this stage may write — NOT a directory-wide grant. Each entry is either an exact file path or a directory prefix ending in '/' (e.g. "research/issue-162-b3v2/telemetry/"). """ violations = [] after = capture_worktree_state(repo_root) if "error" in before or "error" in after: return violations # can't verify in a broken-git env — don't false-positive before_fp: dict = before.get("fingerprints", {}) after_fp: dict = after.get("fingerprints", {}) # Normalize allowed paths: exact files or directory prefixes allowed_exact = set() allowed_prefixes = set() for p in allowed_paths: norm = os.path.normpath(p) if p.endswith("/"): allowed_prefixes.add(norm) else: allowed_exact.add(norm) def _is_allowed(rel: str) -> bool: if rel in allowed_exact: return True return any(rel.startswith(prefix + os.sep) or rel.startswith(prefix + "/") for prefix in allowed_prefixes) # D5.1: compare fingerprints — detects changes to already-dirty files for path in sorted(set(before_fp) | set(after_fp)): if _is_allowed(path): continue old = before_fp.get(path) new = after_fp.get(path) if old is None and new is not None: violations.append(f"UNAUTHORIZED_MUTATION: new file {path}") elif old is not None and new is None: violations.append(f"UNAUTHORIZED_MUTATION: deleted {path}") elif old != new and old is not None and new is not None: violations.append(f"UNAUTHORIZED_MUTATION: content changed {path}") # HEAD must not move (cognitive stages must never commit) if before.get("head") and after.get("head") and before["head"] != after["head"]: violations.append(f"UNAUTHORIZED_COMMIT: HEAD moved {before['head'][:8]} → {after['head'][:8]}") return violations def require_clean_worktree(repo_root: str) -> tuple[bool, str]: """D5.1 corollary: canonical cognitive execution requires a clean worktree. Returns (clean, status_output). The caller decides: abort or accept-and-fingerprint. Dirty worktrees can't hide mutations (fingerprints still work) but the baseline methodology requires clean starts.""" status = _git("status", "--porcelain", cwd=repo_root) return (status == "", status)