From ca2001078f1842ebd773aa17ad18d90006c7b961 Mon Sep 17 00:00:00 2001 From: 4444jPPP Date: Sat, 14 Nov 2026 07:12:14 -0500 Subject: [PATCH 1/5] feat: agent verification receipt schema, validator and fixtures Co-Authored-By: Claude Sonnet 5.5 --- ges/__main__.py | 5 + ges/receipts.py | 427 ++++++++++++++++++ .../agent-verification-receipt.v1.json | 102 +++++ tests/fixtures/receipts/expected-errors.json | 28 ++ tests/fixtures/receipts/generate.py | 170 +++++++ .../invalid/acceptance-before-merge.json | 55 +++ .../receipts/invalid/bad-timestamp.json | 48 ++ .../invalid/ci-run-other-repository.json | 53 +++ .../invalid/duplicate-check-names.json | 48 ++ .../receipts/invalid/fail-with-zero-exit.json | 48 ++ .../receipts/invalid/finish-before-start.json | 48 ++ .../invalid/generated-before-finish.json | 48 ++ .../invalid/merged-with-acceptance.json | 55 +++ .../invalid/merged-without-evidence.json | 48 ++ .../receipts/invalid/missing-field.json | 45 ++ .../invalid/not-run-with-exit-code.json | 70 +++ .../invalid/not-verifiable-with-output.json | 70 +++ .../invalid/overall-pass-despite-fail.json | 48 ++ .../invalid/overall-pass-despite-not-run.json | 70 +++ .../invalid/pass-with-nonzero-exit.json | 48 ++ .../invalid/pass-without-output-digest.json | 48 ++ .../fixtures/receipts/invalid/short-sha.json | 48 ++ .../invalid/skipped-conclusion-as-pass.json | 48 ++ .../invalid/staged-with-merge-evidence.json | 53 +++ .../invalid/tampered-claim-keeps-old-id.json | 48 ++ .../receipts/invalid/tampered-content.json | 48 ++ .../receipts/invalid/unknown-claim.json | 48 ++ .../receipts/invalid/unknown-field.json | 49 ++ .../invalid/verified-with-failing-ci.json | 55 +++ .../verified-with-incomplete-checks.json | 55 +++ .../invalid/verified-without-acceptance.json | 51 +++ .../receipts/valid/github-checks.json | 48 ++ tests/fixtures/receipts/valid/incomplete.json | 70 +++ tests/fixtures/receipts/valid/merged.json | 53 +++ .../fixtures/receipts/valid/staged-fail.json | 48 ++ .../fixtures/receipts/valid/staged-pass.json | 48 ++ tests/fixtures/receipts/valid/verified.json | 55 +++ 37 files changed, 2407 insertions(+) create mode 100644 ges/receipts.py create mode 100644 schemas/receipts/agent-verification-receipt.v1.json create mode 100644 tests/fixtures/receipts/expected-errors.json create mode 100644 tests/fixtures/receipts/generate.py create mode 100644 tests/fixtures/receipts/invalid/acceptance-before-merge.json create mode 100644 tests/fixtures/receipts/invalid/bad-timestamp.json create mode 100644 tests/fixtures/receipts/invalid/ci-run-other-repository.json create mode 100644 tests/fixtures/receipts/invalid/duplicate-check-names.json create mode 100644 tests/fixtures/receipts/invalid/fail-with-zero-exit.json create mode 100644 tests/fixtures/receipts/invalid/finish-before-start.json create mode 100644 tests/fixtures/receipts/invalid/generated-before-finish.json create mode 100644 tests/fixtures/receipts/invalid/merged-with-acceptance.json create mode 100644 tests/fixtures/receipts/invalid/merged-without-evidence.json create mode 100644 tests/fixtures/receipts/invalid/missing-field.json create mode 100644 tests/fixtures/receipts/invalid/not-run-with-exit-code.json create mode 100644 tests/fixtures/receipts/invalid/not-verifiable-with-output.json create mode 100644 tests/fixtures/receipts/invalid/overall-pass-despite-fail.json create mode 100644 tests/fixtures/receipts/invalid/overall-pass-despite-not-run.json create mode 100644 tests/fixtures/receipts/invalid/pass-with-nonzero-exit.json create mode 100644 tests/fixtures/receipts/invalid/pass-without-output-digest.json create mode 100644 tests/fixtures/receipts/invalid/short-sha.json create mode 100644 tests/fixtures/receipts/invalid/skipped-conclusion-as-pass.json create mode 100644 tests/fixtures/receipts/invalid/staged-with-merge-evidence.json create mode 100644 tests/fixtures/receipts/invalid/tampered-claim-keeps-old-id.json create mode 100644 tests/fixtures/receipts/invalid/tampered-content.json create mode 100644 tests/fixtures/receipts/invalid/unknown-claim.json create mode 100644 tests/fixtures/receipts/invalid/unknown-field.json create mode 100644 tests/fixtures/receipts/invalid/verified-with-failing-ci.json create mode 100644 tests/fixtures/receipts/invalid/verified-with-incomplete-checks.json create mode 100644 tests/fixtures/receipts/invalid/verified-without-acceptance.json create mode 100644 tests/fixtures/receipts/valid/github-checks.json create mode 100644 tests/fixtures/receipts/valid/incomplete.json create mode 100644 tests/fixtures/receipts/valid/merged.json create mode 100644 tests/fixtures/receipts/valid/staged-fail.json create mode 100644 tests/fixtures/receipts/valid/staged-pass.json create mode 100644 tests/fixtures/receipts/valid/verified.json diff --git a/ges/__main__.py b/ges/__main__.py index d9728d12..5f6a6a78 100644 --- a/ges/__main__.py +++ b/ges/__main__.py @@ -15,6 +15,8 @@ def main(argv=None): sub=p.add_subparsers(dest='command',required=True) from .semantics import configure_parser configure_parser(sub) + from .receipts import configure_parser as configure_receipts + configure_receipts(sub) c=sub.add_parser('branch-checks', help='Read-only exact-revision required-check observation; no enforcement certification') c.add_argument('--snapshot',type=Path,required=True); c.add_argument('--profile',type=Path,required=True) c.add_argument('--revision',required=True); c.add_argument('--revision-kind',choices=['head','test_merge','merge_group'],required=True) @@ -44,6 +46,9 @@ def main(argv=None): report=assess(load(args.snapshot),load(args.profile),args.revision,args.revision_kind) dump(args.output,report); print(json.dumps(report['summary'],indent=2)) return 0 if report['observed_checks_pass'] else 1 + if args.command == 'receipt': + from .receipts import run as run_receipt + return run_receipt(args) if args.command == 'semantics': from .semantics import run return run(args) diff --git a/ges/receipts.py b/ges/receipts.py new file mode 100644 index 00000000..d6bf8704 --- /dev/null +++ b/ges/receipts.py @@ -0,0 +1,427 @@ +"""Agent verification receipts: strict schema, cross-field semantics, PR-body injection. + +Standard library only so the reusable GitHub Action can run this file directly +(``python ges/receipts.py ...``) without installing dependencies. + +A receipt records what an agent observed. Validation proves structure and internal +consistency; it does not authenticate the agent, any named human, or the commands' +real execution. See docs/agent-verification-receipts.md for the formal semantics. +""" +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import html +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / 'schemas' / 'receipts' / 'agent-verification-receipt.v1.json' +SCHEMA_ID = 'ges.agent-verification-receipt.v1' +ID_PREFIX = 'agent-verification-receipt:' + +BEGIN_MARKER = '' +END_MARKER = '' +MARKER_FRAGMENT = 'ges:agent-verification-receipt:' +PR_BODY_LIMIT = 65536 + +# Formal mapping from GitHub check-run conclusions (REST "conclusion" values) to +# receipt statuses. Only `success` can be PASS; skipped, neutral, cancelled, stale +# and action_required are never green (they are NOT_VERIFIABLE, not NOT_RUN: the +# provider returned a verdict that does not establish success). A check with no +# conclusion yet is NOT_RUN and carries `github_conclusion: null`. +GITHUB_CONCLUSION_STATUS = { + 'success': 'PASS', + 'failure': 'FAIL', + 'timed_out': 'FAIL', + 'cancelled': 'NOT_VERIFIABLE', + 'skipped': 'NOT_VERIFIABLE', + 'neutral': 'NOT_VERIFIABLE', + 'stale': 'NOT_VERIFIABLE', + 'action_required': 'NOT_VERIFIABLE', +} + +# The closed JSON Schema vocabulary the validator implements. A test walks the +# schema and fails on any keyword outside this set, so the schema cannot silently +# carry rules that nothing enforces. +SUPPORTED_KEYWORDS = frozenset({ + '$schema', '$id', '$comment', 'title', '$defs', '$ref', 'type', 'const', 'enum', + 'pattern', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', + 'uniqueItems', 'items', 'properties', 'required', 'additionalProperties', 'anyOf', +}) + + +def canonical_bytes(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode('utf-8') + + +def receipt_id(receipt: dict) -> str: + """Identity binds every field except `id`.""" + body = {key: value for key, value in receipt.items() if key != 'id'} + return ID_PREFIX + hashlib.sha256(canonical_bytes(body)).hexdigest() + + +def seal(receipt: dict) -> dict: + """Return a copy with `schema` set and `id` computed. Does not validate.""" + sealed = dict(receipt) + sealed['schema'] = SCHEMA_ID + sealed['id'] = receipt_id(sealed) + return sealed + + +def load_schema() -> dict: + return json.loads(SCHEMA_PATH.read_text(encoding='utf-8')) + + +def _resolve(schema: dict, root: dict) -> dict: + ref = schema.get('$ref') + if ref is None: + return schema + if not ref.startswith('#/$defs/'): + raise ValueError('Unsupported $ref ' + ref) + return root['$defs'][ref[len('#/$defs/'):]] + + +def _matches(value: object, kind: str) -> bool: + return { + 'object': isinstance(value, dict), + 'array': isinstance(value, list), + 'string': isinstance(value, str), + 'integer': type(value) is int, + 'boolean': type(value) is bool, + 'null': value is None, + }[kind] + + +def _check(value: object, schema: dict, root: dict, path: str, errors: list[str]) -> None: + schema = _resolve(schema, root) + if 'anyOf' in schema: + branches = [] + for branch in schema['anyOf']: + attempt: list[str] = [] + _check(value, branch, root, path, attempt) + if not attempt: + return + branches.append(attempt) + errors.append(path + ': matches none of the permitted forms (' + '; '.join( + sorted({e for attempt in branches for e in attempt[:1]})) + ')') + return + if 'const' in schema and value != schema['const']: + errors.append(path + ': must equal ' + json.dumps(schema['const'])) + return + if 'type' in schema: + kinds = [schema['type']] if isinstance(schema['type'], str) else schema['type'] + if not any(_matches(value, kind) for kind in kinds): + errors.append(path + ': incorrect type (expected ' + '|'.join(kinds) + ')') + return + if 'enum' in schema and value not in schema['enum']: + errors.append(path + ': unsupported value') + if isinstance(value, str): + if len(value) < schema.get('minLength', 0): + errors.append(path + ': too short') + if len(value) > schema.get('maxLength', len(value)): + errors.append(path + ': too long') + if 'pattern' in schema and not re.search(schema['pattern'], value): + errors.append(path + ': invalid format') + if type(value) is int: + if value < schema.get('minimum', value): + errors.append(path + ': below minimum') + if value > schema.get('maximum', value): + errors.append(path + ': above maximum') + if isinstance(value, list): + if len(value) < schema.get('minItems', 0): + errors.append(path + ': too few items') + if len(value) > schema.get('maxItems', len(value)): + errors.append(path + ': too many items') + if schema.get('uniqueItems') and len({canonical_bytes(v) for v in value}) != len(value): + errors.append(path + ': duplicate items') + for index, item in enumerate(value): + _check(item, schema['items'], root, f'{path}[{index}]', errors) + if isinstance(value, dict): + properties = schema.get('properties', {}) + for key in schema.get('required', []): + if key not in value: + errors.append(path + '.' + key + ': required field missing') + if schema.get('additionalProperties') is False: + for key in value: + if key not in properties: + errors.append(path + '.' + key + ': unknown field') + for key, item in value.items(): + if key in properties: + _check(item, properties[key], root, path + '.' + key, errors) + + +def validate_structure(receipt: object) -> list[str]: + errors: list[str] = [] + schema = load_schema() + if not isinstance(receipt, dict): + return ['$: receipt must be a JSON object'] + _check(receipt, schema, schema, '$', errors) + return errors + + +def _time(value: str) -> dt.datetime: + return dt.datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def validate_semantics(receipt: dict) -> list[str]: + """Cross-field invariants. Assumes validate_structure passed.""" + errors: list[str] = [] + checks = receipt['checks'] + names = [check['name'] for check in checks] + if len(set(names)) != len(names): + errors.append('$.checks: duplicate check names') + latest = None + for index, check in enumerate(checks): + where = f'$.checks[{index}]' + status, exit_code = check['status'], check['exit_code'] + conclusion = check['github_conclusion'] + started, finished = check['started_at'], check['finished_at'] + if (started is None) != (finished is None): + errors.append(where + ': started_at and finished_at must both be set or both null') + if started and finished: + if _time(finished) < _time(started): + errors.append(where + ': finished_at precedes started_at') + latest = _time(finished) if latest is None else max(latest, _time(finished)) + if conclusion is not None and GITHUB_CONCLUSION_STATUS[conclusion] != status: + errors.append(where + f': github_conclusion {conclusion} requires status ' + + GITHUB_CONCLUSION_STATUS[conclusion]) + if status == 'PASS': + if exit_code != 0 and not (exit_code is None and conclusion == 'success'): + errors.append(where + ': PASS requires exit_code 0 (or a GitHub success conclusion)') + if check['output_sha256'] is None: + errors.append(where + ': PASS requires output_sha256 evidence') + if started is None: + errors.append(where + ': PASS requires started_at and finished_at') + elif status == 'FAIL': + if exit_code == 0 or (exit_code is None and conclusion not in ('failure', 'timed_out')): + errors.append(where + ': FAIL requires a nonzero exit_code (or a GitHub failure conclusion)') + if started is None: + errors.append(where + ': FAIL requires started_at and finished_at') + elif status == 'NOT_RUN': + if exit_code is not None or check['output_sha256'] is not None or started is not None: + errors.append(where + ': NOT_RUN cannot carry exit_code, output or timestamps') + if conclusion is not None: + errors.append(where + ': NOT_RUN cannot carry a provider conclusion') + else: + if exit_code is not None or check['output_sha256'] is not None: + errors.append(where + ': NOT_VERIFIABLE cannot carry exit_code or output evidence') + statuses = {check['status'] for check in checks} + derived = ('FAIL' if 'FAIL' in statuses + else 'INCOMPLETE' if statuses & {'NOT_RUN', 'NOT_VERIFIABLE'} else 'PASS') + if receipt['overall_status'] != derived: + errors.append(f'$.overall_status: derived status is {derived}') + generated = _time(receipt['generated_at']) + if latest is not None and generated < latest: + errors.append('$.generated_at: precedes the last check finish time') + + claim, merge, accept = receipt['claim'], receipt['merge_evidence'], receipt['acceptance'] + if claim == 'STAGED': + if merge is not None or accept is not None: + errors.append('$.claim: STAGED cannot carry merge_evidence or acceptance') + elif claim == 'MERGED': + if merge is None: + errors.append('$.claim: MERGED requires merge_evidence') + if accept is not None: + errors.append('$.claim: MERGED cannot carry acceptance (only VERIFIED does)') + else: + if merge is None: + errors.append('$.claim: VERIFIED requires merge_evidence') + elif merge['ci_conclusion'] != 'success': + errors.append('$.claim: VERIFIED requires merge_evidence.ci_conclusion success') + if accept is None: + errors.append('$.claim: VERIFIED requires human acceptance') + if receipt['overall_status'] != 'PASS': + errors.append('$.claim: VERIFIED requires overall_status PASS') + if merge is not None and accept is not None and _time(accept['accepted_at']) < _time(merge['merged_at']): + errors.append('$.acceptance: accepted_at precedes merged_at') + if merge is not None: + if _time(merge['merged_at']) > generated: + errors.append('$.merge_evidence: merged_at follows generated_at') + repo = receipt['subject']['repository'] + if not merge['ci_run_url'].startswith('https://github.com/' + repo + '/actions/runs/'): + errors.append('$.merge_evidence.ci_run_url: run does not belong to the subject repository') + if accept is not None and _time(accept['accepted_at']) > generated: + errors.append('$.acceptance: accepted_at follows generated_at') + return errors + + +def validate_receipt(receipt: object) -> list[str]: + """Return every violation found; an empty list means structurally and semantically valid.""" + errors = validate_structure(receipt) + if errors: + return errors + errors = validate_semantics(receipt) + if receipt['id'] != receipt_id(receipt): + errors.append('$.id: identity differs from content digest') + return errors + + +def _safe_text(value: str) -> str: + return re.sub(r'[^A-Za-z0-9 _.:/=+-]', '?', value) + + +def render_block(receipt: dict) -> str: + """Render the marker-delimited PR-body block (validated first).""" + errors = validate_receipt(receipt) + if errors: + raise ValueError('Invalid receipt: ' + '; '.join(errors[:5])) + dumped = json.dumps(receipt, sort_keys=True, indent=2, ensure_ascii=True, allow_nan=False) + if MARKER_FRAGMENT in dumped: + raise ValueError('Receipt content contains the injection marker') + longest = max((len(run) for run in re.findall(r'`+', dumped)), default=0) + fence = '`' * max(3, longest + 1) + subject = receipt['subject'] + lines = [ + BEGIN_MARKER, + '### Agent verification receipt', + '', + f"Claim: **{receipt['claim']}** | Overall: **{receipt['overall_status']}** | " + f"Head: `{subject['head_sha'][:12]}` | Agent: {_safe_text(receipt['agent']['name'])}", + '', + '| Check | Status | Exit |', + '| --- | --- | --- |', + ] + for check in receipt['checks']: + exit_code = '-' if check['exit_code'] is None else str(check['exit_code']) + lines.append(f"| {_safe_text(check['name'])} | {check['status']} | {exit_code} |") + lines += [ + '', + '
Receipt JSON (' + html.escape(receipt['id'][:40]) + ')', + '', + fence + 'json', + dumped, + fence, + '', + '
', + '', + 'Structural validation only: this receipt does not authenticate the agent or certify ' + 'Verified status. Receipts below `VERIFIED` are Staged evidence.', + END_MARKER, + ] + return '\n'.join(lines) + + +def _block_span(body: str) -> tuple[int, int] | None: + begins = [m.start() for m in re.finditer(re.escape(BEGIN_MARKER), body)] + ends = [m.end() for m in re.finditer(re.escape(END_MARKER), body)] + if not begins and not ends: + return None + if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]: + raise ValueError('PR body has unbalanced or repeated receipt markers') + return begins[0], ends[0] + + +def inject(body: str | None, receipt: dict) -> str: + """Insert or replace the receipt block, leaving all other body text untouched.""" + body = (body or '').replace('\r\n', '\n') + block = render_block(receipt) + span = _block_span(body) + if span is None: + result = (body.rstrip('\n') + '\n\n' if body.strip() else '') + block + '\n' + else: + result = body[:span[0]] + block + body[span[1]:] + if len(result) > PR_BODY_LIMIT: + raise ValueError(f'PR body would exceed {PR_BODY_LIMIT} characters') + return result + + +def extract(body: str | None) -> dict | None: + """Parse and validate the receipt embedded in a PR body, or None when absent.""" + body = (body or '').replace('\r\n', '\n') + span = _block_span(body) + if span is None: + return None + block = body[span[0]:span[1]] + match = re.search(r'^(`{3,})json\n(.*?)\n\1$', block, re.S | re.M) + if not match: + raise ValueError('Receipt block has no JSON fence') + receipt = json.loads(match.group(2)) + errors = validate_receipt(receipt) + if errors: + raise ValueError('Embedded receipt invalid: ' + '; '.join(errors[:5])) + if block != render_block(receipt): + raise ValueError('Embedded receipt block was altered after rendering') + return receipt + + +def _load_json(path: str) -> object: + return json.loads(Path(path).read_text(encoding='utf-8')) + + +def configure_parser(subparsers) -> None: + parser = subparsers.add_parser('receipt', help='Agent verification receipts') + commands = parser.add_subparsers(dest='receipt_command', required=True) + command = commands.add_parser('validate', help='Validate receipt JSON files') + command.add_argument('files', nargs='+') + command = commands.add_parser('seal', help='Compute schema and id for an unsealed receipt') + command.add_argument('--input', required=True) + command.add_argument('--output', required=True) + command = commands.add_parser('render', help='Print the PR-body block for a receipt') + command.add_argument('--receipt', required=True) + command = commands.add_parser('inject', help='Insert or replace the receipt block in a PR body file') + command.add_argument('--receipt', required=True) + command.add_argument('--body-file', required=True) + command.add_argument('--output', required=True) + command = commands.add_parser('extract', help='Validate the receipt embedded in a PR body file') + command.add_argument('--body-file', required=True) + + +def run(args) -> int: + try: + if args.receipt_command == 'validate': + failed = 0 + for name in args.files: + try: + errors = validate_receipt(_load_json(name)) + except (OSError, ValueError) as exc: + errors = ['$: ' + str(exc)] + for error in errors: + print(f'{name}: {error}', file=sys.stderr) + failed += bool(errors) + if not errors: + print(f'{name}: valid') + return 2 if failed else 0 + if args.receipt_command == 'seal': + sealed = seal(_load_json(args.input)) + errors = validate_receipt(sealed) + for error in errors: + print(f'{args.input}: {error}', file=sys.stderr) + if errors: + return 2 + Path(args.output).write_text(json.dumps(sealed, indent=2, sort_keys=True) + '\n', encoding='utf-8') + return 0 + if args.receipt_command == 'render': + print(render_block(_load_json(args.receipt))) + return 0 + if args.receipt_command == 'inject': + body = Path(args.body_file).read_text(encoding='utf-8') + Path(args.output).write_text(inject(body, _load_json(args.receipt)), encoding='utf-8') + return 0 + if args.receipt_command == 'extract': + receipt = extract(Path(args.body_file).read_text(encoding='utf-8')) + if receipt is None: + print('no receipt block', file=sys.stderr) + return 2 + print(receipt['id']) + return 0 + except (OSError, ValueError, KeyError) as exc: + print('receipt: ' + str(exc), file=sys.stderr) + return 2 + return 2 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest='command', required=True) + configure_parser(sub) + arguments = list(sys.argv[1:] if argv is None else argv) + return run(parser.parse_args(['receipt'] + arguments)) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/schemas/receipts/agent-verification-receipt.v1.json b/schemas/receipts/agent-verification-receipt.v1.json new file mode 100644 index 00000000..260ea571 --- /dev/null +++ b/schemas/receipts/agent-verification-receipt.v1.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:ges:agent-verification-receipt:v1", + "title": "ges.agent-verification-receipt.v1", + "$comment": "Structural contract only. ges.receipts additionally enforces cross-field invariants (derived overall status, status/exit-code consistency, GitHub conclusion mapping, claim ladder, identity digest). A receipt records what an agent observed; it does not authenticate the agent or any named human, and it never certifies Verified without merged-main evidence and human acceptance.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "generated_at", "agent", "subject", "claim", "checks", "overall_status", "merge_evidence", "acceptance", "limitations"], + "properties": { + "schema": {"const": "ges.agent-verification-receipt.v1"}, + "id": {"type": "string", "pattern": "^agent-verification-receipt:[a-f0-9]{64}$"}, + "generated_at": {"$ref": "#/$defs/timestamp"}, + "agent": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "session_ref"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S"}, + "kind": {"type": "string", "enum": ["claude", "codex", "gemini", "copilot", "opencode", "cursor", "human", "other"]}, + "session_ref": {"type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "\\S"} + } + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "pull_request", "head_sha", "base_ref", "base_sha"], + "properties": { + "repository": {"type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$"}, + "pull_request": {"type": ["integer", "null"], "minimum": 1}, + "head_sha": {"$ref": "#/$defs/sha"}, + "base_ref": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^\\s]+$"}, + "base_sha": {"anyOf": [{"$ref": "#/$defs/sha"}, {"type": "null"}]} + } + }, + "claim": {"type": "string", "enum": ["STAGED", "MERGED", "VERIFIED"]}, + "checks": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": {"$ref": "#/$defs/check"} + }, + "overall_status": {"type": "string", "enum": ["PASS", "FAIL", "INCOMPLETE"]}, + "merge_evidence": { + "anyOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["main_sha", "merged_at", "ci_run_url", "ci_conclusion"], + "properties": { + "main_sha": {"$ref": "#/$defs/sha"}, + "merged_at": {"$ref": "#/$defs/timestamp"}, + "ci_run_url": {"type": "string", "pattern": "^https://github\\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/actions/runs/[0-9]+$"}, + "ci_conclusion": {"$ref": "#/$defs/githubConclusion"} + } + } + ] + }, + "acceptance": { + "anyOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["accepted_by", "reference", "accepted_at"], + "properties": { + "accepted_by": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S"}, + "reference": {"type": "string", "minLength": 1, "maxLength": 512, "pattern": "\\S"}, + "accepted_at": {"$ref": "#/$defs/timestamp"} + } + } + ] + }, + "limitations": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1, "maxLength": 512, "pattern": "\\S"} + } + }, + "$defs": { + "sha": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$"}, + "githubConclusion": {"type": "string", "enum": ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"]}, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["name", "command", "status", "exit_code", "github_conclusion", "started_at", "finished_at", "output_sha256", "observation"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\r\\n]+$"}, + "command": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^\\r\\n]+$"}, + "status": {"type": "string", "enum": ["PASS", "FAIL", "NOT_RUN", "NOT_VERIFIABLE"]}, + "exit_code": {"type": ["integer", "null"], "minimum": -255, "maximum": 255}, + "github_conclusion": {"anyOf": [{"$ref": "#/$defs/githubConclusion"}, {"type": "null"}]}, + "started_at": {"anyOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, + "finished_at": {"anyOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, + "output_sha256": {"anyOf": [{"type": "string", "pattern": "^[a-f0-9]{64}$"}, {"type": "null"}]}, + "observation": {"type": "string", "maxLength": 2048} + } + } + } +} diff --git a/tests/fixtures/receipts/expected-errors.json b/tests/fixtures/receipts/expected-errors.json new file mode 100644 index 00000000..06805dea --- /dev/null +++ b/tests/fixtures/receipts/expected-errors.json @@ -0,0 +1,28 @@ +{ + "acceptance-before-merge": "precedes merged_at", + "bad-timestamp": "generated_at", + "ci-run-other-repository": "does not belong to the subject repository", + "duplicate-check-names": "duplicate check names", + "fail-with-zero-exit": "FAIL requires a nonzero exit_code", + "finish-before-start": "precedes started_at", + "generated-before-finish": "precedes the last check", + "merged-with-acceptance": "MERGED cannot carry acceptance", + "merged-without-evidence": "MERGED requires merge_evidence", + "missing-field": "limitations", + "not-run-with-exit-code": "NOT_RUN cannot carry", + "not-verifiable-with-output": "NOT_VERIFIABLE cannot carry", + "overall-pass-despite-fail": "derived status is FAIL", + "overall-pass-despite-not-run": "derived status is INCOMPLETE", + "pass-with-nonzero-exit": "PASS requires exit_code 0", + "pass-without-output-digest": "PASS requires output_sha256", + "short-sha": "head_sha", + "skipped-conclusion-as-pass": "requires status NOT_VERIFIABLE", + "staged-with-merge-evidence": "STAGED cannot carry", + "tampered-claim-keeps-old-id": "MERGED requires merge_evidence", + "tampered-content": "identity differs", + "unknown-claim": "$.claim", + "unknown-field": "unknown field", + "verified-with-failing-ci": "ci_conclusion success", + "verified-with-incomplete-checks": "derived status is PASS", + "verified-without-acceptance": "VERIFIED requires human acceptance" +} diff --git a/tests/fixtures/receipts/generate.py b/tests/fixtures/receipts/generate.py new file mode 100644 index 00000000..64bd16d6 --- /dev/null +++ b/tests/fixtures/receipts/generate.py @@ -0,0 +1,170 @@ +"""Regenerate receipt fixtures: `python tests/fixtures/receipts/generate.py`. + +Valid fixtures are sealed receipts. Invalid fixtures mutate a valid one and are +re-sealed so each fails only the invariant named in expected-errors.json (except +identity cases, which are deliberately left unsealed). +""" +import copy +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[2])) +from ges.receipts import seal # noqa: E402 + +SHA_A = 'a' * 40 +SHA_B = 'b' * 40 +SHA_C = 'c' * 40 +DIGEST = 'd' * 64 +RUN = 'https://github.com/4444J99/guide-github-standard/actions/runs/123456789' + + +def check(name, command, status='PASS', exit_code=0, **extra): + base = { + 'name': name, 'command': command, 'status': status, 'exit_code': exit_code, + 'github_conclusion': None, 'started_at': '2026-11-14T10:00:00Z', + 'finished_at': '2026-11-14T10:00:30Z', 'output_sha256': DIGEST, + 'observation': 'observed locally', + } + base.update(extra) + return base + + +def staged(): + return { + 'generated_at': '2026-11-14T10:05:00Z', + 'agent': {'name': 'claude-code', 'kind': 'claude', 'session_ref': 'session-0001'}, + 'subject': {'repository': '4444J99/guide-github-standard', 'pull_request': 521, + 'head_sha': SHA_A, 'base_ref': 'main', 'base_sha': SHA_B}, + 'claim': 'STAGED', + 'checks': [ + check('unit tests', 'python -m unittest discover -s tests -v'), + check('ges validate', 'python -m ges validate'), + ], + 'overall_status': 'PASS', 'merge_evidence': None, 'acceptance': None, + 'limitations': ['Branch-only evidence; not merged to main.'], + } + + +def merged(): + receipt = staged() + receipt['claim'] = 'MERGED' + receipt['generated_at'] = '2026-11-14T11:00:00Z' + receipt['merge_evidence'] = {'main_sha': SHA_C, 'merged_at': '2026-11-14T10:30:00Z', + 'ci_run_url': RUN, 'ci_conclusion': 'success'} + return receipt + + +def verified(): + receipt = merged() + receipt['claim'] = 'VERIFIED' + receipt['generated_at'] = '2026-11-14T12:00:00Z' + receipt['acceptance'] = {'accepted_by': 'designated-owner', 'accepted_at': '2026-11-14T11:30:00Z', + 'reference': 'https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1'} + receipt['limitations'] = [] + return receipt + + +def incomplete(): + receipt = staged() + receipt['checks'].append(check('remote CI', 'gh pr checks 521', status='NOT_RUN', exit_code=None, + started_at=None, finished_at=None, output_sha256=None, + observation='CI had not started')) + receipt['checks'].append(check('security review', 'human review', status='NOT_VERIFIABLE', exit_code=None, + output_sha256=None, observation='requires a human reviewer')) + receipt['overall_status'] = 'INCOMPLETE' + return receipt + + +def failing(): + receipt = staged() + receipt['checks'][1] = check('ges validate', 'python -m ges validate', status='FAIL', exit_code=2, + observation='validation errors reported') + receipt['overall_status'] = 'FAIL' + return receipt + + +def github_checks(): + receipt = staged() + receipt['checks'] = [ + check('validate (3.13)', 'GitHub check run: validate (3.13)', exit_code=None, github_conclusion='success'), + check('lint', 'GitHub check run: lint', status='NOT_VERIFIABLE', exit_code=None, + github_conclusion='skipped', output_sha256=None, observation='provider skipped the job'), + ] + receipt['overall_status'] = 'INCOMPLETE' + return receipt + + +def mutate(base, fn, reseal=True): + receipt = copy.deepcopy(seal(base())) + fn(receipt) + return seal(receipt) if reseal else receipt + + +def set_path(receipt, dotted, value): + node = receipt + parts = dotted.split('.') + for part in parts[:-1]: + node = node[int(part)] if isinstance(node, list) else node[part] + last = parts[-1] + if isinstance(node, list): + node[int(last)] = value + else: + node[last] = value + + +def cases(): + out = {} + def add(name, base, dotted, value, expect, reseal=True): + out[name] = (mutate(base, lambda r: set_path(r, dotted, value), reseal), expect) + add('short-sha', staged, 'subject.head_sha', 'abc1234', 'head_sha') + add('unknown-claim', staged, 'claim', 'DONE', '$.claim') + add('bad-timestamp', staged, 'generated_at', '2026-11-14 10:05:00', 'generated_at') + add('pass-with-nonzero-exit', staged, 'checks.0.exit_code', 1, 'PASS requires exit_code 0') + add('pass-without-output-digest', staged, 'checks.0.output_sha256', None, 'PASS requires output_sha256') + add('fail-with-zero-exit', failing, 'checks.1.exit_code', 0, 'FAIL requires a nonzero exit_code') + add('overall-pass-despite-fail', failing, 'overall_status', 'PASS', 'derived status is FAIL') + add('overall-pass-despite-not-run', incomplete, 'overall_status', 'PASS', 'derived status is INCOMPLETE') + add('not-run-with-exit-code', incomplete, 'checks.2.exit_code', 0, 'NOT_RUN cannot carry') + add('not-verifiable-with-output', incomplete, 'checks.3.output_sha256', DIGEST, 'NOT_VERIFIABLE cannot carry') + add('skipped-conclusion-as-pass', github_checks, 'checks.1.status', 'PASS', 'requires status NOT_VERIFIABLE') + add('duplicate-check-names', staged, 'checks.1.name', 'unit tests', 'duplicate check names') + add('finish-before-start', staged, 'checks.0.finished_at', '2026-11-14T09:00:00Z', 'precedes started_at') + add('generated-before-finish', staged, 'generated_at', '2026-11-14T10:00:10Z', 'precedes the last check') + add('staged-with-merge-evidence', merged, 'claim', 'STAGED', 'STAGED cannot carry') + add('merged-without-evidence', merged, 'merge_evidence', None, 'MERGED requires merge_evidence') + add('merged-with-acceptance', verified, 'claim', 'MERGED', 'MERGED cannot carry acceptance') + add('verified-without-acceptance', verified, 'acceptance', None, 'VERIFIED requires human acceptance') + add('verified-with-failing-ci', verified, 'merge_evidence.ci_conclusion', 'failure', 'ci_conclusion success') + add('verified-with-incomplete-checks', verified, 'overall_status', 'INCOMPLETE', 'derived status is PASS') + add('acceptance-before-merge', verified, 'acceptance.accepted_at', '2026-11-14T10:10:00Z', 'precedes merged_at') + add('ci-run-other-repository', merged, 'merge_evidence.ci_run_url', + 'https://github.com/someone/else/actions/runs/1', 'does not belong to the subject repository') + add('unknown-field', staged, 'extra', 'x', 'unknown field') + out['missing-field'] = (seal({k: v for k, v in staged().items() if k != 'limitations'}), 'limitations') + add('tampered-content', staged, 'agent.name', 'someone-else', 'identity differs', reseal=False) + stale = seal(staged()) + stale['claim'] = 'MERGED' + out['tampered-claim-keeps-old-id'] = (stale, 'MERGED requires merge_evidence') + return out + + +def write(path, value): + path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n', encoding='utf-8') + + +def main(): + valid = {'staged-pass': staged, 'merged': merged, 'verified': verified, 'incomplete': incomplete, + 'staged-fail': failing, 'github-checks': github_checks} + for name, build in valid.items(): + write(HERE / 'valid' / (name + '.json'), seal(build())) + expected = {} + for name, (receipt, expect) in cases().items(): + write(HERE / 'invalid' / (name + '.json'), receipt) + expected[name] = expect + write(HERE / 'expected-errors.json', expected) + + +if __name__ == '__main__': + main() diff --git a/tests/fixtures/receipts/invalid/acceptance-before-merge.json b/tests/fixtures/receipts/invalid/acceptance-before-merge.json new file mode 100644 index 00000000..fc944b30 --- /dev/null +++ b/tests/fixtures/receipts/invalid/acceptance-before-merge.json @@ -0,0 +1,55 @@ +{ + "acceptance": { + "accepted_at": "2026-11-14T10:10:00Z", + "accepted_by": "designated-owner", + "reference": "https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1" + }, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:03a755513aa7cdbdae560c94655c52b1d3fdd7ea1232bd7fb4c1921d81bebd49", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/bad-timestamp.json b/tests/fixtures/receipts/invalid/bad-timestamp.json new file mode 100644 index 00000000..700fb894 --- /dev/null +++ b/tests/fixtures/receipts/invalid/bad-timestamp.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14 10:05:00", + "id": "agent-verification-receipt:a4291e664c24814a09c70fbf8981f8bc0392edd6cc1423bcab051e7458e02441", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/ci-run-other-repository.json b/tests/fixtures/receipts/invalid/ci-run-other-repository.json new file mode 100644 index 00000000..f72df5c0 --- /dev/null +++ b/tests/fixtures/receipts/invalid/ci-run-other-repository.json @@ -0,0 +1,53 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:0e59cd107a2fbefab4e85c0ce11ebdc53b39571472d3f243a97fb039c96c840b", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/someone/else/actions/runs/1", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/duplicate-check-names.json b/tests/fixtures/receipts/invalid/duplicate-check-names.json new file mode 100644 index 00000000..5d3295d8 --- /dev/null +++ b/tests/fixtures/receipts/invalid/duplicate-check-names.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:af63c089050acb0c046c50f879d176a643be2bd0cf7710f0471e4503c5d6ec7f", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/fail-with-zero-exit.json b/tests/fixtures/receipts/invalid/fail-with-zero-exit.json new file mode 100644 index 00000000..31f902a3 --- /dev/null +++ b/tests/fixtures/receipts/invalid/fail-with-zero-exit.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "validation errors reported", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "FAIL" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:ff44f72a66af5ac45faaa40f778b4e6802072aa704863a705348d8f24aab462f", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "FAIL", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/finish-before-start.json b/tests/fixtures/receipts/invalid/finish-before-start.json new file mode 100644 index 00000000..2cba8f35 --- /dev/null +++ b/tests/fixtures/receipts/invalid/finish-before-start.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T09:00:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:eda1675cd44ac3110d0f75df5bddd9bf6b989eb46b4bebea064395f9105eaf3b", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/generated-before-finish.json b/tests/fixtures/receipts/invalid/generated-before-finish.json new file mode 100644 index 00000000..52317431 --- /dev/null +++ b/tests/fixtures/receipts/invalid/generated-before-finish.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:00:10Z", + "id": "agent-verification-receipt:91a08bfe4a1b90bf5357d2cc0bfbc2da9da97d2469847ecc45cb58df629e36ae", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/merged-with-acceptance.json b/tests/fixtures/receipts/invalid/merged-with-acceptance.json new file mode 100644 index 00000000..8871f53f --- /dev/null +++ b/tests/fixtures/receipts/invalid/merged-with-acceptance.json @@ -0,0 +1,55 @@ +{ + "acceptance": { + "accepted_at": "2026-11-14T11:30:00Z", + "accepted_by": "designated-owner", + "reference": "https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1" + }, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:3d811c146d5d922eb950fe6dd9cd6a612deb7ebe4bc4f57a83ffebfcc6d1044f", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/merged-without-evidence.json b/tests/fixtures/receipts/invalid/merged-without-evidence.json new file mode 100644 index 00000000..057ee6db --- /dev/null +++ b/tests/fixtures/receipts/invalid/merged-without-evidence.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:0e142180809671bca35d5247a1c9e47f36e6481ef48c00d579d37226aa56b572", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/missing-field.json b/tests/fixtures/receipts/invalid/missing-field.json new file mode 100644 index 00000000..5ea3a791 --- /dev/null +++ b/tests/fixtures/receipts/invalid/missing-field.json @@ -0,0 +1,45 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:4f94ddea52a4dbeb40f80b34f2b2265311343ec0f63ac31f808af9c43d9589c3", + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/not-run-with-exit-code.json b/tests/fixtures/receipts/invalid/not-run-with-exit-code.json new file mode 100644 index 00000000..f7727b6a --- /dev/null +++ b/tests/fixtures/receipts/invalid/not-run-with-exit-code.json @@ -0,0 +1,70 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "gh pr checks 521", + "exit_code": 0, + "finished_at": null, + "github_conclusion": null, + "name": "remote CI", + "observation": "CI had not started", + "output_sha256": null, + "started_at": null, + "status": "NOT_RUN" + }, + { + "command": "human review", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "security review", + "observation": "requires a human reviewer", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "NOT_VERIFIABLE" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:3fc3d62343f88ff22aec6507fee54122fc68f63eae0d3da9c03166a8875b7dbb", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/not-verifiable-with-output.json b/tests/fixtures/receipts/invalid/not-verifiable-with-output.json new file mode 100644 index 00000000..69051d1b --- /dev/null +++ b/tests/fixtures/receipts/invalid/not-verifiable-with-output.json @@ -0,0 +1,70 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "gh pr checks 521", + "exit_code": null, + "finished_at": null, + "github_conclusion": null, + "name": "remote CI", + "observation": "CI had not started", + "output_sha256": null, + "started_at": null, + "status": "NOT_RUN" + }, + { + "command": "human review", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "security review", + "observation": "requires a human reviewer", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "NOT_VERIFIABLE" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:1c9a22e3ecff936258fdec25c7c98401a67f2a64d94eaf542e1866dbcc472251", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/overall-pass-despite-fail.json b/tests/fixtures/receipts/invalid/overall-pass-despite-fail.json new file mode 100644 index 00000000..051e877b --- /dev/null +++ b/tests/fixtures/receipts/invalid/overall-pass-despite-fail.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 2, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "validation errors reported", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "FAIL" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:00b4f4985a1eea0c5eeca8ca59694a692672126756a824196cfeaf771e9d4351", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/overall-pass-despite-not-run.json b/tests/fixtures/receipts/invalid/overall-pass-despite-not-run.json new file mode 100644 index 00000000..8542485a --- /dev/null +++ b/tests/fixtures/receipts/invalid/overall-pass-despite-not-run.json @@ -0,0 +1,70 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "gh pr checks 521", + "exit_code": null, + "finished_at": null, + "github_conclusion": null, + "name": "remote CI", + "observation": "CI had not started", + "output_sha256": null, + "started_at": null, + "status": "NOT_RUN" + }, + { + "command": "human review", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "security review", + "observation": "requires a human reviewer", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "NOT_VERIFIABLE" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:5226287e33d1b910bee1bef3b48bd4ee07194ec262040461c845a9928f423f47", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/pass-with-nonzero-exit.json b/tests/fixtures/receipts/invalid/pass-with-nonzero-exit.json new file mode 100644 index 00000000..8ab96a2e --- /dev/null +++ b/tests/fixtures/receipts/invalid/pass-with-nonzero-exit.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 1, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:876f2d87ab8820d252f800cae5e93521e17adfb6ac19039c0f2ef83cab2262a7", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/pass-without-output-digest.json b/tests/fixtures/receipts/invalid/pass-without-output-digest.json new file mode 100644 index 00000000..fd602277 --- /dev/null +++ b/tests/fixtures/receipts/invalid/pass-without-output-digest.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:768363eebf37816d7dd897ef549f74bf050a2546dda461cf432e6f5c967c0249", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/short-sha.json b/tests/fixtures/receipts/invalid/short-sha.json new file mode 100644 index 00000000..2da21f4c --- /dev/null +++ b/tests/fixtures/receipts/invalid/short-sha.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:d8cc25c7e275fc91c47f23a5b5b48e478d693d248dddd6b7a994c1f97da3410d", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "abc1234", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/skipped-conclusion-as-pass.json b/tests/fixtures/receipts/invalid/skipped-conclusion-as-pass.json new file mode 100644 index 00000000..0e095509 --- /dev/null +++ b/tests/fixtures/receipts/invalid/skipped-conclusion-as-pass.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "GitHub check run: validate (3.13)", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": "success", + "name": "validate (3.13)", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "GitHub check run: lint", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": "skipped", + "name": "lint", + "observation": "provider skipped the job", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:f2ad4b50e039e484c1c757258346a630924cc8fda75ae30fc859cabd77b83c73", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/staged-with-merge-evidence.json b/tests/fixtures/receipts/invalid/staged-with-merge-evidence.json new file mode 100644 index 00000000..648f7332 --- /dev/null +++ b/tests/fixtures/receipts/invalid/staged-with-merge-evidence.json @@ -0,0 +1,53 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:cd0aee95779ee9a67e3c4067b7da0dabc45f9397e1d56fbf38df22870c999cb2", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/tampered-claim-keeps-old-id.json b/tests/fixtures/receipts/invalid/tampered-claim-keeps-old-id.json new file mode 100644 index 00000000..66e36d97 --- /dev/null +++ b/tests/fixtures/receipts/invalid/tampered-claim-keeps-old-id.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:0c90f14a1b209ef0fb7d422ed624e0c04c6022d3ae0f9c464c19eae894524e14", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/tampered-content.json b/tests/fixtures/receipts/invalid/tampered-content.json new file mode 100644 index 00000000..d6911c6c --- /dev/null +++ b/tests/fixtures/receipts/invalid/tampered-content.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "someone-else", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:0c90f14a1b209ef0fb7d422ed624e0c04c6022d3ae0f9c464c19eae894524e14", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/unknown-claim.json b/tests/fixtures/receipts/invalid/unknown-claim.json new file mode 100644 index 00000000..ceb9fdbe --- /dev/null +++ b/tests/fixtures/receipts/invalid/unknown-claim.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "DONE", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:c76b0dc32e13123b6c73c498ae6b6247cf03d1c4b09f89c1b37219b46cd687ee", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/unknown-field.json b/tests/fixtures/receipts/invalid/unknown-field.json new file mode 100644 index 00000000..a02095e6 --- /dev/null +++ b/tests/fixtures/receipts/invalid/unknown-field.json @@ -0,0 +1,49 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "extra": "x", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:e6ce2052964dedc54144ed6ae5c4f04bef9a0e8b7d4c778298696248bd5926ca", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/verified-with-failing-ci.json b/tests/fixtures/receipts/invalid/verified-with-failing-ci.json new file mode 100644 index 00000000..8f41ba42 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-with-failing-ci.json @@ -0,0 +1,55 @@ +{ + "acceptance": { + "accepted_at": "2026-11-14T11:30:00Z", + "accepted_by": "designated-owner", + "reference": "https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1" + }, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:cf8cbec95463ec07216734e74dcfa6e85e2da9f1120fb8b8f012032897593263", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "failure", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/verified-with-incomplete-checks.json b/tests/fixtures/receipts/invalid/verified-with-incomplete-checks.json new file mode 100644 index 00000000..517beb28 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-with-incomplete-checks.json @@ -0,0 +1,55 @@ +{ + "acceptance": { + "accepted_at": "2026-11-14T11:30:00Z", + "accepted_by": "designated-owner", + "reference": "https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1" + }, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:a4051eb5bd103f98093ea32bfe16a55f3eb3a69821559c07600509db6802507b", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/invalid/verified-without-acceptance.json b/tests/fixtures/receipts/invalid/verified-without-acceptance.json new file mode 100644 index 00000000..56132176 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-without-acceptance.json @@ -0,0 +1,51 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:d5f929256736f55f2db54e808b3fc4b69d22811abda314b00e5aaced34e1a4b7", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/github-checks.json b/tests/fixtures/receipts/valid/github-checks.json new file mode 100644 index 00000000..a274d299 --- /dev/null +++ b/tests/fixtures/receipts/valid/github-checks.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "GitHub check run: validate (3.13)", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": "success", + "name": "validate (3.13)", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "GitHub check run: lint", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": "skipped", + "name": "lint", + "observation": "provider skipped the job", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "NOT_VERIFIABLE" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:58e9eb271ad523e936d7074928226aac47081dbde6f682800a91a5d6916c4cbb", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/incomplete.json b/tests/fixtures/receipts/valid/incomplete.json new file mode 100644 index 00000000..16bcfa74 --- /dev/null +++ b/tests/fixtures/receipts/valid/incomplete.json @@ -0,0 +1,70 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "gh pr checks 521", + "exit_code": null, + "finished_at": null, + "github_conclusion": null, + "name": "remote CI", + "observation": "CI had not started", + "output_sha256": null, + "started_at": null, + "status": "NOT_RUN" + }, + { + "command": "human review", + "exit_code": null, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "security review", + "observation": "requires a human reviewer", + "output_sha256": null, + "started_at": "2026-11-14T10:00:00Z", + "status": "NOT_VERIFIABLE" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:7080849bcdbdcf66723883f117282d84cfa9917b122db5334a16ceb985adbc28", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/merged.json b/tests/fixtures/receipts/valid/merged.json new file mode 100644 index 00000000..28190cbe --- /dev/null +++ b/tests/fixtures/receipts/valid/merged.json @@ -0,0 +1,53 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:2c37a218e374988f7e7f2341536b800aa83de6f9db4a85058c1e8166b0622c0c", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/staged-fail.json b/tests/fixtures/receipts/valid/staged-fail.json new file mode 100644 index 00000000..0c5fefee --- /dev/null +++ b/tests/fixtures/receipts/valid/staged-fail.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 2, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "validation errors reported", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "FAIL" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:cc4f104a5cc1a997bf4c47daa356814939271f949a24eda175c4b8d669d879d1", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "FAIL", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/staged-pass.json b/tests/fixtures/receipts/valid/staged-pass.json new file mode 100644 index 00000000..29caa5b2 --- /dev/null +++ b/tests/fixtures/receipts/valid/staged-pass.json @@ -0,0 +1,48 @@ +{ + "acceptance": null, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:0c90f14a1b209ef0fb7d422ed624e0c04c6022d3ae0f9c464c19eae894524e14", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} diff --git a/tests/fixtures/receipts/valid/verified.json b/tests/fixtures/receipts/valid/verified.json new file mode 100644 index 00000000..9aff42d0 --- /dev/null +++ b/tests/fixtures/receipts/valid/verified.json @@ -0,0 +1,55 @@ +{ + "acceptance": { + "accepted_at": "2026-11-14T11:30:00Z", + "accepted_by": "designated-owner", + "reference": "https://github.com/4444J99/guide-github-standard/pull/521#issuecomment-1" + }, + "agent": { + "kind": "claude", + "name": "claude-code", + "session_ref": "session-0001" + }, + "checks": [ + { + "command": "python -m unittest discover -s tests -v", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:00:30Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:00:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:10b715ffe17d6cbeb9cf103e587b607119df17cf5e7d40aea5f7321267dff3a7", + "limitations": [], + "merge_evidence": { + "ci_conclusion": "success", + "ci_run_url": "https://github.com/4444J99/guide-github-standard/actions/runs/123456789", + "main_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-11-14T10:30:00Z" + }, + "overall_status": "PASS", + "schema": "ges.agent-verification-receipt.v1", + "subject": { + "base_ref": "main", + "base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pull_request": 521, + "repository": "4444J99/guide-github-standard" + } +} From 93bd4fb583b560e0bdb8414eaaac61611e83f688 Mon Sep 17 00:00:00 2001 From: 4444jPPP Date: Sat, 14 Nov 2026 07:14:31 -0500 Subject: [PATCH 2/5] feat: receipt PR-body injection, composite action and tests Co-Authored-By: Claude Sonnet 5.5 --- .../actions/inject-agent-receipt/action.yml | 61 ++++ ges/receipts.py | 51 ++- tests/test_receipts.py | 322 ++++++++++++++++++ 3 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 .github/actions/inject-agent-receipt/action.yml create mode 100644 tests/test_receipts.py diff --git a/.github/actions/inject-agent-receipt/action.yml b/.github/actions/inject-agent-receipt/action.yml new file mode 100644 index 00000000..27e2c6b1 --- /dev/null +++ b/.github/actions/inject-agent-receipt/action.yml @@ -0,0 +1,61 @@ +name: Inject agent verification receipt +description: >- + Validate an agent verification receipt (ges.agent-verification-receipt.v1) and insert it into the + pull request body between idempotent markers. Fails visibly on an invalid or stale receipt, an + unsupported event, or a token that cannot write the body; it never skips silently. +inputs: + receipt-path: + description: Path to the sealed receipt JSON, relative to the workspace. The receipt must be produced by a trusted step, not read from untrusted PR content. + required: true + pull-request-number: + description: Pull request number. Defaults to the number in the triggering pull_request event. + required: false + default: '' + github-token: + description: Token with pull-requests write permission. The calling job must grant it. + required: false + default: ${{ github.token }} +outputs: + receipt-id: + description: Identity of the receipt now embedded in the pull request body. + value: ${{ steps.inject.outputs.receipt-id }} +runs: + using: composite + steps: + - id: inject + shell: bash + env: + RECEIPT_PATH: ${{ inputs.receipt-path }} + INPUT_PR: ${{ inputs.pull-request-number }} + EVENT_PR: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ inputs.github-token }} + ACTION_PATH: ${{ github.action_path }} + WORKSPACE: ${{ github.workspace }} + RUNNER_TEMP_DIR: ${{ runner.temp }} + run: | + set -euo pipefail + fail() { echo "::error title=agent-receipt::$1"; exit 1; } + [ "$EVENT_NAME" != "pull_request_target" ] || fail "pull_request_target is refused: the receipt would come from an untrusted context" + pr="${INPUT_PR:-$EVENT_PR}" + case "$pr" in ''|*[!0-9]*) fail "pull-request-number is missing or not numeric" ;; esac + receipt="$WORKSPACE/$RECEIPT_PATH" + [ -f "$receipt" ] || fail "receipt file not found: $RECEIPT_PATH" + tool="$ACTION_PATH/../../../ges/receipts.py" + python3 "$tool" validate "$receipt" || fail "receipt failed validation" + work="$RUNNER_TEMP_DIR/agent-receipt-$pr" + mkdir -p "$work" + gh api "repos/$REPOSITORY/pulls/$pr" > "$work/pr.json" || fail "cannot read the pull request" + python3 "$tool" inject --receipt "$receipt" --pr-json "$work/pr.json" \ + --repository "$REPOSITORY" --pull-request "$pr" --output "$work/body.md" \ + || fail "receipt does not bind to this pull request head or the body cannot be updated" + if ! gh api -X PATCH "repos/$REPOSITORY/pulls/$pr" -F "body=@$work/body.md" > "$work/patched.json"; then + fail "cannot write the pull request body (fork pull requests and read-only tokens cannot; the job needs pull-requests: write)" + fi + gh api "repos/$REPOSITORY/pulls/$pr" > "$work/after.json" + id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$receipt") + python3 "$tool" extract --pr-json "$work/after.json" --expect-id "$id" >/dev/null \ + || fail "the receipt is not present in the pull request body after the update (concurrent body edit?)" + echo "receipt-id=$id" >> "$GITHUB_OUTPUT" + echo "Embedded $id into $REPOSITORY#$pr" diff --git a/ges/receipts.py b/ges/receipts.py index d6bf8704..e8ac24ba 100644 --- a/ges/receipts.py +++ b/ges/receipts.py @@ -349,6 +349,23 @@ def extract(body: str | None) -> dict | None: return receipt +def check_binding(receipt: dict, repository: str, pull_request: int, head_sha: str) -> None: + """Refuse a receipt that does not describe this exact PR head (stale or foreign evidence).""" + subject = receipt['subject'] + if subject['repository'].lower() != repository.lower(): + raise ValueError(f"Receipt repository {subject['repository']} differs from {repository}") + if subject['pull_request'] is not None and subject['pull_request'] != pull_request: + raise ValueError(f"Receipt pull request {subject['pull_request']} differs from {pull_request}") + if subject['head_sha'] != head_sha: + raise ValueError(f"Receipt head_sha {subject['head_sha']} differs from current PR head {head_sha}") + + +def _pr_fields(pr: object) -> tuple[str, str]: + if not isinstance(pr, dict) or not isinstance(pr.get('head'), dict) or not isinstance(pr['head'].get('sha'), str): + raise ValueError('Not a pull request API response') + return pr.get('body') or '', pr['head']['sha'] + + def _load_json(path: str) -> object: return json.loads(Path(path).read_text(encoding='utf-8')) @@ -365,10 +382,17 @@ def configure_parser(subparsers) -> None: command.add_argument('--receipt', required=True) command = commands.add_parser('inject', help='Insert or replace the receipt block in a PR body file') command.add_argument('--receipt', required=True) - command.add_argument('--body-file', required=True) + source = command.add_mutually_exclusive_group(required=True) + source.add_argument('--body-file') + source.add_argument('--pr-json', help='GET /repos/{r}/pulls/{n} response; also checks the receipt binds to this PR head') + command.add_argument('--repository', help='owner/name (required with --pr-json)') + command.add_argument('--pull-request', type=int, help='PR number (required with --pr-json)') command.add_argument('--output', required=True) command = commands.add_parser('extract', help='Validate the receipt embedded in a PR body file') - command.add_argument('--body-file', required=True) + source = command.add_mutually_exclusive_group(required=True) + source.add_argument('--body-file') + source.add_argument('--pr-json') + command.add_argument('--expect-id', help='Fail unless the embedded receipt has this id') def run(args) -> int: @@ -399,14 +423,31 @@ def run(args) -> int: print(render_block(_load_json(args.receipt))) return 0 if args.receipt_command == 'inject': - body = Path(args.body_file).read_text(encoding='utf-8') - Path(args.output).write_text(inject(body, _load_json(args.receipt)), encoding='utf-8') + receipt = _load_json(args.receipt) + errors = validate_receipt(receipt) + if errors: + for error in errors: + print(f'{args.receipt}: {error}', file=sys.stderr) + return 2 + if args.pr_json: + if not args.repository or not args.pull_request: + raise ValueError('--pr-json requires --repository and --pull-request') + body, head = _pr_fields(_load_json(args.pr_json)) + check_binding(receipt, args.repository, args.pull_request, head) + else: + body = Path(args.body_file).read_text(encoding='utf-8') + Path(args.output).write_text(inject(body, receipt), encoding='utf-8') return 0 if args.receipt_command == 'extract': - receipt = extract(Path(args.body_file).read_text(encoding='utf-8')) + body = (_pr_fields(_load_json(args.pr_json))[0] if args.pr_json + else Path(args.body_file).read_text(encoding='utf-8')) + receipt = extract(body) if receipt is None: print('no receipt block', file=sys.stderr) return 2 + if args.expect_id and receipt['id'] != args.expect_id: + print('embedded receipt id differs from the expected id', file=sys.stderr) + return 2 print(receipt['id']) return 0 except (OSError, ValueError, KeyError) as exc: diff --git a/tests/test_receipts.py b/tests/test_receipts.py new file mode 100644 index 00000000..8d4153de --- /dev/null +++ b/tests/test_receipts.py @@ -0,0 +1,322 @@ +import copy +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from ges import receipts # noqa: E402 + +FX = ROOT / 'tests' / 'fixtures' / 'receipts' +ACTION = ROOT / '.github' / 'actions' / 'inject-agent-receipt' / 'action.yml' + + +def load(path): + return json.loads(Path(path).read_text(encoding='utf-8')) + + +class SchemaContract(unittest.TestCase): + def test_schema_uses_only_keywords_the_validator_enforces(self): + def walk(node, path='$'): + if isinstance(node, dict): + for key, value in node.items(): + if key in ('properties', '$defs'): + for name, sub in value.items(): + walk(sub, f'{path}.{key}.{name}') + elif key in ('items',): + walk(value, path + '.items') + elif key == 'anyOf': + for index, sub in enumerate(value): + walk(sub, f'{path}.anyOf[{index}]') + else: + self.assertIn(key, receipts.SUPPORTED_KEYWORDS, f'{path}: unenforced keyword {key}') + walk(receipts.load_schema()) + + def test_every_object_schema_is_closed_and_requires_all_properties(self): + def walk(node, path='$'): + if isinstance(node, dict): + if node.get('type') == 'object': + self.assertIs(node.get('additionalProperties'), False, path) + self.assertEqual(set(node['required']), set(node['properties']), path) + for key, value in node.items(): + walk(value, f'{path}.{key}') + elif isinstance(node, list): + for index, value in enumerate(node): + walk(value, f'{path}[{index}]') + walk(receipts.load_schema()) + + def test_conclusion_mapping_covers_the_schema_enum(self): + schema = receipts.load_schema() + self.assertEqual(set(schema['$defs']['githubConclusion']['enum']), set(receipts.GITHUB_CONCLUSION_STATUS)) + self.assertEqual([k for k, v in receipts.GITHUB_CONCLUSION_STATUS.items() if v == 'PASS'], ['success']) + + +class Fixtures(unittest.TestCase): + def test_valid_fixtures_validate(self): + paths = sorted((FX / 'valid').glob('*.json')) + self.assertGreaterEqual(len(paths), 6) + for path in paths: + self.assertEqual(receipts.validate_receipt(load(path)), [], path.name) + + def test_each_invalid_fixture_fails_for_its_named_reason(self): + expected = load(FX / 'expected-errors.json') + paths = sorted((FX / 'invalid').glob('*.json')) + self.assertEqual({p.stem for p in paths}, set(expected)) + for path in paths: + errors = receipts.validate_receipt(load(path)) + self.assertTrue(any(expected[path.stem] in error for error in errors), (path.name, errors)) + + def test_fixtures_match_generator(self): + import importlib.util + spec = importlib.util.spec_from_file_location('receipt_fixture_generator', FX / 'generate.py') + generator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(generator) + for name, build in {'staged-pass': generator.staged, 'merged': generator.merged, + 'verified': generator.verified, 'incomplete': generator.incomplete, + 'staged-fail': generator.failing, 'github-checks': generator.github_checks}.items(): + self.assertEqual(load(FX / 'valid' / (name + '.json')), receipts.seal(build()), name) + for name, (receipt, expect) in generator.cases().items(): + self.assertEqual(load(FX / 'invalid' / (name + '.json')), receipt, name) + self.assertEqual(load(FX / 'expected-errors.json')[name], expect, name) + + def test_non_object_and_garbage_inputs(self): + for value in ([], 'x', 1, None): + self.assertTrue(receipts.validate_receipt(value)) + + +class Semantics(unittest.TestCase): + def setUp(self): + self.verified = load(FX / 'valid' / 'verified.json') + + def test_branch_pass_is_never_verified_by_construction(self): + staged = load(FX / 'valid' / 'staged-pass.json') + staged['claim'] = 'VERIFIED' + self.assertTrue(receipts.validate_receipt(receipts.seal(staged))) + + def test_every_non_success_conclusion_is_not_green(self): + for conclusion, status in receipts.GITHUB_CONCLUSION_STATUS.items(): + receipt = load(FX / 'valid' / 'github-checks.json') + check = receipt['checks'][0] + check['github_conclusion'] = conclusion + check['status'] = status + check['exit_code'] = None + if status != 'PASS': + check['output_sha256'] = None + receipt['overall_status'] = 'PASS' if status == 'PASS' and receipt['checks'][1]['status'] == 'PASS' else ( + 'FAIL' if status == 'FAIL' else 'INCOMPLETE') + self.assertEqual(receipts.validate_receipt(receipts.seal(receipt)), [], conclusion) + if status != 'PASS': + receipt['checks'][0]['status'] = 'PASS' + self.assertTrue(receipts.validate_receipt(receipts.seal(receipt)), conclusion) + + def test_identity_ignores_key_order_and_changes_with_content(self): + reordered = dict(reversed(list(self.verified.items()))) + self.assertEqual(receipts.receipt_id(reordered), self.verified['id']) + changed = copy.deepcopy(self.verified) + changed['limitations'] = ['changed'] + self.assertNotEqual(receipts.receipt_id(changed), self.verified['id']) + + def test_booleans_are_not_integers(self): + receipt = copy.deepcopy(self.verified) + receipt['subject']['pull_request'] = True + self.assertTrue(receipts.validate_structure(receipt)) + + +class Injection(unittest.TestCase): + def setUp(self): + self.receipt = load(FX / 'valid' / 'staged-pass.json') + + def test_inject_appends_then_replaces_idempotently(self): + body = 'Intent\n\n## Notes\nkeep this' + once = receipts.inject(body, self.receipt) + self.assertIn('keep this', once) + self.assertEqual(once.count(receipts.BEGIN_MARKER), 1) + self.assertEqual(receipts.inject(once, self.receipt), once) + newer = receipts.seal({**self.receipt, 'limitations': ['updated']}) + twice = receipts.inject(once, newer) + self.assertEqual(twice.count(receipts.BEGIN_MARKER), 1) + self.assertEqual(receipts.extract(twice)['id'], newer['id']) + self.assertTrue(twice.startswith('Intent\n\n## Notes\nkeep this')) + + def test_empty_and_missing_bodies(self): + for body in (None, '', '\n\n'): + self.assertEqual(receipts.extract(receipts.inject(body, self.receipt))['id'], self.receipt['id']) + + def test_crlf_bodies_are_normalised(self): + self.assertEqual(receipts.extract(receipts.inject('a\r\nb', self.receipt))['id'], self.receipt['id']) + + def test_invalid_receipt_is_never_injected(self): + bad = load(FX / 'invalid' / 'tampered-content.json') + with self.assertRaises(ValueError): + receipts.inject('body', bad) + + def test_unbalanced_or_repeated_markers_fail(self): + for body in (receipts.BEGIN_MARKER, receipts.END_MARKER + receipts.BEGIN_MARKER, + receipts.BEGIN_MARKER + receipts.END_MARKER + receipts.BEGIN_MARKER + receipts.END_MARKER): + with self.assertRaises(ValueError): + receipts.inject(body, self.receipt) + + def test_hostile_text_cannot_break_out_of_the_block(self): + receipt = copy.deepcopy(self.receipt) + receipt['checks'][0]['name'] = '@octocat [click](https://evil.example) | x