Files
rune/scripts/mutation_boundary.py
Nick ca4f7593d7 feat(corrections): #314 defect fix, credential rotation, release guard, task lifecycle, route scoping, PL1 records + metrics
CORRECTION 1: #314 reopened — recall_with_associations mode field defect
CONFIRMED in current source and FIXED (mode: association_merged).

CORRECTION 3: Gitea credential removed from remote URL (credential helper).
TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires
Gitea web UI access). Remote URL now shows username only, no secret.

CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence
computed warranted bump from commits-since-tag without checking if master
version already satisfies the bump. FIX: already-converged guard in
version_convergence.py (master version > last tag → no commit). Repo-aware
(args.repo passed to read_version/latest_tag).

CORRECTION 6: lifecycle terminalization — execution exception now
guarantees task terminalizes as failed with evidence (no immortal RUNNING).

CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction
(NOT shared operator state). Read-only leading-verb override. Isolation
tests prove no cross-contamination between interleaved tasks.

PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure
class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics,
route-instruction learning loop (PE-1789102658-0269 SEED).

1253 tests green; ratchet 352/80 (shrink); henxels clean.
2026-09-11 15:36:08 +01:00

134 lines
5.5 KiB
Python

#!/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)