CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 14s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / lint (push) Failing after 34s
CI Pipeline / canary (push) Successful in 37s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / unit-tests (push) Successful in 1m4s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 48s
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.
134 lines
5.5 KiB
Python
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)
|