diff --git a/.github/actions/inject-agent-receipt/action.yml b/.github/actions/inject-agent-receipt/action.yml new file mode 100644 index 00000000..807af16a --- /dev/null +++ b/.github/actions/inject-agent-receipt/action.yml @@ -0,0 +1,62 @@ +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" \ + --repository "$REPOSITORY" --pull-request "$pr" >/dev/null \ + || fail "after the update the receipt is missing from the body or no longer binds to the current head (concurrent edit or push)" + echo "receipt-id=$id" >> "$GITHUB_OUTPUT" + echo "Embedded $id into $REPOSITORY#$pr" diff --git a/CHANGELOG.md b/CHANGELOG.md index 611a00ca..607fa6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,3 +35,5 @@ See `evidence/recovery-status.json` for measured construction status and ## Unreleased Phase 1 (secrets & supply chain) warn-only package: reusable `phase1-warn.yml`, `standards/phase1/` checks, pass/fail fixtures, and `docs/phase1-warn-only.md`. No required checks or ruleset changes. + +Agent verification receipts: `ges.agent-verification-receipt.v1` schema, stdlib validator (`python -m ges receipt ...`), valid/invalid fixtures, and the `inject-agent-receipt` composite action; see `docs/agent-verification-receipts.md`. No required checks or ruleset changes. diff --git a/README.md b/README.md index 797ec997..ed6b933e 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Unresolved dependencies and conditional/version rendering remain explicit review ## Where to look -`controls/catalog.json` is the canonical authored control definition. `generated/functional-checklist.md`, `source-crosswalk.md` and `bindings.json` are generated from it. `templates/` contains implementation starting points; `profiles/` contains explicitly draft target policies. `ges/` owns acquisition, assessment and compilation behavior. `tests/` exercises false-pass prevention and deterministic outputs. `docs/` records architecture, limitations, decisions and acceptance criteria. `evidence/` records executed work, not aspirations. +`controls/catalog.json` is the canonical authored control definition. `generated/functional-checklist.md`, `source-crosswalk.md` and `bindings.json` are generated from it. `templates/` contains implementation starting points; `profiles/` contains explicitly draft target policies. `ges/` owns acquisition, assessment and compilation behavior. `tests/` exercises false-pass prevention and deterministic outputs. `schemas/receipts/` and [agent verification receipts](docs/agent-verification-receipts.md) define the strict Staged/Merged/Verified receipt, its validator and the PR-body action. `docs/` records architecture, limitations, decisions and acceptance criteria. `evidence/` records executed work, not aspirations. The large source corpus and page-level review queues are workflow artifacts and a separate downloadable evidence bundle; they are not duplicated into every consumer repository. diff --git a/docs/agent-verification-receipts.md b/docs/agent-verification-receipts.md new file mode 100644 index 00000000..2b3b7d35 --- /dev/null +++ b/docs/agent-verification-receipts.md @@ -0,0 +1,157 @@ +# Agent verification receipts + +`ges.agent-verification-receipt.v1` is a strict, closed JSON record of what an agent +observed when it verified a change, plus a validator, fixtures and a reusable GitHub +Action that embeds the receipt in a pull request body. It encodes this repository's +verification standard (AGENTS.md, GOVERNANCE.md) so that "Staged", "Merged" and +"Verified" are three machine-checkable claims instead of prose. + +Contract files: + +- schema: `schemas/receipts/agent-verification-receipt.v1.json` +- validator, injector and CLI: `ges/receipts.py` (standard library only) +- fixtures: `tests/fixtures/receipts/` (`valid/`, `invalid/`, `expected-errors.json`, `generate.py`) +- action: `.github/actions/inject-agent-receipt/action.yml` + +## Formal semantics + +Let a receipt `R` carry checks `C`, a claim `claim(R)`, optional merge evidence `M` +and optional human acceptance `A`. Every rule below is enforced by `validate_receipt`; +each has a negative fixture. + +**Check status.** A check has exactly one status. + +| Status | Meaning | Required | Forbidden | +| --- | --- | --- | --- | +| `PASS` | The command ran and exited 0 | `exit_code = 0`, `output_sha256`, both timestamps | none beyond the table | +| `FAIL` | The command ran and failed | `exit_code != 0`, both timestamps | `exit_code = 0` | +| `NOT_RUN` | No execution occurred | the command that was intended | exit code, output digest, timestamps, provider conclusion | +| `NOT_VERIFIABLE` | A run (or provider verdict) exists but does not establish success | the command or check name | exit code, output digest | + +A check that mirrors a GitHub check run records the REST `conclusion` in +`github_conclusion`. The mapping is total and fixed: + +| GitHub conclusion | Receipt status | +| --- | --- | +| `success` | `PASS` (exit code may be null; evidence digest still required) | +| `failure`, `timed_out` | `FAIL` | +| `cancelled`, `skipped`, `neutral`, `stale`, `action_required` | `NOT_VERIFIABLE` | +| no conclusion yet | `NOT_RUN` (`github_conclusion` null) | + +So a skipped or neutral check can never read as green, matching the repository's +honest-green aggregate (`standards/phase1/honest_green.py`). Missing evidence is +never `PASS`. + +**Overall status** is derived, never asserted: `FAIL` if any check is `FAIL`; +otherwise `INCOMPLETE` if any check is `NOT_RUN` or `NOT_VERIFIABLE`; otherwise `PASS`. +A receipt whose `overall_status` differs from the derivation is invalid. + +**Claim ladder.** `claim` is the strongest statement the receipt makes. + +| Claim | Requires | Forbids | +| --- | --- | --- | +| `STAGED` | nothing beyond valid checks (branch-only evidence) | merge evidence, acceptance | +| `MERGED` | `merge_evidence` (main SHA, merge time, CI run URL in the subject repository, CI conclusion) | acceptance | +| `VERIFIED` | `overall_status = PASS`, a non-empty `required_checks`, `merge_evidence.ci_conclusion = success`, every check started at or after `merged_at` (observed on the merged commit, not the branch), human `acceptance` at or after the merge time | none | + +`VERIFIED` therefore requires, in the receipt's own words, the merged-main and +organization-acceptance conditions of the Verification Standard. A receipt cannot +prove who the named human is; `acceptance.reference` is a pointer a reviewer follows. + +**Required checks.** `required_checks` names the checks the producer's policy demands +(for this repository, the verification commands in AGENTS.md). Every name must have a +recorded result in any receipt, so an omitted check cannot hide; `VERIFIED` additionally +requires the set to be non-empty. The set is declared by the producer: the validator +proves the receipt is complete against its own declaration, and a reviewer or caller +policy decides whether the declaration is the right one. The schema does not hard-code +any repository's command list. + +**Identity and time.** `id` is `agent-verification-receipt:` plus the SHA-256 of the +canonical encoding (sorted keys, compact separators, UTF-8) of every other field. +Timestamps are UTC with a trailing `Z`; a check's finish does not precede its start, +and `generated_at` is not earlier than any check finish or merge/acceptance time. +Check names are unique; commit SHAs are 40 lowercase hex characters. + +**What a receipt does not establish.** It does not authenticate the agent, prove the +commands ran, or replace human review. Validation is structural plus internal +consistency. The `limitations` array exists for the producer to state what was not +evaluated. + +## Validator + +The validator implements the closed JSON Schema vocabulary the schema uses +(`SUPPORTED_KEYWORDS` in `ges/receipts.py`); a test fails if the schema ever uses a +keyword the validator does not enforce, if an object schema is open, or if a property +is optional. It reports every violation, not only the first. + +``` +python -m ges receipt validate receipt.json # exit 0 valid, 2 invalid +python -m ges receipt seal --input draft.json --output receipt.json +python -m ges receipt render --receipt receipt.json +python -m ges receipt inject --receipt receipt.json --body-file body.md --output new-body.md +python -m ges receipt extract --body-file new-body.md +``` + +`seal` adds `schema` and `id` and refuses to write an invalid receipt. +Regenerate fixtures with `python tests/fixtures/receipts/generate.py`; a test fails +if committed fixtures drift from the generator. + +## PR-body injection + +The block sits between `` and +``. Injection is idempotent: a re-run +replaces the block and leaves all other body text untouched. Repeated or unbalanced +markers fail. The visible table shows only the check name (reduced to a safe +character set, so mentions and links cannot render), status and exit code; all other +detail is inside a fenced JSON block whose fence is longer than any backtick run in +the content. Receipt text containing the marker is refused. `extract` re-renders the +embedded receipt and rejects a block that was edited after rendering. + +## Action + +```yaml +# Caller workflow (pull_request trigger, same-repository branches) +permissions: + contents: read + pull-requests: write +jobs: + receipt: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + concurrency: + group: agent-receipt-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + # ...a trusted step writes receipt.json from observed results... + - uses: 4444J99/guide-github-standard/.github/actions/inject-agent-receipt@ + with: + receipt-path: receipt.json +``` + +Pin the action to a full commit SHA. The step: + +1. refuses `pull_request_target` (the receipt would originate in an untrusted context); +2. validates the receipt and fails on any violation; +3. fetches the PR and refuses a receipt whose repository, PR number or `head_sha` differs + from the PR's current head (stale or foreign evidence is never embedded); +4. patches the body, then re-reads the PR and confirms the same receipt id is present and + still binds to the PR's head at that moment (a push during the update fails the step). + +Failure is always visible: an invalid receipt, a read-only token (fork pull requests, +or a job without `pull-requests: write`) and a lost update from a concurrent body edit +each fail the step. Nothing is skipped silently. The action takes no inputs through +shell interpolation; values reach the script as environment variables. It has no +third-party `uses:` dependencies. The receipt path is read from the workspace, so the +producing step must not take its content from untrusted PR text. + +Outputs: `receipt-id`. + +## Limits + +Concurrent edits to the PR description between the read and the write can still be +lost; the post-write check detects the case in which the receipt itself is lost but not +other overwritten text. This tranche adds no required check, ruleset change or consumer +onboarding. 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..d346bc8c --- /dev/null +++ b/ges/receipts.py @@ -0,0 +1,506 @@ +"""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') + required = receipt['required_checks'] + for name in required: + if name not in names: + errors.append(f'$.required_checks: required check "{name}" has no recorded result') + 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 not required: + errors.append('$.required_checks: VERIFIED requires a declared, non-empty required check set') + if merge is not None: + for index, check in enumerate(checks): + if check['started_at'] is not None and _time(check['started_at']) < _time(merge['merged_at']): + errors.append(f'$.checks[{index}]: VERIFIED evidence must start at or after merged_at ' + '(observed on the merged commit, not the branch)') + 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 _calendar_errors(node: object, path: str = '$') -> list[str]: + """The schema pattern checks timestamp shape; reject impossible calendar values here.""" + errors: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + errors += _calendar_errors(value, path + '.' + key) + elif isinstance(node, list): + for index, value in enumerate(node): + errors += _calendar_errors(value, f'{path}[{index}]') + elif isinstance(node, str) and re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3}|\.\d{6})?Z', node): + try: + _time(node) + except ValueError: + errors.append(path + ': impossible calendar date or time') + 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 = _calendar_errors(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 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')) + + +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) + 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') + 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') + command.add_argument('--repository', help='With --pr-json: also require the receipt to bind to this repository and head') + command.add_argument('--pull-request', type=int) + + +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': + 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': + head = None + if args.pr_json: + body, head = _pr_fields(_load_json(args.pr_json)) + else: + body = Path(args.body_file).read_text(encoding='utf-8') + receipt = extract(body) + if receipt is not None and head is not None and args.repository and args.pull_request: + check_binding(receipt, args.repository, args.pull_request, head) + 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: + 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..f9f35463 --- /dev/null +++ b/schemas/receipts/agent-verification-receipt.v1.json @@ -0,0 +1,108 @@ +{ + "$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", "required_checks", "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"]}, + "required_checks": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\r\\n]+$"} + }, + "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]{3}|\\.[0-9]{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..24d31152 --- /dev/null +++ b/tests/fixtures/receipts/expected-errors.json @@ -0,0 +1,31 @@ +{ + "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", + "required-check-missing": "has no recorded result", + "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-empty-required-checks": "non-empty required check set", + "verified-evidence-predates-merge": "merged commit, not the branch", + "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..b4ce1f8d --- /dev/null +++ b/tests/fixtures/receipts/generate.py @@ -0,0 +1,178 @@ +"""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', + 'required_checks': ['unit tests', 'ges validate'], + '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() + for item in receipt['checks']: + item['started_at'], item['finished_at'] = '2026-11-14T10:31:00Z', '2026-11-14T10:32:00Z' + + 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['required_checks'] = ['validate (3.13)'] + 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('verified-empty-required-checks', verified, 'required_checks', [], 'non-empty required check set') + add('required-check-missing', staged, 'required_checks', ['unit tests', 'ges compile'], 'has no recorded result') + add('verified-evidence-predates-merge', verified, 'checks.0.started_at', '2026-11-14T10:00:00Z', 'merged commit, not the branch') + 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..681a5c22 --- /dev/null +++ b/tests/fixtures/receipts/invalid/acceptance-before-merge.json @@ -0,0 +1,59 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:7cd0284c9a8db37c5468d5ca987d5b921ec268122053952c529fc1915e9fdf85", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..455a5514 --- /dev/null +++ b/tests/fixtures/receipts/invalid/bad-timestamp.json @@ -0,0 +1,52 @@ +{ + "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:ae5745f29c2c4806eb031aaed13b115647f893deeec5dfe5d7b0ade2eebbc8fb", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..7c723d39 --- /dev/null +++ b/tests/fixtures/receipts/invalid/ci-run-other-repository.json @@ -0,0 +1,57 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:d4ab8f4dbae00b888380229350b0e31b7c4f7f872721127b51c93f4a3d4eea10", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..b5d87fc4 --- /dev/null +++ b/tests/fixtures/receipts/invalid/duplicate-check-names.json @@ -0,0 +1,52 @@ +{ + "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:8f50283276f4c26decf27740eb90c1ac9ad1eb323997a25fdc365f4519f4098f", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..e9151f2c --- /dev/null +++ b/tests/fixtures/receipts/invalid/fail-with-zero-exit.json @@ -0,0 +1,52 @@ +{ + "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:86454581a293a522412ac73716513ee44729ee526e955f3bc7346be1160a0f0f", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "FAIL", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..78cfe76d --- /dev/null +++ b/tests/fixtures/receipts/invalid/finish-before-start.json @@ -0,0 +1,52 @@ +{ + "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:1c9ed24a909fab00e85949475292e319f49161d767d998da78043f2dc4bdc03e", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..0ef59964 --- /dev/null +++ b/tests/fixtures/receipts/invalid/generated-before-finish.json @@ -0,0 +1,52 @@ +{ + "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:b6920e62d46884b4e22b9e9934652fcbb7df2c86fae1b5e06d88d387fcda439b", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..9a1f443f --- /dev/null +++ b/tests/fixtures/receipts/invalid/merged-with-acceptance.json @@ -0,0 +1,59 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:de76c7ef5ae4e2faac08c816fcdadbfca11956ce9c19b25b424b16cfcac1b678", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..993d5418 --- /dev/null +++ b/tests/fixtures/receipts/invalid/merged-without-evidence.json @@ -0,0 +1,52 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:f8f120d7db885adae6690ff547d4de9898024048cc0e6bbbf99a5b799291e4f3", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..449018b9 --- /dev/null +++ b/tests/fixtures/receipts/invalid/missing-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", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:769572f7372588b92167baac1c8eec1ae6b3bf81c311909a742c166768931e4c", + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..4ada48bf --- /dev/null +++ b/tests/fixtures/receipts/invalid/not-run-with-exit-code.json @@ -0,0 +1,74 @@ +{ + "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:8790ebfff34553b6a513b0f044c0a732e18d2651da8cae294b552f20176b46d6", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..99bad77a --- /dev/null +++ b/tests/fixtures/receipts/invalid/not-verifiable-with-output.json @@ -0,0 +1,74 @@ +{ + "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:ce83b8ee7b59e44f0d84e29aa78ca150c10a8bc7641b09caad3d5abd918d8515", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..de6425eb --- /dev/null +++ b/tests/fixtures/receipts/invalid/overall-pass-despite-fail.json @@ -0,0 +1,52 @@ +{ + "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:5f2e14b0f0a526fa72e99d941e0e808bfb8b099b70a482521d2751e765ee07c3", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..97455812 --- /dev/null +++ b/tests/fixtures/receipts/invalid/overall-pass-despite-not-run.json @@ -0,0 +1,74 @@ +{ + "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:a9fc8aa2baca99330228e45d1387d23ae3aa6886d3dc5b98398105e8cfcf989e", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..90393e3a --- /dev/null +++ b/tests/fixtures/receipts/invalid/pass-with-nonzero-exit.json @@ -0,0 +1,52 @@ +{ + "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:8200d437629e24b659a3bf5842777b258d7c65331439642e6df8f90a97ff6a64", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..26553d64 --- /dev/null +++ b/tests/fixtures/receipts/invalid/pass-without-output-digest.json @@ -0,0 +1,52 @@ +{ + "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:c04b855f0794a37156d6fb95f790ab868202215a95a66e0806dbf652c0926424", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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/required-check-missing.json b/tests/fixtures/receipts/invalid/required-check-missing.json new file mode 100644 index 00000000..2221ae67 --- /dev/null +++ b/tests/fixtures/receipts/invalid/required-check-missing.json @@ -0,0 +1,52 @@ +{ + "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:edc754ad7d922ff4d764de89510407a52150d13dfa95c269345c195bda432745", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges compile" + ], + "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..6d4b3a5f --- /dev/null +++ b/tests/fixtures/receipts/invalid/short-sha.json @@ -0,0 +1,52 @@ +{ + "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:60962c33d00d0f658f6e0ec6bb8d1640a60f98c8b1ba0587d9a19dcece0ebcbf", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..ea41bffb --- /dev/null +++ b/tests/fixtures/receipts/invalid/skipped-conclusion-as-pass.json @@ -0,0 +1,51 @@ +{ + "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:21b2f3fba8ec75094f142a3a87c943e1bd92215fbac3371cb3c4057956e6e58c", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "required_checks": [ + "validate (3.13)" + ], + "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..64cb1b04 --- /dev/null +++ b/tests/fixtures/receipts/invalid/staged-with-merge-evidence.json @@ -0,0 +1,57 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "STAGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:f61b54e979aaf4ca2ad94d9bb03b40d337bca2d6d15a4d923fcb468d4390bf09", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..2c727536 --- /dev/null +++ b/tests/fixtures/receipts/invalid/tampered-claim-keeps-old-id.json @@ -0,0 +1,52 @@ +{ + "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:f2f1c3969c5a2e7d29bfbbd1ac859600d7fd4e2329d4cc282f2834067954af02", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..0ab22269 --- /dev/null +++ b/tests/fixtures/receipts/invalid/tampered-content.json @@ -0,0 +1,52 @@ +{ + "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:f2f1c3969c5a2e7d29bfbbd1ac859600d7fd4e2329d4cc282f2834067954af02", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..661cb18a --- /dev/null +++ b/tests/fixtures/receipts/invalid/unknown-claim.json @@ -0,0 +1,52 @@ +{ + "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:5f9473cacec2c2a6c1f57bfba5b8f454a33d3dada45ca819ddc554fe7f8923f2", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..c7f72a2d --- /dev/null +++ b/tests/fixtures/receipts/invalid/unknown-field.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", + "extra": "x", + "generated_at": "2026-11-14T10:05:00Z", + "id": "agent-verification-receipt:96c9a37ecb78fcc943aaba83789e17aac704fbb35468e3116eeea351dc85ec10", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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-empty-required-checks.json b/tests/fixtures/receipts/invalid/verified-empty-required-checks.json new file mode 100644 index 00000000..f65755d7 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-empty-required-checks.json @@ -0,0 +1,56 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:6b993c34177b17e0b94a5a3c91a2104c6f4cd5be323a9e905500a0a24ad80d21", + "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", + "required_checks": [], + "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-evidence-predates-merge.json b/tests/fixtures/receipts/invalid/verified-evidence-predates-merge.json new file mode 100644 index 00000000..619afae7 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-evidence-predates-merge.json @@ -0,0 +1,59 @@ +{ + "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:32: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:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:389fa6c7e58d1a76799fefaa0ac75d4342d0b54dc25ae5b055e855224477f007", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..8e1c02be --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-with-failing-ci.json @@ -0,0 +1,59 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:a8231662f47bb6b626597b91bbd49906cc087ef4c104126173b3b94d73085856", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..172848c2 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-with-incomplete-checks.json @@ -0,0 +1,59 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:88d839bb202ec18b69561e12e8b7eeb20ec6bb7423d40ee0db1eb5f2668fe845", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..8764ca25 --- /dev/null +++ b/tests/fixtures/receipts/invalid/verified-without-acceptance.json @@ -0,0 +1,55 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:bf273f3defdf1f1a0348a3cacc8b9fcafe40a613feda07a5fc79c5f2e285d6b0", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..40caf4fc --- /dev/null +++ b/tests/fixtures/receipts/valid/github-checks.json @@ -0,0 +1,51 @@ +{ + "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:f839c50a36f510cddc3b0cbc848fc13c1f401205da28e32dcd8c0e8cc5c52aa2", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "required_checks": [ + "validate (3.13)" + ], + "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..6f79bef0 --- /dev/null +++ b/tests/fixtures/receipts/valid/incomplete.json @@ -0,0 +1,74 @@ +{ + "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:d2398bc33db489d9a8ceb278226e511ba5704fdd9fc0ae5e9681c591ca0d3534", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "INCOMPLETE", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..936026f1 --- /dev/null +++ b/tests/fixtures/receipts/valid/merged.json @@ -0,0 +1,57 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "MERGED", + "generated_at": "2026-11-14T11:00:00Z", + "id": "agent-verification-receipt:ee2861da14483d55b41e6e3e98705eb4a240d12c1644bfeb3a593b33f6a32fbb", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..d5d3730b --- /dev/null +++ b/tests/fixtures/receipts/valid/staged-fail.json @@ -0,0 +1,52 @@ +{ + "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:b6be7852671928dad8079b4adaee3f981b8b14fc037e556d7d1e2f69b09441a0", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "FAIL", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..97039cae --- /dev/null +++ b/tests/fixtures/receipts/valid/staged-pass.json @@ -0,0 +1,52 @@ +{ + "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:f2f1c3969c5a2e7d29bfbbd1ac859600d7fd4e2329d4cc282f2834067954af02", + "limitations": [ + "Branch-only evidence; not merged to main." + ], + "merge_evidence": null, + "overall_status": "PASS", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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..6d3f7d21 --- /dev/null +++ b/tests/fixtures/receipts/valid/verified.json @@ -0,0 +1,59 @@ +{ + "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:32:00Z", + "github_conclusion": null, + "name": "unit tests", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + }, + { + "command": "python -m ges validate", + "exit_code": 0, + "finished_at": "2026-11-14T10:32:00Z", + "github_conclusion": null, + "name": "ges validate", + "observation": "observed locally", + "output_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "started_at": "2026-11-14T10:31:00Z", + "status": "PASS" + } + ], + "claim": "VERIFIED", + "generated_at": "2026-11-14T12:00:00Z", + "id": "agent-verification-receipt:88fb1baba4a8a05ba5bc13eb9e6cba0a51892cc8e41bc79052298d388a6c1300", + "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", + "required_checks": [ + "unit tests", + "ges validate" + ], + "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/test_receipts.py b/tests/test_receipts.py new file mode 100644 index 00000000..6e49ba03 --- /dev/null +++ b/tests/test_receipts.py @@ -0,0 +1,353 @@ +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_impossible_calendar_values_are_errors_not_crashes(self): + for value in ('2026-02-30T10:05:00Z', '2026-11-14T25:00:00Z', '2026-13-01T00:00:00Z'): + receipt = copy.deepcopy(self.verified) + receipt['generated_at'] = value + errors = receipts.validate_receipt(receipts.seal(receipt)) + self.assertTrue(any('impossible calendar' in e for e in errors), value) + receipt = copy.deepcopy(self.verified) + receipt['generated_at'] = '2026-11-14T12:00:00.250Z' + self.assertEqual(receipts.validate_receipt(receipts.seal(receipt)), []) + + 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