"""Structural workflow guards; evidence claims are shape-only, not authenticated. CLI (exactly four commands): snapshot --repo-root ROOT --paths-json INPUT --output SNAPSHOT; compare-source --repo-root ROOT --before SNAPSHOT --paths-json INPUT; check-tests/check-contracts --input JSON. Exit 0 is PASS, 1 detected drift/violations, 2 rejected input. APIs raise GuardError for rejected input or return status/violations. JSON inputs are regular files bounded to 1 MiB; unknown/missing/duplicate object keys and boolean-as-integer fields block. ROOT is a literal canonical absolute Git root, without symlink ancestors. Paths are a nonempty unique repository-relative string array, with no dot/traversal segments or backslash aliases. Protected files must exist and be regular. Snapshot output is new/no-clobber, mode 0600, outside ROOT, in an already-existing executing-UID-owned 0700 directory. No permissions or directories are repaired. check_tests input has exactly these required keys: baseline_ids, current_ids: arrays of unique nonempty test-ID strings; baseline_status, current_status: exact ID-keyed maps with values {skip: bool, expected_failure: bool}; baseline_collection, current_collection: objects with exactly {source_revision: lowercase 40-hex, command_id: nonempty string, argv: nonempty array of nonempty strings, inventory_sha256: lowercase 64-hex, evidence: nonempty reference string}. The digest is SHA256 over UTF-8 bytes of json.dumps( {'ids': sorted(ids), 'statuses': {id: statuses[id] for id in sorted(ids)}}, sort_keys=True, separators=(',', ':')) with default JSON escaping. authorized_changes: array of exact objects {old_id: baseline ID, kind: 'delete'|'rename'|'status', replacement_ids: unique current-ID array, reason: nonempty string, approval: nonempty reference, evidence: nonempty semantic-review reference}. delete requires old ID absent and empty replacements; rename requires old ID absent and at least one actual replacement; status requires old ID present and replacements exactly [old_id]. Old IDs and replacement targets cannot repeat between approvals. Ordinary unskipped regression additions need no deletion approval. Every removed ID and newly introduced skip/xfail requires a compatible approval. Do not invent replacement coverage. changed_tests: array, unique by test_id, of exact objects {test_id: current ID, baseline_sha256: lowercase 64-hex, current_sha256: lowercase 64-hex, review_evidence: nonempty reference}. findings: array, unique by id, of exact objects {id: nonempty string, test_id: current ID, classification: 'assertion'|'approved_behavior', red: execution, green: execution}. Each execution has exactly {test_id: finding's test_id, source_revision: lowercase 40-hex, scope_sha256: lowercase 64-hex, command_id: nonempty reference, argv: nonempty string array, exit_code: int, expected_exit_code: same int, failure_kind: string, evidence: nonempty reference}. RED exit is positive/nonzero, failure_kind assertion or approved_behavior; GREEN exit is 0, failure_kind pass. Import/environment failures are not RED. check_contracts input has exactly baseline, current, mappings, stages, precedence: baseline: nonempty array, unique semantic_key, of exact objects {semantic_key: nonempty string, baseline_revision: lowercase 40-hex, path: safe repository-relative path, meaning: nonempty string}; current: array, unique semantic_key, of exact objects {semantic_key: nonempty string, meaning: nonempty string, observation: 'observed'|'not_applicable'|'unknown', evidence: nonempty reference}. unknown blocks even with evidence; not_applicable needs genuine justification. mappings: one exact object per baseline semantic_key {semantic_key: baseline key, current_keys: unique current-key array, status: 'preserved'|'intentionally_changed'|'missing'|'ambiguous', authorization: string, verification: string}. Resolved rows require nonempty current_keys/verification. intentionally_changed additionally requires nonempty authorization; preserved may use empty authorization. missing/ambiguous always block. All current keys are covered. Substitutions and merges may use new current keys; several old rows can map to one current row without dropping any old meaning or verification. stages: nonempty array of unique nonempty strings; precedence: array of unique [prerequisite, dependent] declared-stage pairs. Initialization graph must be acyclic, not a graph of allowed lifecycle loops. Snapshot JSON is generated by snapshot, not hand-declared: schema integer 1, repo canonical root, head full SHA, sorted paths, files keyed by exact paths. Each file object has present true, type regular, sha256 64-hex, and nonnegative integer device/inode/mode/uid/nlink. Comparison checks HEAD, path set and file content/identity metadata; it does not prove absence of edit-then-restore or out-of-scope writes and is not a sandbox. No evidence/approval reference is opened or authenticated by check_tests or check_contracts. Main verifies actual source HEAD/blob/scope digests, approvals, argv/environment/output/exit and intended named failure using existing immutable verification records. Declared hashes are not proof of source identity. Meaning preservation and omitted edits require independent review. Runnable nested JSON examples and main-agent binding procedure are in repository-cache.md, Shared workflow guard gates / Nested input contracts; synthetic fixtures are not evidence. """ import argparse, hashlib, json, os, re, stat, subprocess from functools import wraps from pathlib import Path class GuardError(ValueError): pass _HEX40 = re.compile(r'^[0-9a-f]{40}$') _HEX64 = re.compile(r'^[0-9a-f]{64}$') def _fail(message='invalid workflow guard input'): raise GuardError(message) def _keys(obj, keys): if not isinstance(obj, dict) or set(obj) != set(keys): _fail('invalid object fields') def _str(v): return isinstance(v, str) and bool(v.strip()) def _hex(v, pattern): return isinstance(v, str) and pattern.fullmatch(v) is not None def _guard_errors(function): @wraps(function) def checked(*args, **kwargs): try: return function(*args, **kwargs) except GuardError: raise except (OSError, ValueError, TypeError, KeyError, RecursionError): _fail('workflow guard rejected input') return checked def _directory(raw): if not isinstance(raw, str) or not os.path.isabs(raw) or raw != os.path.normpath(raw): _fail('noncanonical directory path') if raw != '/' and any(part in ('', '.', '..') for part in raw.split('/')[1:]): _fail('noncanonical directory path') fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: for part in raw.split('/')[1:]: if not part: continue nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) os.close(fd) fd = nxt return fd except BaseException: os.close(fd) raise def _json(path): def pairs(items): out = {} for k,v in items: if k in out: _fail('duplicate JSON key') out[k]=v return out try: fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) try: if not stat.S_ISREG(os.fstat(fd).st_mode): _fail('JSON input must be regular') with os.fdopen(fd, 'rb') as f: fd = None raw = f.read(1048577) finally: if fd is not None: os.close(fd) if len(raw)>1048576: _fail('JSON input too large') return json.loads(raw.decode('utf-8'), object_pairs_hook=pairs, parse_constant=lambda _: _fail('invalid JSON number')) except GuardError: raise except (OSError, UnicodeError, ValueError, TypeError, RecursionError): _fail('invalid JSON input') def _paths(paths): if not isinstance(paths,list) or not paths or any(not isinstance(p,str) or not p for p in paths): _fail('invalid paths') for p in paths: if p.startswith('/') or '\\' in p or any(x in ('','.','..') for x in p.split('/')): _fail('unsafe path') if len(set(paths)) != len(paths): _fail('duplicate path') return sorted(paths) def _root(root): raw=os.fspath(root) if not isinstance(raw,str) or not os.path.isabs(raw) or raw.endswith('/') or '//' in raw or '/./' in raw or '/../' in raw: _fail('invalid repository root') fd=None try: fd=_directory(raw) p=subprocess.run(['git','-C',raw,'rev-parse','--show-toplevel','--is-bare-repository'],check=True,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True) lines=p.stdout.splitlines() if len(lines)!=2 or lines[1]!='false' or os.path.realpath(raw)!=lines[0]: _fail('root is not repository top-level') return os.path.realpath(raw) except (OSError, subprocess.SubprocessError): _fail('cannot identify repository root') finally: if fd is not None: os.close(fd) def _head(root): try: h=subprocess.run(['git','-C',root,'rev-parse','--verify','HEAD'],check=True,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True).stdout.strip() if not _hex(h,_HEX40): _fail('invalid HEAD') return h except (OSError,subprocess.SubprocessError): _fail('cannot identify HEAD') def _open_file(root,rel): fd=_directory(root) try: parts=rel.split('/') for part in parts[:-1]: nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd); os.close(fd); fd=nxt named = os.stat(parts[-1], dir_fd=fd, follow_symlinks=False) if not stat.S_ISREG(named.st_mode): _fail('protected path is not a regular file') f = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd) try: opened = os.fstat(f) if not stat.S_ISREG(opened.st_mode) or (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino): _fail('protected file changed') return f, opened except BaseException: os.close(f) raise finally: os.close(fd) def _record(root, rel): fd,st=_open_file(root,rel) try: h=hashlib.sha256() while True: b=os.read(fd,131072) if not b: break h.update(b) end=os.fstat(fd) if (st.st_dev,st.st_ino,st.st_size,st.st_mtime_ns)!=(end.st_dev,end.st_ino,end.st_size,end.st_mtime_ns): _fail('protected file changed during read') return {'present':True,'type':'regular','sha256':h.hexdigest(),'device':st.st_dev,'inode':st.st_ino,'mode':stat.S_IMODE(st.st_mode),'uid':st.st_uid,'nlink':st.st_nlink} finally: os.close(fd) def _out_parent(output,root): raw=os.path.abspath(os.fspath(output)); parent,name=os.path.split(raw) if not name or os.path.commonpath([root,raw])==root: _fail('output must be outside repository') cur=os.open('/' if raw.startswith('/') else '.',os.O_RDONLY|os.O_DIRECTORY) try: for part in [x for x in parent.split('/') if x]: n=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=cur); os.close(cur); cur=n st=os.fstat(cur) if st.st_uid!=os.getuid() or stat.S_IMODE(st.st_mode)!=0o700: _fail('output parent must be owned 0700 directory') return cur,name except (OSError, GuardError): os.close(cur); _fail('unsafe output parent') @_guard_errors def snapshot(repo_root,paths,output): root=_root(repo_root); paths=_paths(paths); head=_head(root) files={p:_record(root,p) for p in paths} if _head(root)!=head: _fail('HEAD changed during capture') parent,name=_out_parent(output,root) fd = None try: fd=os.open(name,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=parent) os.fchmod(fd,0o600); st=os.fstat(fd); named=os.stat(name,dir_fd=parent,follow_symlinks=False) if (st.st_dev,st.st_ino)!=(named.st_dev,named.st_ino): _fail('output changed') data={'schema':1,'repo':root,'head':head,'paths':paths,'files':files} with os.fdopen(fd,'w',encoding='utf8') as f: fd = None json.dump(data,f,sort_keys=True); f.write('\n') except (OSError,ValueError,TypeError): _fail('cannot safely create snapshot') finally: if fd is not None: os.close(fd) os.close(parent) return {'status':'pass','head':head,'paths':paths} @_guard_errors def compare_source(repo_root,before,paths): root=_root(repo_root); paths=_paths(paths); b=_json(before) _keys(b,('schema','repo','head','paths','files')) if type(b['schema']) is not int or b['schema']!=1 or b['repo']!=root or not _hex(b['head'],_HEX40) or b['paths']!=paths or not isinstance(b['files'],dict) or set(b['files'])!=set(paths): _fail('malformed source baseline') for x in b['files'].values(): _keys(x,('present','type','sha256','device','inode','mode','uid','nlink')) if x['present'] is not True or x['type']!='regular' or not _hex(x['sha256'],_HEX64) or any(type(x[k]) is not int or x[k]<0 for k in ('device','inode','mode','uid','nlink')): _fail('malformed file baseline') violations=[] if _head(root)!=b['head']: violations.append('HEAD changed') for p in paths: try: r=_record(root,p) if r!=b['files'][p]: violations.append('source changed: '+p) except (GuardError, OSError): violations.append('source missing or changed: '+p) return {'status':'block' if violations else 'pass','violations':violations} def _ids(d,k): x=d.get(k) if not isinstance(x,list) or any(not _str(i) for i in x) or len(set(x))!=len(x): _fail('invalid IDs') return x,set(x) def _status(s): _keys(s,('skip','expected_failure')) if type(s['skip']) is not bool or type(s['expected_failure']) is not bool: _fail('invalid status') def _collection(c,ids,status): _keys(c,('source_revision','command_id','argv','inventory_sha256','evidence')) if not _hex(c['source_revision'],_HEX40) or not _str(c['command_id']) or not isinstance(c['argv'],list) or not c['argv'] or any(not _str(a) for a in c['argv']) or not _str(c['evidence']): _fail('invalid collection provenance') digest=hashlib.sha256(json.dumps({'ids':sorted(ids),'statuses':{i:status[i] for i in sorted(ids)}},sort_keys=True,separators=(',',':')).encode()).hexdigest() if c['inventory_sha256']!=digest: _fail('collection digest mismatch') @_guard_errors def check_tests(data): _keys(data,('baseline_ids','current_ids','baseline_status','current_status','baseline_collection','current_collection','authorized_changes','changed_tests','findings')) old,olds=_ids(data,'baseline_ids'); new,news=_ids(data,'current_ids') violations=[] for prefix,ids in (('baseline',olds),('current',news)): statuses=data[prefix+'_status'] if not isinstance(statuses,dict) or set(statuses)!=ids: _fail('status inventory mismatch') for s in statuses.values(): _status(s) _collection(data[prefix+'_collection'],ids,statuses) auth=data['authorized_changes'] if not isinstance(auth,list): _fail('invalid authorizations') approvals={}; targets=set() for a in auth: _keys(a,('old_id','kind','replacement_ids','reason','approval','evidence')) if not _str(a['old_id']) or a['old_id'] not in olds or a['old_id'] in approvals or not isinstance(a['replacement_ids'],list) or any(not _str(x) or x not in news for x in a['replacement_ids']) or len(set(a['replacement_ids']))!=len(a['replacement_ids']) or any(not _str(a[k]) for k in ('reason','approval','evidence')): _fail('invalid authorization') kind, old_id, replacements = a['kind'], a['old_id'], a['replacement_ids'] if kind == 'delete': if old_id in news or replacements: _fail('invalid deletion authorization') elif kind == 'rename': if old_id in news or not replacements: _fail('invalid rename authorization') elif kind == 'status': if old_id not in news or replacements != [old_id]: _fail('invalid status authorization') else: _fail('invalid authorization kind') if targets.intersection(a['replacement_ids']): _fail('reused replacement') targets.update(a['replacement_ids']); approvals[a['old_id']]=a for i in olds-news: if i not in approvals: violations.append('removed test lacks authorization') for i in news: if data['current_status'][i]['skip'] and (i not in olds or not data['baseline_status'][i]['skip']) and not any(i in a['replacement_ids'] or a['old_id']==i for a in auth): violations.append('new skip lacks authorization') if data['current_status'][i]['expected_failure'] and (i not in olds or not data['baseline_status'][i]['expected_failure']) and not any(i in a['replacement_ids'] or a['old_id']==i for a in auth): violations.append('new expected failure lacks authorization') changed=data['changed_tests'] if not isinstance(changed,list): _fail('invalid changed tests') seen=set() for c in changed: _keys(c,('test_id','baseline_sha256','current_sha256','review_evidence')) i=c['test_id'] if i not in news or i in seen or not _hex(c['baseline_sha256'],_HEX64) or not _hex(c['current_sha256'],_HEX64) or not _str(c['review_evidence']): _fail('invalid changed test review') seen.add(i) findings=data['findings'] if not isinstance(findings,list): _fail('invalid findings') fids=set() for f in findings: _keys(f,('id','test_id','classification','red','green')) if not _str(f['id']) or f['id'] in fids or f['test_id'] not in news or f['classification'] not in ('assertion','approved_behavior'): _fail('invalid finding') fids.add(f['id']) for stage in ('red','green'): r=f[stage]; _keys(r,('test_id','source_revision','scope_sha256','command_id','argv','exit_code','expected_exit_code','failure_kind','evidence')) if r['test_id']!=f['test_id'] or not _hex(r['source_revision'],_HEX40) or not _hex(r['scope_sha256'],_HEX64) or not _str(r['command_id']) or not isinstance(r['argv'],list) or not r['argv'] or any(not _str(a) for a in r['argv']) or type(r['exit_code']) is not int or r['expected_exit_code']!=r['exit_code'] or type(r['expected_exit_code']) is not int or not _str(r['evidence']): _fail('invalid finding execution') if stage=='red' and (r['exit_code']<=0 or r['failure_kind'] not in ('assertion','approved_behavior')): _fail('invalid RED evidence') if stage=='green' and (r['exit_code']!=0 or r['failure_kind']!='pass'): _fail('invalid GREEN evidence') return {'status':'block' if violations else 'pass','violations':violations} @_guard_errors def check_contracts(data): _keys(data,('baseline','current','mappings','stages','precedence')) b,c,m=data['baseline'],data['current'],data['mappings'] if not all(isinstance(x,list) for x in (b,c,m)) or not b: _fail('invalid or empty contracts') def unique_rows(rows,base): out={} for r in rows: fields=('semantic_key','baseline_revision','path','meaning') if base else ('semantic_key','meaning','observation','evidence') _keys(r,fields); k=r['semantic_key'] if not _str(k) or k in out or not _str(r['meaning']): _fail('invalid contract row') if base: if not _hex(r['baseline_revision'],_HEX40) or not isinstance(r['path'],str) or not r['path'] or r['path'].startswith('/') or any(x in ('','.','..') for x in r['path'].split('/')): _fail('invalid baseline contract') else: if r['observation'] not in ('observed','unknown','not_applicable') or not isinstance(r['evidence'],str) or not r['evidence'].strip() or r['observation']=='unknown': _fail('unverified current contract') out[k]=r return out old=unique_rows(b,True); new=unique_rows(c,False) violations=[]; maps={} for r in m: _keys(r,('semantic_key','current_keys','status','authorization','verification')) k=r['semantic_key'] if k not in old or k in maps or not isinstance(r['current_keys'],list) or any(not isinstance(x,str) for x in r['current_keys']) or len(set(r['current_keys']))!=len(r['current_keys']) or any(x not in new for x in r['current_keys']) or not isinstance(r['authorization'],str) or not isinstance(r['verification'],str): _fail('invalid mapping') maps[k]=r if set(maps)!=set(old): _fail('incomplete mappings') claimed=set() for k,r in maps.items(): status=r['status']; keys=r['current_keys'] if status not in ('preserved','intentionally_changed','missing','ambiguous'): _fail('invalid mapping status') if status in ('missing','ambiguous'): violations.append('unresolved contract: '+k) elif not keys or not r['verification'].strip(): violations.append('contract mapping lacks verification: '+k) if status=='intentionally_changed' and not r['authorization'].strip(): violations.append('contract change lacks authorization: '+k) claimed.update(keys) if claimed != set(new): _fail('unmapped current contract') stages=data['stages']; edges=data['precedence'] if not isinstance(stages,list) or not stages or any(not _str(x) for x in stages) or len(set(stages))!=len(stages): _fail('invalid stages') if not isinstance(edges,list): _fail('invalid precedence') graph={x:[] for x in stages}; indegree={x:0 for x in stages}; seen=set() for e in edges: if not isinstance(e,list) or len(e)!=2 or any(not isinstance(x,str) or x not in graph for x in e) or tuple(e) in seen: _fail('invalid precedence edge') seen.add(tuple(e)); graph[e[0]].append(e[1]); indegree[e[1]]+=1 queue=[x for x in stages if indegree[x]==0]; n=0 while queue: x=queue.pop(); n+=1 for y in graph[x]: indegree[y]-=1 if indegree[y]==0: queue.append(y) if n!=len(stages): violations.append('precedence cycle') return {'status':'block' if violations else 'pass','violations':violations} def main(argv=None): p=argparse.ArgumentParser(); s=p.add_subparsers(dest='cmd',required=True) x=s.add_parser('snapshot'); x.add_argument('--repo-root',required=True); x.add_argument('--paths-json',required=True); x.add_argument('--output',required=True) x=s.add_parser('compare-source'); x.add_argument('--repo-root',required=True); x.add_argument('--before',required=True); x.add_argument('--paths-json',required=True) for name in ('check-tests','check-contracts'): x=s.add_parser(name); x.add_argument('--input',required=True) a=p.parse_args(argv) try: if a.cmd=='snapshot': result=snapshot(a.repo_root,_json(a.paths_json),a.output) elif a.cmd=='compare-source': result=compare_source(a.repo_root,a.before,_json(a.paths_json)) elif a.cmd=='check-tests': result=check_tests(_json(a.input)) else: result=check_contracts(_json(a.input)) print(json.dumps(result,sort_keys=True)); return 0 if result['status']=='pass' else 1 except (GuardError,OSError,ValueError,TypeError,RecursionError): print(json.dumps({'status':'block','violations':['workflow guard rejected input']},sort_keys=True)); return 2 if __name__=='__main__': raise SystemExit(main())