Haiku subagent build under decision 129. Candidate manifest f72730de names roles/chief-of-staff.json and .md, the business README and packages/business/tests/chief-of-staff.test.mjs. Gate and mutants ran in Sage's slot. Co-Authored-By: Claude Opus 5.5 <[email protected]>
241 lines
8.9 KiB
Python
241 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
# Row 84 (#1578) mutation runner, chief-of-staff role. Written in Phase A; run once in Phase B (see BUILD.md).
|
|
#
|
|
# Phase B usage, from the worktree root, after Sage's "slot clear" and after the
|
|
# rebase onto the current refactor HEAD:
|
|
# python3 -I <packet>/mutants.py <worktree> <packet>
|
|
#
|
|
# Nine mutants. M01 to M08 rewrite one exact-once anchor in roles/chief-of-staff.json.
|
|
# M09 rewrites one exact-once anchor in packages/business/src/resolve.mjs, which is
|
|
# NOT in the candidate manifest; its original sha256 is pinned below instead. Each
|
|
# mutant runs packages/business/tests/chief-of-staff.test.mjs in its own process group
|
|
# under a timeout, and restores the original bytes of its file in a finally block.
|
|
# The candidate manifest is checked before the baseline and after the last restore.
|
|
# resolve.mjs is checked against its pinned sha256 before the baseline, after each
|
|
# restore of M09, and at the end. A red baseline aborts before any mutant. A timeout
|
|
# counts as a failure.
|
|
#
|
|
# Exit codes: 0 every mutant killed; 1 a mutant survived or a run timed out;
|
|
# 2 baseline red, timed out, manifest or pinned-hash mismatch, or leftover processes;
|
|
# 3 an anchor was not found exactly once.
|
|
|
|
import hashlib
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
|
|
TIMEOUT_S = 180
|
|
ROLE = 'roles/chief-of-staff.json'
|
|
RESOLVE = 'packages/business/src/resolve.mjs'
|
|
# sha256 of resolve.mjs at BASE 58a3766d (checked: identical to base). Not in the candidate.
|
|
RESOLVE_SHA256 = 'c50340af650aaa2d0eb15224e823525ba942a91f475ed62bd7e7ad42055f182b'
|
|
SUITE = ['node', '--test', 'packages/business/tests/chief-of-staff.test.mjs']
|
|
|
|
# (id, file, old exact text, new exact text, what the test that must kill it checks)
|
|
MUTANTS = [
|
|
('M01', ROLE, '"tools": ["read", "grep", "find", "ls"],',
|
|
'"tools": ["read", "grep", "find", "ls", "bash"],',
|
|
'widen tools: bash added; shipped-file equality test fails'),
|
|
('M02', ROLE, '"tools": ["read", "grep", "find", "ls"],',
|
|
'"tools": ["read", "write", "grep", "find", "ls"],',
|
|
'widen tools: write added (a write tool, which waits for I4); equality test fails'),
|
|
('M03', ROLE, '"network": "api-only",',
|
|
'"network": "open",',
|
|
'widen network: api-only to open; equality test fails'),
|
|
('M04', ROLE, '"withinRole": ["message.send"],',
|
|
'"withinRole": ["message.send", "role.launch"],',
|
|
'widen withinRole: role.launch within-role; equality test fails'),
|
|
('M05', ROLE, '"withinRole": ["message.send"],',
|
|
'"withinRole": ["message.send", "task.create"],',
|
|
'widen withinRole: task.create within-role; equality test and classify test fail'),
|
|
('M06', ROLE, '"crossRole": []',
|
|
'"crossRole": ["task.scope.change"]',
|
|
'widen crossRole: task.scope.change; equality test fails'),
|
|
('M07', ROLE, '"credentials": []',
|
|
'"credentials": [{ "service": "gitea", "scopes": ["read:issue"] }]',
|
|
'widen credentials: gitea scope; business fixture refuses the instance (no matching credential ref) and equality test fails'),
|
|
('M08', ROLE, '"withinRole": ["message.send"],',
|
|
'"withinRole": [],',
|
|
'narrow withinRole: message.send dropped; equality and classify (within for message.send) tests fail'),
|
|
('M09', RESOLVE, 'withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));',
|
|
'withinRole = [...new Set([...withinRole, ...vars["limits.authority"]])];',
|
|
'widen through limits: a limits.authority grant adds gated actions to withinRole; the limits-grant test (task.create, role.launch stay gated) must fail'),
|
|
]
|
|
|
|
|
|
def sha256_of(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, 'rb') as handle:
|
|
for block in iter(lambda: handle.read(65536), b''):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def read_manifest(packet):
|
|
entries = []
|
|
with open(os.path.join(packet, 'candidate-manifest.sha256'), encoding='utf-8') as handle:
|
|
for line in handle:
|
|
line = line.rstrip('\n')
|
|
if line:
|
|
digest, rel = line.split(' ', 1)
|
|
entries.append((digest, rel))
|
|
return entries
|
|
|
|
|
|
def manifest_mismatches(wt, entries):
|
|
return [rel for digest, rel in entries if sha256_of(os.path.join(wt, rel)) != digest]
|
|
|
|
|
|
def resolve_mismatch(wt):
|
|
return sha256_of(os.path.join(wt, RESOLVE)) != RESOLVE_SHA256
|
|
|
|
|
|
def kill_group(pgid):
|
|
try:
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def run_suite(wt, log_path):
|
|
with open(log_path, 'wb') as log:
|
|
proc = subprocess.Popen(
|
|
SUITE,
|
|
cwd=wt,
|
|
env=os.environ.copy(),
|
|
stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
return proc.wait(timeout=TIMEOUT_S), 'exit'
|
|
except subprocess.TimeoutExpired:
|
|
kill_group(proc.pid)
|
|
proc.wait()
|
|
return None, 'timeout'
|
|
finally:
|
|
kill_group(proc.pid)
|
|
|
|
|
|
def ancestors():
|
|
# This process and its parents: the runner and the shell that started it.
|
|
chain = set()
|
|
pid = os.getpid()
|
|
while pid > 1 and pid not in chain:
|
|
chain.add(pid)
|
|
try:
|
|
with open(os.path.join('/proc', str(pid), 'stat'), encoding='utf-8') as handle:
|
|
stat = handle.read()
|
|
except OSError:
|
|
break
|
|
pid = int(stat.rsplit(')', 1)[1].split()[1])
|
|
return chain
|
|
|
|
|
|
def worktree_leftovers(wt):
|
|
# Reads /proc cwd links only. Reports; kills nothing. Skips this runner and its parents.
|
|
skip = ancestors()
|
|
found = []
|
|
for pid in filter(str.isdigit, os.listdir('/proc')):
|
|
if int(pid) in skip:
|
|
continue
|
|
try:
|
|
cwd = os.readlink(os.path.join('/proc', pid, 'cwd'))
|
|
except OSError:
|
|
continue
|
|
if cwd == wt or cwd.startswith(wt + os.sep):
|
|
found.append((pid, cwd))
|
|
return found
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 3:
|
|
sys.stderr.write('usage: mutants.py <worktree> <packet_dir>\n')
|
|
return 2
|
|
wt = os.path.abspath(sys.argv[1])
|
|
packet = os.path.abspath(sys.argv[2])
|
|
out = os.path.join(packet, 'out')
|
|
os.makedirs(out, exist_ok=True)
|
|
entries = read_manifest(packet)
|
|
|
|
broken = manifest_mismatches(wt, entries)
|
|
if broken:
|
|
print('manifest mismatch before baseline:', broken)
|
|
return 2
|
|
if resolve_mismatch(wt):
|
|
print(f'{RESOLVE} differs from the pinned base sha256 before baseline')
|
|
return 2
|
|
|
|
rc, status = run_suite(wt, os.path.join(out, 'baseline.log'))
|
|
if status != 'exit' or rc != 0:
|
|
print('baseline red or timed out:', status, rc)
|
|
return 2
|
|
|
|
# Original bytes of every file a mutant touches, read once.
|
|
originals = {}
|
|
for _, rel, _, _, _ in MUTANTS:
|
|
if rel not in originals:
|
|
with open(os.path.join(wt, rel), 'rb') as handle:
|
|
originals[rel] = handle.read()
|
|
|
|
# Check every anchor before the first mutant runs.
|
|
for mid, rel, old, _, _ in MUTANTS:
|
|
count = originals[rel].decode('utf-8').count(old)
|
|
if count != 1:
|
|
print(f'{mid} ANCHOR count={count} in {rel}; aborting before any mutant')
|
|
return 3
|
|
|
|
summary = ['baseline: green']
|
|
failures = []
|
|
for mid, rel, old, new, what in MUTANTS:
|
|
path = os.path.join(wt, rel)
|
|
original = originals[rel]
|
|
text = original.decode('utf-8')
|
|
log_path = os.path.join(out, f'{mid}.log')
|
|
try:
|
|
with open(path, 'wb') as handle:
|
|
handle.write(text.replace(old, new, 1).encode('utf-8'))
|
|
rc, status = run_suite(wt, log_path)
|
|
finally:
|
|
with open(path, 'wb') as handle:
|
|
handle.write(original)
|
|
if status == 'timeout':
|
|
verdict = 'TIMEOUT (counted as failure)'
|
|
failures.append(mid)
|
|
elif rc == 0:
|
|
verdict = 'SURVIVED'
|
|
failures.append(mid)
|
|
else:
|
|
verdict = 'killed'
|
|
summary.append(f'{mid} {verdict} {what}')
|
|
if rel == RESOLVE and resolve_mismatch(wt):
|
|
summary.append(f'{RESOLVE} differs from the pinned sha256 after restoring {mid}')
|
|
failures.append('restore')
|
|
|
|
# The restore must leave the candidate byte-identical to the manifest.
|
|
after = manifest_mismatches(wt, entries)
|
|
if after:
|
|
summary.append(f'manifest mismatch after restore: {after}')
|
|
failures.append('restore')
|
|
if resolve_mismatch(wt):
|
|
summary.append(f'{RESOLVE} differs from the pinned sha256 after the last restore')
|
|
failures.append('restore')
|
|
|
|
left = worktree_leftovers(wt)
|
|
if left:
|
|
summary.append(f'leftover processes with cwd in worktree: {left}')
|
|
failures.append('leftovers')
|
|
|
|
with open(os.path.join(out, 'summary.txt'), 'w', encoding='utf-8') as handle:
|
|
handle.write('\n'.join(summary) + '\n')
|
|
print('\n'.join(summary))
|
|
|
|
if 'restore' in failures or 'leftovers' in failures:
|
|
return 2
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|