policy-nexus/tools/verify_publication_transition.py
tegwick 65862b1e0c
All checks were successful
Build and publish policy-nexus image / build-and-push (push) Successful in 1m25s
Reject history overwrite and unreviewed publication routing changes
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 02:31:41 +02:00

69 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""Compare a candidate against captured public history, without network or authority."""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
SEMANTIC_FIELDS = ('source_digest', 'revision', 'status', 'lifecycle', 'currency',
'canonical_path', 'revision_path', 'source_repo', 'source_path', 'owner')
def safe_path(value):
p = PurePosixPath(value)
if p.is_absolute() or '..' in p.parts or not p.parts:
raise ValueError('unsafe publication path')
return p
def verify(candidate, baseline_manifest, immutable_audit, allowed_changed_ids):
candidate = Path(candidate).resolve()
old = json.loads(Path(baseline_manifest).read_text())['documents']
new = json.loads((candidate / 'publication-manifest.json').read_text())['documents']
old_by = {d['id']: d for d in old}
new_by = {d['id']: d for d in new}
if len(old_by) != len(old) or len(new_by) != len(new):
raise ValueError('duplicate document identity')
errors = []
changed = set(new_by) - set(old_by)
for identity, before in old_by.items():
after = new_by.get(identity)
if after is None:
errors.append(f'{identity}: published document removed')
elif any(before.get(k) != after.get(k) for k in SEMANTIC_FIELDS):
changed.add(identity)
for identity in sorted(changed - set(allowed_changed_ids)):
errors.append(f'{identity}: unreviewed semantic change')
history = json.loads(Path(immutable_audit).read_text())['immutable_results']
if len(history) != len(old) or {h['id'] for h in history} != set(old_by):
errors.append('baseline capture does not cover every previously published document')
for record in history:
identity = record['id']
if record.get('http_status') != 200 or not record.get('served_html_sha256'):
errors.append(f'{identity}: prior public history was not successfully captured')
continue
if record.get('revision_path') != old_by[identity]['revision_path']:
errors.append(f'{identity}: captured path differs from baseline manifest')
continue
path = candidate / safe_path(record['revision_path'])
if (not path.is_file() or path.is_symlink()
or candidate not in path.resolve().parents
or any(parent.is_symlink() for parent in path.parents if parent != candidate and candidate in parent.parents)) :
errors.append(f'{identity}: old immutable revision absent or unsafe')
elif hashlib.sha256(path.read_bytes()).hexdigest() != record['served_html_sha256']:
errors.append(f'{identity}: old immutable bytes changed')
if errors:
raise ValueError('\n'.join(errors))
return {'previous_documents': len(old), 'candidate_documents': len(new),
'reviewed_semantic_changes': sorted(changed), 'history_preserved': len(history)}
if __name__ == '__main__':
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--candidate', type=Path, required=True)
p.add_argument('--baseline-manifest', type=Path, required=True)
p.add_argument('--immutable-audit', type=Path, required=True)
p.add_argument('--allowed-changed-id', action='append', default=[])
a = p.parse_args()
try:
result = verify(a.candidate, a.baseline_manifest, a.immutable_audit, a.allowed_changed_id)
except (ValueError, OSError, KeyError, TypeError) as e:
p.exit(1, str(e) + '\n')
print(json.dumps(result, sort_keys=True))