From 6fcf15c14f29e40e23f8126af0ab1f0c9f44a6b4 Mon Sep 17 00:00:00 2001 From: ExatronOmega Date: Sun, 14 Jun 2026 09:43:00 +0200 Subject: [PATCH 1/2] Complete roadmap verifier and release gates Co-authored-by: Krzysztof Probola <32790662+rozmiarD@users.noreply.github.com> --- CHANGELOG.md | 6 + PUBLISHING.md | 20 ++ docs/ROADMAP_COMPLETION_AUDIT.md | 125 ++++++++++ docs/VALIDATION.md | 104 +++++++++ scripts/verify_audit_ledger.py | 114 +++++++++ scripts/verify_runner_receipt_binding.py | 186 +++++++++++++++ tests/test_operator_verifier_scripts.py | 281 +++++++++++++++++++++++ 7 files changed, 836 insertions(+) create mode 100644 docs/ROADMAP_COMPLETION_AUDIT.md create mode 100644 scripts/verify_audit_ledger.py create mode 100644 scripts/verify_runner_receipt_binding.py create mode 100644 tests/test_operator_verifier_scripts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee88e8..844cbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,12 @@ GovEngine follows conservative pre-1.0 versioning while the API boundary is stil lifecycle smoke evidence in docs without expanding live-execution claims. - Strengthened public-truth and documentation hygiene guards in `scripts/validate_public_truth.py` and tests. +- Added read-only operator verifier scripts for runner receipt bindings and + development JSONL audit ledgers, with bounded outputs, stable exit codes, and + focused CLI tests. +- Added the next-alpha release readiness gate, downstream compatibility smoke + design, and final roadmap audit decision without publishing, tagging, or + adding host runtime imports. - Added governed-runtime smoke-chain coverage in standalone tests. - Removed Signposter control-plane artifacts (`docs/roadmaps/`, `DOCUMENTATION_HYGIENE.md`) from the tracked public surface. diff --git a/PUBLISHING.md b/PUBLISHING.md index 9660e2b..b04d606 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -13,6 +13,9 @@ Current published PyPI line: `govengine==0.12.2a0` (`0.12.2-alpha`) with `sclite - [ ] `python scripts/validate_alpha_readiness.py` passes for alpha release lines. - [ ] `python -m pytest -q` passes. - [ ] `python scripts/validate_clean_package_install.py --venv /tmp/govengine-clean-release --dev --sclite-source /path/to/SCLite --no-editable` passes from a new virtual environment path, including its isolated installed-package retirement smoke. +- [ ] `scripts/verify_runner_receipt_binding.py` and `scripts/verify_audit_ledger.py` are treated as read-only verifier smoke helpers if their records are used as release evidence; they must not generate runner requests, append ledger records, or expose raw payloads. +- [ ] Maintainer/security review confirms there are no open P0/P1 security findings. Passing tests alone is not release approval when a P0/P1 finding is open. +- [ ] Downstream smoke evidence is classified before release: SCLite released-line is required, SCLite main is optional/coordinated unless targeted, and Ravenclaw/Tecrax host contract smokes remain external host-owned checks. - [ ] Build artifacts are generated from a clean tree. - [ ] No generated `build/`, `dist/`, `*.egg-info`, caches, private state, or Ravenclaw workspace files are committed unless intentionally package metadata. @@ -73,3 +76,20 @@ python -m venv /tmp/govengine-wheel-smoke ``` Do not upload to PyPI or create public tags until the operator explicitly approves the release action. + +## Downstream compatibility smoke gates + +GovEngine release checks may validate downstream compatibility, but production +code must stay host-neutral: + +- Required: SCLite released-line smoke in a clean environment using the + supported `sclite-core` package range. +- Optional/coordinated: SCLite main smoke during dependency waves. Treat it as + an early warning unless the release target explicitly updates the supported + dependency line. +- External/manual: Ravenclaw, Tecrax, or other host contract smokes. Those + checks prove package consumption and host adapter compatibility without + adding host imports to GovEngine. + +These smokes support a release decision. They do not publish, tag, upload, +enable live execution, or make production-readiness claims. diff --git a/docs/ROADMAP_COMPLETION_AUDIT.md b/docs/ROADMAP_COMPLETION_AUDIT.md new file mode 100644 index 0000000..5c343eb --- /dev/null +++ b/docs/ROADMAP_COMPLETION_AUDIT.md @@ -0,0 +1,125 @@ +# GovEngine Roadmap Completion Audit + +Date: 2026-06-14. + +Scope: close the active GovEngine hardening roadmap at the repository level +without using Signposter, mutating issues, publishing packages, enabling live +execution, or moving host-specific behavior into GovEngine. + +## DAG Reduction + +The open issue graph is much larger than the remaining code delta. Most GOV +nodes from `GOV-001` through `GOV-059` are already represented in the current +tree by guard replay hardening, receipt binding, audit ledger contracts, public +projection helpers, inspect-only admission, bounded output guards, public truth +validators, and documentation hygiene tests. + +The remaining eligible tail collapsed into one implementation batch: + +- `GOV-060` and `GOV-061`: design and implement a read-only runner receipt + binding verifier. +- `GOV-062` and `GOV-063`: design and implement a read-only development audit + ledger verifier. +- `GOV-064`: define the next alpha release readiness gate. +- `GOV-065`: define downstream compatibility smoke gates outside production + imports. +- `GOV-066`: record this completion audit and beta-readiness decision. + +`GOV-S001` is intentionally not executed in this repository batch. It is a +cross-repo SCLite transition task whose acceptance criteria require Signposter +status, scheduler, worktree, and dry-run lifecycle commands. That path is not +eligible under the current operator boundary. + +## Completed Evidence + +Implemented code and tests: + +- `scripts/verify_runner_receipt_binding.py` verifies existing request, + receipt, admission, and ticket references through + `validate_runner_receipt_binding()`. It never generates runner requests, + executes work, stores raw evidence, or contacts targets. +- `scripts/verify_audit_ledger.py` verifies an existing development JSONL audit + ledger through `JsonlAuditLedgerAdapter.read()` and `.verify()`. It never + appends or rewrites ledger files. +- `tests/test_operator_verifier_scripts.py` covers successful receipt binding, + tampered receipt binding, valid ledger verification, one-field ledger tamper, + malformed JSONL, and deleted-line detection. + +Documentation and release gates: + +- `docs/VALIDATION.md` now records exact CLI shapes, JSON inputs, bounded + outputs, forbidden behavior, stable exit codes, next-alpha readiness checks, + no-open-P0/P1 security finding requirement, and downstream smoke ownership. +- `PUBLISHING.md` now requires release reviewers to classify SCLite released + line, SCLite main, and host contract smokes without importing host runtimes + into GovEngine. +- `CHANGELOG.md` records the new verifier and release-readiness work under + Unreleased. + +## Boundary Audit + +Confirmed retained non-claims: + +- no live runner, daemon, scheduler, queue, sandbox, or worker loop was added; +- no PKI, CA, KMS, HSM, key storage, or credential management was added; +- no SCLite schema, canonicalization, lifecycle, scoped-ticket, or review + verdict logic was cloned; +- no Ravenclaw, Tecrax, carrier, credential, target, command, raw prompt, raw + stdout/stderr, or raw evidence behavior entered GovEngine production code; +- audit ledger verification remains a development JSONL smoke over bounded + records, not a production persistence, locking, retention, or concurrency + implementation; +- receipt verification remains a binding check over supplied references, not + execution authority. + +## Validation Evidence + +Local validation required before merge: + +```bash +ruff check . +env PYTHONDONTWRITEBYTECODE=1 python3 scripts/validate_public_truth.py +env PYTHONDONTWRITEBYTECODE=1 python3 scripts/validate_alpha_readiness.py +env PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider +git diff --check +``` + +Package/release validation required before any tag or upload: + +```bash +python scripts/validate_clean_package_install.py \ + --venv /tmp/govengine-clean-release \ + --dev \ + --sclite-source /path/to/SCLite \ + --no-editable +python -m build +python -m twine check dist/* +``` + +CI evidence is the repository workflow `.github/workflows/pytest.yml`: it runs +public truth, alpha readiness, full pytest across Python 3.11, 3.12, and 3.13, +plus package dry-run build, `twine check`, wheel install, and isolated +`pip check`. Branch or PR CI should be treated as required merge evidence. + +## Remaining Risks + +- Beta readiness is not an automatic claim. A human maintainer must approve any + beta, RC, 1.0, PyPI upload, public tag, or production-readiness statement. +- A P0/P1 security finding blocks release even when local tests pass. +- Downstream Ravenclaw/Tecrax smoke failures are host integration risks and + should be fixed in host adapters or contract boundaries, not by importing + host behavior into GovEngine core. +- SCLite main compatibility is useful during coordinated dependency waves but + should not block unrelated GovEngine patch releases unless the target release + updates the SCLite dependency line. + +## Decision + +GovEngine is ready for a maintainer-reviewed next-alpha stabilization PR after +local validation and branch/PR CI pass. It is not yet beta-ready without a +human gate confirming security issue state, downstream smoke policy, release +scope, and package publication intent. + +First eligible next roadmap task after this batch: maintainer review of the +next-alpha stabilization PR and explicit decision on whether to run the SCLite +transition outside this GovEngine batch. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index cbcafa1..0bdd7fc 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -32,6 +32,110 @@ then runs validators, tests, and `pip check`. A broad system interpreter is not a release-readiness environment because unrelated installed tools can make its dependency set inconsistent. +## Read-only operator verifier gates + +These scripts are local verification helpers. They never execute work, contact +targets, append audit records, rewrite ledgers, create runner requests, or +validate SCLite canonicalization. They summarize already-bounded GovEngine +records only. + +Runner receipt binding verification: + +```bash +python scripts/verify_runner_receipt_binding.py \ + --request /path/to/runner-request.json \ + --receipt /path/to/runner-receipt.json \ + --admission /path/to/runtime-admission.json \ + --ticket-id ticket-1 \ + --ticket-digest sha256: +``` + +Inputs are JSON mappings for `GovRunnerRequest`, `GovRunnerReceipt`, and +optionally `RuntimeAdmissionResult`. If no admission file is supplied, use +`--admission-id` and `--admission-digest`. The script rejects missing or +mismatched admission, ticket, request, receipt, status, and digest bindings +through `validate_runner_receipt_binding()`. It does not accept raw target, +prompt, command, credential, stdout/stderr payload, or raw evidence fields in +the binding. Output is bounded to status, reason code, blockers, request id, +receipt status, admission id, ticket id, and the fixed `execution: not +performed` non-claim. + +Development audit ledger verification: + +```bash +python scripts/verify_audit_ledger.py /path/to/audit-ledger.jsonl +``` + +Input is a JSONL file containing `AuditLedgerEntry` records created by the +development `JsonlAuditLedgerAdapter` or an equivalent bounded local fixture. +The script reads and verifies sequence, previous-entry digest, and entry digest +continuity. Malformed JSONL, tamper, deleted-line, restarted-chain, empty, or +invalid-limit cases fail closed. Output omits raw records and reports only +status, reason code, blockers, checked entry count, last entry id, and the +fixed `writes: none` non-claim. + +Stable exit codes for both helpers: + +- `0`: verified. +- `1`: verification failed or failed closed on invalid/tampered input. +- `2`: CLI/input handling error such as unreadable JSON or invalid read limit. + +## Next alpha release readiness gate + +The next alpha release gate is a decision checklist, not a publication action. +It must pass before any tag or package upload is considered: + +```bash +env PYTHONDONTWRITEBYTECODE=1 python3 scripts/validate_public_truth.py +env PYTHONDONTWRITEBYTECODE=1 python3 scripts/validate_alpha_readiness.py +env PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider +ruff check . +git diff --check +python scripts/validate_clean_package_install.py \ + --venv /tmp/govengine-clean-release \ + --dev \ + --sclite-source /path/to/SCLite \ + --no-editable +``` + +Release reviewers should also run a clean build and wheel install smoke where +build tooling is available: + +```bash +python -m build +python -m twine check dist/* +python -m venv /tmp/govengine-wheel-smoke +/tmp/govengine-wheel-smoke/bin/python -m pip install --upgrade pip +/tmp/govengine-wheel-smoke/bin/python -m pip install dist/*.whl +/tmp/govengine-wheel-smoke/bin/python -m pip check +``` + +No tag, PyPI upload, release artifact publication, or production-readiness +claim is part of this gate. A maintainer must explicitly confirm that there +are no open P0/P1 security findings in the tracked issue/security-review +source before approving a release. If any P0/P1 finding is open, the release +gate is blocked even when tests pass. + +## Downstream compatibility smoke design + +Downstream compatibility is release engineering evidence, not a runtime import +path. GovEngine production code must not import Ravenclaw, Tecrax, or other +host runtimes. + +- SCLite released-line smoke: local or CI gate. Install the currently supported + `sclite-core` package range and run public truth, alpha readiness, full + pytest, clean install, and package smoke. This is required for release + approval. +- SCLite main smoke: optional CI or manual pre-release gate during coordinated + SCLite/GovEngine waves. It may run against + `sclite-core @ git+https://github.com/rozmiarD/SCLite.git@main`, but failures + should block only coordinated dependency updates, not ordinary patch releases + unless the release target explicitly consumes main. +- Ravenclaw or other host contract smoke: external/manual gate owned by the + host runtime. It should validate package consumption and contract adapters + outside GovEngine production imports. Host failures become release risk + evidence, not justification to add host semantics to GovEngine core. + ## Current package-line gate Only this section states current validation expectations. The versioned diff --git a/scripts/verify_audit_ledger.py b/scripts/verify_audit_ledger.py new file mode 100644 index 0000000..4e8d25b --- /dev/null +++ b/scripts/verify_audit_ledger.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Mapping + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from govengine.admission import JsonlAuditLedgerAdapter # noqa: E402 +from govengine.api import GovApiError # noqa: E402 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Verify a development JSONL audit ledger without appending or rewriting it.', + ) + parser.add_argument('ledger', help='Path to a JSONL audit ledger.') + parser.add_argument( + '--limit', + type=int, + default=1000, + help='Maximum entries to read. Defaults to 1000.', + ) + parser.add_argument( + '--format', + choices=('text', 'json'), + default='text', + help='Output format. Defaults to compact text.', + ) + return parser + + +def _summary( + *, + status: str, + verified: bool, + reason_code: str, + blockers: tuple[str, ...] = (), + checked_entries: int = 0, + last_entry_id: str = '', +) -> dict[str, Any]: + return { + 'status': status, + 'verified': verified, + 'reason_code': reason_code, + 'blockers': list(blockers), + 'checked_entries': checked_entries, + 'last_entry_id': last_entry_id, + 'writes': 'none', + } + + +def _render_text(summary: Mapping[str, Any]) -> str: + lines = [ + f"audit_ledger: {summary['status']}", + f"verified: {str(summary['verified']).lower()}", + f"reason_code: {summary['reason_code']}", + f"checked_entries: {summary['checked_entries']}", + f"last_entry_id: {summary['last_entry_id']}", + 'blockers:', + ] + blockers = list(summary.get('blockers') or ()) + lines.extend(f'- {item}' for item in blockers) if blockers else lines.append('- none') + lines.append(f"writes: {summary['writes']}") + return '\n'.join(lines) + '\n' + + +def _render(summary: Mapping[str, Any], *, output_format: str) -> str: + if output_format == 'json': + return json.dumps(summary, sort_keys=True, separators=(',', ':')) + '\n' + return _render_text(summary) + + +def verify_audit_ledger_file(path: Path, *, limit: int = 1000, output_format: str = 'text') -> tuple[int, str]: + if limit < 1: + summary = _summary( + status='failed', + verified=False, + reason_code='invalid_audit_ledger_read_limit', + blockers=('invalid_audit_ledger_read_limit',), + ) + return 2, _render(summary, output_format=output_format) + ledger = JsonlAuditLedgerAdapter(path) + try: + entries = ledger.read(limit=limit) + result = ledger.verify(entries) + except GovApiError as exc: + summary = _summary(status='failed', verified=False, reason_code=exc.reason_code, blockers=(exc.reason_code,)) + return 1, _render(summary, output_format=output_format) + summary = _summary( + status=result.status, + verified=result.verified, + reason_code=result.reason_code, + blockers=result.blockers, + checked_entries=result.checked_entries, + last_entry_id=result.last_entry_id, + ) + return (0 if result.verified else 1), _render(summary, output_format=output_format) + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + code, output = verify_audit_ledger_file(Path(args.ledger), limit=args.limit, output_format=args.format) + print(output, end='') + return code + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/verify_runner_receipt_binding.py b/scripts/verify_runner_receipt_binding.py new file mode 100644 index 0000000..4b4e812 --- /dev/null +++ b/scripts/verify_runner_receipt_binding.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Mapping + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from govengine.admission import RuntimeAdmissionResult # noqa: E402 +from govengine.api import GovApiError, require_mapping # noqa: E402 +from govengine.execution.runner_protocol import ( # noqa: E402 + GovRunnerRequest, + normalize_runner_steps, +) +from govengine.execution.supervision import validate_runner_receipt_binding # noqa: E402 + + +class JsonInputError(Exception): + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Verify a GovEngine runner receipt binding without executing work.', + ) + parser.add_argument('--request', required=True, help='Path to a JSON GovRunnerRequest record.') + parser.add_argument('--receipt', required=True, help='Path to a JSON GovRunnerReceipt record.') + parser.add_argument( + '--admission', + help='Optional path to a JSON RuntimeAdmissionResult record used for id/digest comparison.', + ) + parser.add_argument('--admission-id', default='', help='Expected admission id when no admission file is supplied.') + parser.add_argument( + '--admission-digest', + default='', + help='Expected admission digest when no admission file is supplied.', + ) + parser.add_argument('--ticket-id', default='', help='Expected execution ticket id.') + parser.add_argument('--ticket-digest', default='', help='Expected execution ticket digest.') + parser.add_argument( + '--format', + choices=('text', 'json'), + default='text', + help='Output format. Defaults to compact text.', + ) + return parser + + +def _read_json(path: Path, *, reason_prefix: str) -> Mapping[str, Any]: + try: + payload = json.loads(path.read_text(encoding='utf-8')) + except OSError as exc: + raise JsonInputError(f'{reason_prefix}_read_failed') from exc + except json.JSONDecodeError as exc: + raise JsonInputError(f'{reason_prefix}_json_invalid') from exc + if not isinstance(payload, Mapping): + raise JsonInputError(f'{reason_prefix}_json_not_mapping') + return payload + + +def _request_from_mapping(value: Mapping[str, Any]) -> GovRunnerRequest: + raw = require_mapping(value, reason_code='invalid_runner_request') + return GovRunnerRequest( + request_id=str(raw.get('request_id') or '').strip(), + source=str(raw.get('source') or '').strip(), + steps=normalize_runner_steps(tuple(raw.get('steps') or ())), + approved_execution_spec=raw.get('approved_execution_spec') + if isinstance(raw.get('approved_execution_spec'), Mapping) + else {}, + execution_ticket_gate=raw.get('execution_ticket_gate') + if isinstance(raw.get('execution_ticket_gate'), Mapping) + else {}, + dry_run=bool(raw.get('dry_run', True)), + ) + + +def _summary(receipt: Mapping[str, Any], *, reason_code: str = 'verified', verified: bool = True) -> dict[str, Any]: + binding = receipt.get('binding') if isinstance(receipt.get('binding'), Mapping) else {} + blockers: list[str] = [] if verified else [reason_code] + return { + 'status': 'verified' if verified else 'failed', + 'verified': verified, + 'reason_code': reason_code, + 'blockers': blockers, + 'request_id': str(receipt.get('request_id') or ''), + 'receipt_status': str(receipt.get('status') or ''), + 'admission_id': str(binding.get('admission_id') or ''), + 'ticket_id': str(binding.get('ticket_id') or ''), + 'execution': 'not performed', + } + + +def _render_text(summary: Mapping[str, Any]) -> str: + lines = [ + f"receipt_binding: {summary['status']}", + f"verified: {str(summary['verified']).lower()}", + f"reason_code: {summary['reason_code']}", + f"request_id: {summary.get('request_id', '')}", + f"receipt_status: {summary.get('receipt_status', '')}", + f"admission_id: {summary.get('admission_id', '')}", + f"ticket_id: {summary.get('ticket_id', '')}", + 'blockers:', + ] + blockers = list(summary.get('blockers') or ()) + lines.extend(f'- {item}' for item in blockers) if blockers else lines.append('- none') + lines.append(f"execution: {summary['execution']}") + return '\n'.join(lines) + '\n' + + +def _render(summary: Mapping[str, Any], *, output_format: str) -> str: + if output_format == 'json': + return json.dumps(summary, sort_keys=True, separators=(',', ':')) + '\n' + return _render_text(summary) + + +def verify_runner_receipt_binding_file( + *, + request_path: Path, + receipt_path: Path, + admission_path: Path | None = None, + admission_id: str = '', + admission_digest: str = '', + ticket_id: str = '', + ticket_digest: str = '', + output_format: str = 'text', +) -> tuple[int, str]: + request_payload = _read_json(request_path, reason_prefix='runner_request') + receipt_payload = _read_json(receipt_path, reason_prefix='runner_receipt') + + try: + admission = None + if admission_path is not None: + admission = RuntimeAdmissionResult.from_mapping( + _read_json(admission_path, reason_prefix='runtime_admission'), + ) + request = _request_from_mapping(request_payload) + validate_runner_receipt_binding( + request, + receipt_payload, + admission=admission.as_dict() if admission is not None else None, + admission_id=admission_id, + admission_digest=admission_digest, + ticket_id=ticket_id, + ticket_digest=ticket_digest, + ) + except GovApiError as exc: + return 1, _render(_summary(receipt_payload, reason_code=exc.reason_code, verified=False), output_format=output_format) + return 0, _render(_summary(receipt_payload), output_format=output_format) + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + code, output = verify_runner_receipt_binding_file( + request_path=Path(args.request), + receipt_path=Path(args.receipt), + admission_path=Path(args.admission) if args.admission else None, + admission_id=args.admission_id, + admission_digest=args.admission_digest, + ticket_id=args.ticket_id, + ticket_digest=args.ticket_digest, + output_format=args.format, + ) + except JsonInputError as exc: + summary = { + 'status': 'failed', + 'verified': False, + 'reason_code': exc.reason_code, + 'blockers': [exc.reason_code], + 'execution': 'not performed', + } + print(_render(summary, output_format=args.format), end='') + return 2 + print(output, end='') + return code + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_operator_verifier_scripts.py b/tests/test_operator_verifier_scripts.py new file mode 100644 index 0000000..f9db58e --- /dev/null +++ b/tests/test_operator_verifier_scripts.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from govengine import ( + JsonlAuditLedgerAdapter, + RuntimeAdmissionResult, + govengine_record_digest, + validate_audit_record, +) +from govengine.execution.runner_protocol import ( + dry_run_runner_receipt, + runner_receipt_with_binding, + runner_request_digest, + runner_request_from_approved_spec, +) + + +ROOT = Path(__file__).resolve().parents[1] +RECEIPT_SCRIPT = ROOT / 'scripts' / 'verify_runner_receipt_binding.py' +LEDGER_SCRIPT = ROOT / 'scripts' / 'verify_audit_ledger.py' +ADMISSION_DIGEST = 'sha256:' + 'a' * 64 +TICKET_DIGEST = 'sha256:' + 'b' * 64 + + +def _approved_spec() -> dict: + return { + 'spec_version': '2026-03-18.approved.v1', + 'action_type': 'single_probe', + 'capability': 'http_probe', + 'resolved_tool': 'curl', + 'execution_mode': 'normalized', + 'approval': {'decision': 'approve', 'reason': 'ok'}, + 'execution_truth': { + 'artifact_type': 'approved_execution_spec', + 'execution_plan': [{'tool': 'curl', 'args': ['https://example.com/']}], + }, + } + + +def _write_json(path: Path, value: dict) -> None: + path.write_text(json.dumps(value, sort_keys=True, separators=(',', ':')), encoding='utf-8') + + +def _runtime_admission() -> RuntimeAdmissionResult: + return RuntimeAdmissionResult.from_mapping({ + 'admission_id': 'admission-1', + 'subject_ref': 'sha256:prepared-contract', + 'status': 'allowed', + 'allowed': True, + 'reason_code': 'all_required_gates_passed', + 'prepared_execution_contract': {'status': 'prepared', 'digest': 'sha256:contract'}, + 'policy_decision': {'decision': 'allow', 'policy_id': 'policy-1'}, + 'execution_ticket': {'status': 'passed', 'ticket_id': 'ticket-1', 'digest': TICKET_DIGEST}, + 'trust_decision': {'status': 'passed', 'trust_status': 'trusted'}, + 'sclite_guarded_strict': {'status': 'passed', 'required': True}, + 'replay_freshness': {'status': 'allowed', 'replay_status': 'fresh'}, + 'runner_profile': {'profile': 'dry_run', 'mode': 'dry_run'}, + 'receipt_obligation': {'required': True, 'binds': ['admission', 'ticket']}, + }) + + +def test_runner_receipt_binding_script_verifies_bounded_refs(tmp_path: Path) -> None: + request = runner_request_from_approved_spec(_approved_spec(), request_id='run-bound', dry_run=True) + admission = _runtime_admission() + admission_digest = govengine_record_digest(admission.as_dict(), record_type='govengine.admission.RuntimeAdmissionResult') + receipt = runner_receipt_with_binding( + dry_run_runner_receipt(request), + admission_id=admission.admission_id, + admission_digest=admission_digest, + ticket_id='ticket-1', + ticket_digest=TICKET_DIGEST, + request_digest=runner_request_digest(request), + receipt_id='receipt-1', + runner_profile='dry-run', + ) + request_path = tmp_path / 'request.json' + receipt_path = tmp_path / 'receipt.json' + admission_path = tmp_path / 'admission.json' + _write_json(request_path, request.as_dict()) + _write_json(receipt_path, receipt.as_dict()) + _write_json(admission_path, admission.as_dict()) + + proc = subprocess.run( + [ + sys.executable, + str(RECEIPT_SCRIPT), + '--request', + str(request_path), + '--receipt', + str(receipt_path), + '--admission', + str(admission_path), + '--ticket-id', + 'ticket-1', + '--ticket-digest', + TICKET_DIGEST, + '--format', + 'json', + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 0 + assert data['status'] == 'verified' + assert data['verified'] is True + assert data['request_id'] == 'run-bound' + assert data['admission_id'] == 'admission-1' + assert data['ticket_id'] == 'ticket-1' + assert data['execution'] == 'not performed' + assert 'step_results' not in data + + +def test_runner_receipt_binding_script_blocks_tampered_receipt(tmp_path: Path) -> None: + request = runner_request_from_approved_spec(_approved_spec(), request_id='run-bound', dry_run=True) + receipt = runner_receipt_with_binding( + dry_run_runner_receipt(request), + admission_id='admission-1', + admission_digest=ADMISSION_DIGEST, + ticket_id='ticket-1', + ticket_digest=TICKET_DIGEST, + request_digest=runner_request_digest(request), + receipt_id='receipt-1', + runner_profile='dry-run', + ).as_dict() + receipt['binding']['request_digest'] = 'sha256:' + 'c' * 64 + request_path = tmp_path / 'request.json' + receipt_path = tmp_path / 'receipt.json' + _write_json(request_path, request.as_dict()) + _write_json(receipt_path, receipt) + + proc = subprocess.run( + [ + sys.executable, + str(RECEIPT_SCRIPT), + '--request', + str(request_path), + '--receipt', + str(receipt_path), + '--admission-id', + 'admission-1', + '--admission-digest', + ADMISSION_DIGEST, + '--ticket-id', + 'ticket-1', + '--ticket-digest', + TICKET_DIGEST, + '--format', + 'json', + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 1 + assert data['status'] == 'failed' + assert data['verified'] is False + assert data['reason_code'] == 'runner_receipt_binding_request_digest_mismatch' + assert data['execution'] == 'not performed' + + +def test_audit_ledger_script_verifies_valid_jsonl_without_raw_records(tmp_path: Path) -> None: + path = tmp_path / 'audit-ledger.jsonl' + ledger = JsonlAuditLedgerAdapter(path) + record = validate_audit_record({ + 'record_id': 'audit-1', + 'record_type': 'admission_decision', + 'subject_ref': 'sha256:runtime-admission', + 'decision_ref': 'runtime-admission-1', + }) + ledger.append(record, record_digest='sha256:audit-1') + + proc = subprocess.run( + [sys.executable, str(LEDGER_SCRIPT), str(path), '--format', 'json'], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 0 + assert data['status'] == 'verified' + assert data['verified'] is True + assert data['checked_entries'] == 1 + assert data['last_entry_id'] == 'audit-ledger-entry-1' + assert data['writes'] == 'none' + assert 'record' not in data + + +def test_audit_ledger_script_blocks_tampered_jsonl(tmp_path: Path) -> None: + path = tmp_path / 'audit-ledger.jsonl' + ledger = JsonlAuditLedgerAdapter(path) + record = validate_audit_record({ + 'record_id': 'audit-1', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:policy-subject', + 'decision_ref': 'policy-1', + }) + ledger.append(record, record_digest='sha256:audit-1') + [line] = path.read_text(encoding='utf-8').splitlines() + tampered = json.loads(line) + tampered['record']['decision_ref'] = 'policy-tampered' + path.write_text(json.dumps(tampered, sort_keys=True, separators=(',', ':')) + '\n', encoding='utf-8') + + proc = subprocess.run( + [sys.executable, str(LEDGER_SCRIPT), str(path), '--format', 'json'], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 1 + assert data['status'] == 'failed' + assert data['verified'] is False + assert data['reason_code'] == 'audit_ledger_entry_digest_mismatch' + assert data['blockers'] == ['audit_ledger_entry_digest_mismatch'] + + +def test_audit_ledger_script_fails_closed_on_malformed_jsonl(tmp_path: Path) -> None: + path = tmp_path / 'audit-ledger.jsonl' + path.write_text('{not-json}\n', encoding='utf-8') + + proc = subprocess.run( + [sys.executable, str(LEDGER_SCRIPT), str(path), '--format', 'json'], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 1 + assert data['status'] == 'failed' + assert data['reason_code'] == 'invalid_audit_ledger_jsonl:1' + + +def test_audit_ledger_script_reports_deleted_line_as_blocked(tmp_path: Path) -> None: + path = tmp_path / 'audit-ledger.jsonl' + ledger = JsonlAuditLedgerAdapter(path) + first = validate_audit_record({ + 'record_id': 'audit-1', + 'record_type': 'admission_decision', + 'subject_ref': 'sha256:runtime-admission', + 'decision_ref': 'runtime-admission-1', + }) + second = validate_audit_record({ + 'record_id': 'audit-2', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:policy-subject', + 'decision_ref': 'policy-1', + }) + ledger.append(first, record_digest='sha256:audit-1') + ledger.append(second, record_digest='sha256:audit-2') + _, remaining = path.read_text(encoding='utf-8').splitlines() + path.write_text(remaining + '\n', encoding='utf-8') + + proc = subprocess.run( + [sys.executable, str(LEDGER_SCRIPT), str(path), '--format', 'json'], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + data = json.loads(proc.stdout) + assert proc.returncode == 1 + assert data['reason_code'] == 'audit_ledger_sequence_mismatch' + assert data['writes'] == 'none' From e60f5b4273ca558dec5cb5971a33d85b416e3ccc Mon Sep 17 00:00:00 2001 From: ExatronOmega Date: Sun, 14 Jun 2026 10:06:13 +0200 Subject: [PATCH 2/2] Complete broader roadmap hardening clusters Co-authored-by: Krzysztof Probola <32790662+rozmiarD@users.noreply.github.com> --- docs/API_STABILITY_MATRIX.md | 7 +- docs/ROADMAP_COMPLETION_AUDIT.md | 54 ++++--- docs/SECURITY_INTEGRATION.md | 82 +++++++++++ govengine/__init__.py | 14 ++ govengine/admission.py | 131 +++++++++++++++++ govengine/execution/approved_spec.py | 14 +- govengine/execution/runner_protocol.py | 60 ++++++++ govengine/replay.py | 3 + govengine/review.py | 25 ++++ scripts/inspect_runtime_admission.py | 18 ++- scripts/validate_public_truth.py | 6 + tests/test_admission_contracts.py | 189 +++++++++++++++++++++++++ tests/test_boundary_hardening.py | 50 +++++++ tests/test_execution_helpers.py | 26 ++++ tests/test_guard_replay.py | 31 +++- tests/test_public_truth_consistency.py | 1 + tests/test_review_contracts.py | 39 +++++ tests/test_runner_protocol.py | 38 +++++ 18 files changed, 762 insertions(+), 26 deletions(-) create mode 100644 docs/SECURITY_INTEGRATION.md diff --git a/docs/API_STABILITY_MATRIX.md b/docs/API_STABILITY_MATRIX.md index 212e925..e4c201a 100644 --- a/docs/API_STABILITY_MATRIX.md +++ b/docs/API_STABILITY_MATRIX.md @@ -14,7 +14,7 @@ GovEngine is still alpha. This matrix is not a production readiness claim, and i | Stability | Source | Exports | Boundary note | | --- | --- | --- | --- | -| alpha | govengine.admission | `AuditLedgerAppendResult`, `AuditLedgerEntry`, `AuditLedgerPort`, `AuditLedgerVerificationResult`, `GovAdmissionDecision`, `GovApprovalRequest`, `GovAuditRecord`, `GovPolicyDecision`, `JsonlAuditLedgerAdapter`, `RuntimeAdmissionResult`, `admission_decision_from_host_gate`, `audit_ledger_entry_digest`, `compose_runtime_admission_result`, `normalize_admission_artifact_refs`, `validate_admission_decision`, `validate_approval_request`, `validate_audit_record`, `validate_audit_ledger_append_result`, `validate_audit_ledger_entry`, `validate_audit_ledger_verification_result`, `validate_policy_decision`, `validate_runtime_admission_result` | Neutral admission/policy/runtime-admission records, bounded audit-ledger port contracts, a JSONL hash-chain development adapter, bounded reference normalization, and gate-summary composition only; host owns policy meaning, approval workflow, live backend behavior, production audit persistence/concurrency, raw evidence storage, SCLite canonicalization, and production trust/key boundaries. | +| alpha | govengine.admission | `AuditLedgerAppendResult`, `AuditLedgerEntry`, `AuditLedgerPort`, `AuditLedgerVerificationResult`, `GovAdmissionDecision`, `GovApprovalRequest`, `GovAuditRecord`, `GovPolicyDecision`, `JsonlAuditLedgerAdapter`, `RuntimeAdmissionResult`, `admission_decision_from_host_gate`, `audit_ledger_entry_digest`, `audit_ledger_verification_public_summary`, `audit_record_public_summary`, `compose_runtime_admission_result`, `normalize_admission_artifact_refs`, `runtime_admission_public_summary`, `validate_admission_decision`, `validate_approval_request`, `validate_audit_record`, `validate_audit_ledger_append_result`, `validate_audit_ledger_entry`, `validate_audit_ledger_verification_result`, `validate_policy_decision`, `validate_runtime_admission_result`, `validate_runtime_admission_proof_inputs` | Neutral admission/policy/runtime-admission records, bounded audit-ledger port contracts, a JSONL hash-chain development adapter, bounded reference normalization, public-safe projection helpers, proof-input completeness checks, and gate-summary composition only; host owns policy meaning, approval workflow, live backend behavior, production audit persistence/concurrency, raw evidence storage, SCLite canonicalization, and production trust/key boundaries. | | alpha | govengine.api | `GovApiError`, `GovApiResult` | Lightweight result/error helpers. | | alpha | govengine.boundary | `BoundaryReport`, `DomainProfileConformance`, `DomainProfileContract`, `KernelBoundary`, `boundary_surface_index`, `domain_profile_conformance`, `kernel_boundary_contract`, `kernel_boundary_report`, `known_profile_contracts`, `ravenclaw_profile_contract`, `validate_domain_profile_contract`, `validate_domain_profile_conformance` | Kernel/profile/runtime/SCLite ownership contracts; Ravenclaw contract remains fixture/profile metadata. | | alpha | govengine.context | `GovEngineContext`, `GovEnginePaths`, `host_compat_context`, `ravenclaw_context` | Host path/context records; Ravenclaw context remains compatibility fixture metadata. | @@ -24,6 +24,7 @@ GovEngine is still alpha. This matrix is not a production readiness claim, and i | alpha | govengine.deconfliction | `ArtifactChangeOrder`, `ArtifactConflict`, `ConflictDetector` | Digest/state conflict helpers only. | | alpha | govengine.events | `EventEnvelope`, `GovEvent`, `validate_event_envelope`, `validate_gov_event` | Transport-neutral event metadata; no carrier payload authority. | | alpha | govengine.execution.gate | `DryRunRunner`, `ExecutionGate`, `ExecutionGateInput`, `RunnerProfile` | Dry-run/default-deny execution gate helpers; no live backend ownership. | +| alpha | govengine.execution.runner_protocol | `runner_receipt_public_summary` | Public-safe runner receipt summary over bounded binding refs and digest counts only; no raw stdout/stderr publication and no execution authority. | | alpha | govengine.execution.supervision | `GovRunnerLease`, `GovSupervisionDecision`, `GovSupervisionPlan`, `LocalSubprocessRunnerReadiness`, `evaluate_local_subprocess_runner_readiness`, `runner_lease_from_request`, `supervision_plan_from_runner_request`, `validate_runner_lease`, `validate_runner_receipt_binding`, `validate_runner_receipt_for_request`, `validate_supervised_runner_request`, `validate_supervision_decision`, `validate_supervision_plan` | Runner request, lease, supervision, readiness, and receipt boundary helpers; live subprocess execution remains not applicable until the missing host-owned safety prerequisites are closed. | | alpha | govengine.execution_backend | `CommandResult`, `GovExecutionBackend` | Host-neutral backend protocol/result helpers. | | alpha | govengine.lifecycle | `ArtifactLifecycleController`, `TransitionGate`, `TransitionPolicy` | Lightweight lifecycle gate/controller helpers; SCLite remains lifecycle authority. | @@ -32,7 +33,7 @@ GovEngine is still alpha. This matrix is not a production readiness claim, and i | alpha | govengine.planning | `GovPlanIntentContract`, `GovTaskContract`, `PlannerPort`, `task_contract_from_host_task`, `validate_plan_intent_contract`, `validate_planner_port`, `validate_task_contract` | Planner-to-runtime contract shapes; no planner implementation ownership. | | alpha | govengine.profiles | `CapabilityDeclaration`, `DomainProfile`, `EvidenceRuleDeclaration`, `PlanningStageRegistry`, `PolicyHookDeclaration`, `ProfileConformanceReport`, `ResourceTypeRegistry`, `RunnerProfileDeclaration`, `TaskFamilyRegistry`, `profile_conformance_report`, `ravenclaw_security_profile`, `tecrax_infra_ops_profile`, `validate_domain_profile`, `validate_profile_conformance` | Contract-only domain profile SDK and fixtures; host owns domain semantics. | | alpha | govengine.replay | `GuardedBundleRuntimeDecision`, `GuardReplayDecision`, `GuardReplayRecord`, `InMemoryReplayClaimStore`, `ReplayClaimStore`, `evaluate_guard_replay`, `guard_replay_record_from_guard`, `record_guard_replay`, `record_guard_replay_file`, `verify_guard_and_record_replay` | Guarded SCLite root replay freshness over host-supplied store plus a claim-once port and deterministic in-memory development adapter; no HMAC/key ownership, database ownership, or production concurrency guarantee. | -| alpha | govengine.review | `GovEvidenceClaim`, `GovEvidenceQualification`, `GovEvidenceRequirement`, `GovReviewResult`, `qualify_evidence_claim`, `validate_evidence_claim`, `validate_evidence_qualification`, `validate_evidence_requirement`, `validate_evidence_review_chain`, `validate_review_result` | Receipt-bounded evidence review records; SCLite review bundle authority is not duplicated. | +| alpha | govengine.review | `GovEvidenceClaim`, `GovEvidenceQualification`, `GovEvidenceRequirement`, `GovReviewResult`, `evidence_claim_public_summary`, `qualify_evidence_claim`, `review_result_public_summary`, `validate_evidence_claim`, `validate_evidence_qualification`, `validate_evidence_requirement`, `validate_evidence_review_chain`, `validate_review_result` | Receipt-bounded evidence review records and public-safe summaries; SCLite review bundle authority is not duplicated and raw evidence stays host-owned. | | alpha | govengine.roles | `GovRoleAdapters` | Adapter binding record; no Ravenclaw/OpenClaw dependency. | | alpha | govengine.runtime_shell | `GovControlAction`, `GovQueueLane`, `GovQueueSnapshot`, `GovRuntimeSnapshot`, `GovSchedulerTick`, `control_action_from_host_action`, `queue_snapshot_from_lanes`, `validate_control_action`, `validate_queue_snapshot`, `validate_runtime_snapshot`, `validate_scheduler_tick` | Host-provided runtime shell projection; no scheduler/storage/live-execution authority. | | alpha | govengine.sclite_contracts lazy exports | `GovSCLiteLifecycleVerifier`, `review_bundle_state`, `review_bundle_transition_decision`, `review_sclite_bundle`, `verify_lifecycle_manifest` | Lazy SCLite bridge exports; SCLite owns lifecycle and review verification. | @@ -47,7 +48,7 @@ GovEngine is still alpha. This matrix is not a production readiness claim, and i Current summary: - stable exports: 0 -- alpha exports: 180 +- alpha exports: 187 - fixture exports: 4 - deprecated exports: 0 - internal-exposed exports: 0 diff --git a/docs/ROADMAP_COMPLETION_AUDIT.md b/docs/ROADMAP_COMPLETION_AUDIT.md index 5c343eb..8380c48 100644 --- a/docs/ROADMAP_COMPLETION_AUDIT.md +++ b/docs/ROADMAP_COMPLETION_AUDIT.md @@ -8,22 +8,20 @@ execution, or moving host-specific behavior into GovEngine. ## DAG Reduction -The open issue graph is much larger than the remaining code delta. Most GOV -nodes from `GOV-001` through `GOV-059` are already represented in the current -tree by guard replay hardening, receipt binding, audit ledger contracts, public -projection helpers, inspect-only admission, bounded output guards, public truth -validators, and documentation hygiene tests. - -The remaining eligible tail collapsed into one implementation batch: - -- `GOV-060` and `GOV-061`: design and implement a read-only runner receipt - binding verifier. -- `GOV-062` and `GOV-063`: design and implement a read-only development audit - ledger verifier. -- `GOV-064`: define the next alpha release readiness gate. -- `GOV-065`: define downstream compatibility smoke gates outside production - imports. -- `GOV-066`: record this completion audit and beta-readiness decision. +The open issue graph is larger than a linear implementation plan and several +issue chains duplicate the same runtime boundary. The first pass over-reduced +the graph to the tail. The corrected repository batch collapses the eligible +GOV nodes into these implementation clusters: + +- guarded replay and SCLite delegation/import boundaries; +- approved execution fail-closed normalization; +- runtime admission schema/versioning, proof-input checks, and public + summaries; +- runner receipt, review, and audit public projections; +- inspect-only admission bounds and stable failure exits; +- read-only runner receipt and audit-ledger verifier scripts; +- API stability matrix, public truth, release gates, downstream smoke guidance, + and security integration documentation. `GOV-S001` is intentionally not executed in this repository batch. It is a cross-repo SCLite transition task whose acceptance criteria require Signposter @@ -34,6 +32,24 @@ eligible under the current operator boundary. Implemented code and tests: +- `approved_execution_steps()` now rejects malformed execution steps, + missing tools, and missing/non-list args instead of silently dropping them. +- runtime admission, audit records, audit ledger entries, guard replay records, + runner requests, and runner receipts carry explicit schema-version handling + with legacy compatibility where needed. +- `validate_runtime_admission_proof_inputs()` checks that an allowed admission + carries guarded-strict, replay-freshness, trust, ticket, runner-profile, + receipt-obligation, and bounded artifact-reference inputs without claiming to + verify SCLite, signatures, policy meaning, or execution authority. +- `runtime_admission_public_summary()`, + `runner_receipt_public_summary()`, `audit_record_public_summary()`, + `audit_ledger_verification_public_summary()`, + `evidence_claim_public_summary()`, and `review_result_public_summary()` + expose bounded public projections without raw evidence/output metadata. +- `scripts/inspect_runtime_admission.py` remains read-only and now rejects + oversized inputs before parsing. +- SCLite integration tests assert the production import allowlist and guarded + replay delegation into `sclite.secure.verify_secure_bundle()`. - `scripts/verify_runner_receipt_binding.py` verifies existing request, receipt, admission, and ticket references through `validate_runner_receipt_binding()`. It never generates runner requests, @@ -47,6 +63,12 @@ Implemented code and tests: Documentation and release gates: +- `docs/SECURITY_INTEGRATION.md` records the required security integration + order, production non-claims, and development-only helpers. +- `docs/API_STABILITY_MATRIX.md` classifies the new public projection and + proof-input helpers. +- `scripts/validate_public_truth.py` now requires the security integration + document as part of the MVP public docs. - `docs/VALIDATION.md` now records exact CLI shapes, JSON inputs, bounded outputs, forbidden behavior, stable exit codes, next-alpha readiness checks, no-open-P0/P1 security finding requirement, and downstream smoke ownership. diff --git a/docs/SECURITY_INTEGRATION.md b/docs/SECURITY_INTEGRATION.md new file mode 100644 index 0000000..2b73972 --- /dev/null +++ b/docs/SECURITY_INTEGRATION.md @@ -0,0 +1,82 @@ +# Security Integration Boundary + +GovEngine composes bounded runtime security records. It does not replace +SCLite lifecycle authority, host policy authority, production identity, or live +execution infrastructure. + +## Required Order + +Runtime-consuming hosts should evaluate the security path in this order: + +1. SCLite secure verification for strict lifecycle and Kernel Guard status. +2. GovEngine replay freshness for the guarded root or guarded payload. +3. Host trust decision for signer, signature, and trust-anchor status. +4. Execution ticket gate for ticket status and scope binding. +5. Runtime admission composition through `RuntimeAdmissionResult`. +6. Runner request creation from an approved execution spec. +7. Runner receipt binding for admission, ticket, request, and receipt refs. +8. Evidence and review records bounded by receipt status. +9. Audit record and audit ledger verification over bounded records. + +This order is a safety precondition checklist, not a scheduler. +`RuntimeAdmissionResult` is not proof and not execution authority. It is a +bounded decision record showing which earlier gates were present, allowed, or +blocked. `validate_runtime_admission_proof_inputs()` checks that an allowed +admission carries the expected proof input summaries and references; it does +not verify SCLite artifacts, validate signatures, choose policy meaning, or +authorize execution. + +## Production Non-Claims + +GovEngine does not provide: + +- live execution, live subprocess runners, target access, or scanner control; +- PKI, KMS, CA, HSM, private key storage, credential storage, or trust-anchor + administration; +- production replay persistence, locking, retention, or multi-process + concurrency; +- production audit database, retention, deletion, or concurrency semantics; +- raw evidence storage, raw stdout/stderr publication, raw prompt storage, or + redaction pipelines; +- SCLite schema ownership, canonicalization, lifecycle verification, + guarded-strict verification, execution-ticket semantics, or review-bundle + verdict authority. + +Hosts own domain policy, operator approval, production storage, live runner +adapters, network access, secrets, release authorization, public evidence +publication, and incident response. + +## Development Helpers + +The following helpers are safe for tests, examples, and local smoke evidence, +but are not production security backends: + +- `DemoDigestSigner` and `DemoDigestVerifier` are deterministic demo helpers, + not cryptographic identity proof. +- `InMemoryReplayClaimStore` is a development claim-once adapter, not durable + atomic storage. +- `record_guard_replay_file()` is a local JSON helper, not a production replay + database. +- `JsonlAuditLedgerAdapter` is a development JSONL hash-chain adapter, not a + production audit ledger. +- `runner_receipt_public_summary()`, `runtime_admission_public_summary()`, + `audit_record_public_summary()`, and review public-summary helpers are + public-safe projections, not raw evidence publication. + +## Public Projection Rule + +Public records may carry ids, statuses, reason codes, counts, bounded refs, and +`sha256:` digests. They must not carry raw targets, credentials, prompts, +commands, carrier payloads, raw evidence, raw stdout, raw stderr, or private +storage paths. + +The public-safe chain is: + +```text +RuntimeAdmissionResult +-> GovRunnerReceipt binding +-> GovEvidenceClaim / GovReviewResult summary +-> GovAuditRecord / AuditLedgerVerificationResult summary +``` + +Each step is review evidence only. It is not permission to execute live work. diff --git a/govengine/__init__.py b/govengine/__init__.py index 5222e70..23dbe23 100644 --- a/govengine/__init__.py +++ b/govengine/__init__.py @@ -19,9 +19,12 @@ JsonlAuditLedgerAdapter, RuntimeAdmissionResult, admission_decision_from_host_gate, + audit_ledger_verification_public_summary, audit_ledger_entry_digest, + audit_record_public_summary, compose_runtime_admission_result, normalize_admission_artifact_refs, + runtime_admission_public_summary, validate_admission_decision, validate_approval_request, validate_audit_record, @@ -30,6 +33,7 @@ validate_audit_ledger_verification_result, validate_policy_decision, validate_runtime_admission_result, + validate_runtime_admission_proof_inputs, ) from .api import GovApiError, GovApiResult from .boundary import ( @@ -69,6 +73,7 @@ from .deconfliction import ArtifactChangeOrder, ArtifactConflict, ConflictDetector from .events import EventEnvelope, GovEvent, validate_event_envelope, validate_gov_event from .execution.gate import DryRunRunner, ExecutionGate, ExecutionGateInput, RunnerProfile +from .execution.runner_protocol import runner_receipt_public_summary from .execution.supervision import ( GovRunnerLease, GovSupervisionDecision, @@ -124,7 +129,9 @@ GovEvidenceQualification, GovEvidenceRequirement, GovReviewResult, + evidence_claim_public_summary, qualify_evidence_claim, + review_result_public_summary, validate_evidence_review_chain, validate_evidence_claim, validate_evidence_qualification, @@ -290,6 +297,7 @@ 'review_bundle_state', 'review_bundle_transition_decision', 'review_sclite_bundle', + 'review_result_public_summary', 'TransitionDecision', 'TransitionGate', 'TransitionPolicy', @@ -301,6 +309,8 @@ 'admission_policy_surface', 'apply_control_decision', 'audit_ledger_entry_digest', + 'audit_ledger_verification_public_summary', + 'audit_record_public_summary', 'compose_runtime_admission_result', 'normalize_admission_artifact_refs', 'control_action_from_host_action', @@ -313,6 +323,7 @@ 'domain_profile_sdk_surface', 'evaluate_local_subprocess_runner_readiness', 'evaluate_guard_replay', + 'evidence_claim_public_summary', 'governance_contract_vocabulary', 'guard_replay_record_from_guard', 'govengine_record_digest', @@ -333,6 +344,8 @@ 'verify_guard_and_record_replay', 'verify_signed_govengine_record', 'runner_lease_from_request', + 'runner_receipt_public_summary', + 'runtime_admission_public_summary', 'runtime_contract_proofs_surface', 'signed_artifact_from_record', 'supervision_plan_from_runner_request', @@ -372,6 +385,7 @@ 'validate_runtime_contract_proof', 'validate_scheduler_tick', 'validate_runtime_admission_result', + 'validate_runtime_admission_proof_inputs', 'validate_state_transition', 'validate_supervised_runner_request', 'validate_supervision_decision', diff --git a/govengine/admission.py b/govengine/admission.py index 6125770..644ce35 100644 --- a/govengine/admission.py +++ b/govengine/admission.py @@ -16,6 +16,9 @@ AUDIT_LEDGER_APPEND_STATUSES = ('appended', 'rejected') AUDIT_LEDGER_VERIFY_STATUSES = ('verified', 'failed', 'empty') RUNTIME_ADMISSION_STATUSES = ('allowed', 'blocked', 'dry_run_only', 'needs_review', 'record_only') +RUNTIME_ADMISSION_SCHEMA_VERSION = 'v0.1' +AUDIT_RECORD_SCHEMA_VERSION = 'v0.1' +AUDIT_LEDGER_ENTRY_SCHEMA_VERSION = 'v0.1' PREPARED_EXECUTION_CONTRACT_STATUSES = ('prepared', 'passed', 'ok', 'allowed') RECEIPT_OBLIGATION_STATUSES = ('required', 'passed', 'ok') POLICY_RUNTIME_BLOCKERS = { @@ -314,6 +317,7 @@ class GovAuditRecord: record_id: str record_type: str subject_ref: str + schema_version: str = AUDIT_RECORD_SCHEMA_VERSION subject_kind: str = 'task' decision_ref: str = '' reason_code: str = 'recorded' @@ -334,6 +338,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> 'GovAuditRecord': record_id=record_id, record_type=_enum(raw.get('record_type'), AUDIT_RECORD_TYPES, 'admission_decision'), subject_ref=subject_ref, + schema_version=str(raw.get('schema_version') or AUDIT_RECORD_SCHEMA_VERSION).strip(), subject_kind=_enum(raw.get('subject_kind'), SUBJECT_KINDS, 'task'), decision_ref=str(raw.get('decision_ref') or '').strip(), reason_code=str(raw.get('reason_code') or 'recorded').strip() or 'recorded', @@ -364,6 +369,7 @@ class AuditLedgerEntry: sequence: int record: GovAuditRecord | Mapping[str, Any] record_digest: str + schema_version: str = AUDIT_LEDGER_ENTRY_SCHEMA_VERSION event_digest: str = '' previous_entry_digest: str = '' entry_digest: str = '' @@ -376,6 +382,7 @@ def __post_init__(self) -> None: object.__setattr__(self, 'sequence', int(self.sequence)) object.__setattr__(self, 'record', record) object.__setattr__(self, 'record_digest', str(self.record_digest or '').strip()) + object.__setattr__(self, 'schema_version', str(self.schema_version or '').strip()) object.__setattr__(self, 'event_digest', str(self.event_digest or '').strip()) object.__setattr__(self, 'previous_entry_digest', str(self.previous_entry_digest or '').strip()) object.__setattr__(self, 'entry_digest', str(self.entry_digest or '').strip()) @@ -394,6 +401,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> 'AuditLedgerEntry': sequence=int(raw.get('sequence') or 0), record=record_value, record_digest=str(raw.get('record_digest') or '').strip(), + schema_version=str(raw.get('schema_version') or '').strip(), event_digest=str(raw.get('event_digest') or '').strip(), previous_entry_digest=str(raw.get('previous_entry_digest') or '').strip(), entry_digest=str(raw.get('entry_digest') or '').strip(), @@ -407,6 +415,7 @@ def as_dict(self) -> dict[str, Any]: 'sequence': self.sequence, 'record': self.record.as_dict(), 'record_digest': self.record_digest, + 'schema_version': self.schema_version, 'event_digest': self.event_digest, 'previous_entry_digest': self.previous_entry_digest, 'entry_digest': self.entry_digest, @@ -656,6 +665,7 @@ class RuntimeAdmissionResult: admission_id: str subject_ref: str + schema_version: str = RUNTIME_ADMISSION_SCHEMA_VERSION status: str = 'blocked' allowed: bool = False reason_code: str = 'blocked' @@ -685,6 +695,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> 'RuntimeAdmissionResult': item = cls( admission_id=admission_id, subject_ref=subject_ref, + schema_version=str(raw.get('schema_version') or RUNTIME_ADMISSION_SCHEMA_VERSION).strip(), status=status, allowed=_bool_value(raw.get('allowed'), default=status == 'allowed'), reason_code=str(raw.get('reason_code') or status).strip() or status, @@ -708,6 +719,7 @@ def as_dict(self) -> dict[str, Any]: return { 'admission_id': self.admission_id, 'subject_ref': self.subject_ref, + 'schema_version': self.schema_version, 'status': self.status, 'allowed': self.allowed, 'reason_code': self.reason_code, @@ -769,6 +781,8 @@ def validate_audit_record(value: Mapping[str, Any] | GovAuditRecord) -> GovAudit raise GovApiError(f'unknown_audit_subject_kind:{item.subject_kind}') if item.record_type not in AUDIT_RECORD_TYPES: raise GovApiError(f'unknown_audit_record_type:{item.record_type}') + if item.schema_version != AUDIT_RECORD_SCHEMA_VERSION: + raise GovApiError(f'unknown_audit_record_schema_version:{item.schema_version or "missing"}') _reject_forbidden_metadata(item.metadata) return item @@ -779,6 +793,8 @@ def validate_audit_ledger_entry(value: Mapping[str, Any] | AuditLedgerEntry) -> raise GovApiError('missing_audit_ledger_entry_id') if item.sequence < 0: raise GovApiError('invalid_audit_ledger_sequence') + if item.schema_version and item.schema_version != AUDIT_LEDGER_ENTRY_SCHEMA_VERSION: + raise GovApiError(f'unknown_audit_ledger_entry_schema_version:{item.schema_version}') validate_audit_record(item.record) _require_digest_ref(item.record_digest, 'missing_audit_ledger_record_digest', 'invalid_audit_ledger_record_digest') _validate_optional_digest_ref(item.event_digest, 'invalid_audit_ledger_event_digest') @@ -843,6 +859,8 @@ def audit_ledger_entry_digest(value: Mapping[str, Any] | AuditLedgerEntry) -> st item = value if isinstance(value, AuditLedgerEntry) else AuditLedgerEntry.from_mapping(value) payload = item.as_dict() payload['entry_digest'] = '' + if not payload.get('schema_version'): + payload.pop('schema_version', None) from govengine.signing import govengine_record_digest return govengine_record_digest(payload, record_type='govengine.admission.AuditLedgerEntry') @@ -850,6 +868,8 @@ def audit_ledger_entry_digest(value: Mapping[str, Any] | AuditLedgerEntry) -> st def validate_runtime_admission_result(value: Mapping[str, Any] | RuntimeAdmissionResult) -> RuntimeAdmissionResult: item = value if isinstance(value, RuntimeAdmissionResult) else RuntimeAdmissionResult.from_mapping(value) + if item.schema_version != RUNTIME_ADMISSION_SCHEMA_VERSION: + raise GovApiError(f'unknown_runtime_admission_schema_version:{item.schema_version or "missing"}') if item.status not in RUNTIME_ADMISSION_STATUSES: raise GovApiError(f'unknown_runtime_admission_status:{item.status}') if item.allowed and item.status != 'allowed': @@ -876,6 +896,99 @@ def validate_runtime_admission_result(value: Mapping[str, Any] | RuntimeAdmissio return item +def runtime_admission_public_summary( + value: Mapping[str, Any] | RuntimeAdmissionResult, + *, + show_artifact_refs: bool = False, +) -> dict[str, Any]: + """Return a bounded public summary of a runtime admission result.""" + + item = validate_runtime_admission_result(value) + summary: dict[str, Any] = { + 'schema_version': item.schema_version, + 'admission_id': item.admission_id, + 'subject_ref': item.subject_ref, + 'status': item.status, + 'allowed': item.allowed, + 'reason_code': item.reason_code, + 'blocker_count': len(item.blockers), + 'required_next_action_count': len(item.required_next_actions), + 'receipt_obligation': _receipt_obligation_public_status(item.receipt_obligation), + } + if show_artifact_refs: + summary['artifact_refs'] = dict(item.artifact_refs) + return summary + + +def audit_record_public_summary(value: Mapping[str, Any] | GovAuditRecord) -> dict[str, Any]: + """Return public-safe audit record identifiers without raw metadata.""" + + item = validate_audit_record(value) + return { + 'schema_version': item.schema_version, + 'record_id': item.record_id, + 'record_type': item.record_type, + 'subject_ref': item.subject_ref, + 'decision_ref': item.decision_ref, + 'reason_code': item.reason_code, + 'event_ref_count': len(item.event_refs), + } + + +def audit_ledger_verification_public_summary( + value: Mapping[str, Any] | AuditLedgerVerificationResult, +) -> dict[str, Any]: + """Return a bounded ledger verification summary without raw records.""" + + item = validate_audit_ledger_verification_result(value) + return { + 'status': item.status, + 'verified': item.verified, + 'reason_code': item.reason_code, + 'blocker_count': len(item.blockers), + 'checked_entries': item.checked_entries, + 'last_entry_id': item.last_entry_id, + 'last_entry_digest': item.last_entry_digest, + } + + +def validate_runtime_admission_proof_inputs( + value: Mapping[str, Any] | RuntimeAdmissionResult, +) -> RuntimeAdmissionResult: + """Validate that an allowed admission carries the expected proof summaries. + + This checks presence and status of already-produced bounded records. It does + not verify SCLite artifacts, check signatures, evaluate policy meaning, or + authorize execution. + """ + + item = validate_runtime_admission_result(value) + if not item.allowed: + raise GovApiError('runtime_admission_proof_not_allowed') + if _guarded_runtime_status(item.sclite_guarded_strict) != 'passed': + raise GovApiError('runtime_admission_proof_guarded_strict_missing') + if _replay_runtime_status(item.replay_freshness, item.sclite_guarded_strict) != 'fresh': + raise GovApiError('runtime_admission_proof_replay_freshness_missing') + if _trust_signal_status(item.trust_decision) not in {'trusted', 'passed', 'ok'}: + raise GovApiError('runtime_admission_proof_trust_decision_missing') + if _ticket_signal_status(item.execution_ticket) not in {'approve', 'approved', 'approved_for_dry_run', 'passed', 'ok'}: + raise GovApiError('runtime_admission_proof_execution_ticket_missing') + if not _receipt_obligation_required(item.receipt_obligation): + raise GovApiError('runtime_admission_proof_receipt_obligation_missing') + if not _bool_value(item.runner_profile.get('allowed'), default=False): + raise GovApiError('runtime_admission_proof_runner_profile_missing') + if _bool_value(item.runner_profile.get('live_backend_enabled'), default=False): + raise GovApiError('runtime_admission_proof_live_backend_not_allowed') + if not _proof_ref(item.artifact_refs, 'sclite_guarded_strict', 'root_chain_digest'): + raise GovApiError('runtime_admission_proof_guard_digest_missing') + if not _proof_ref(item.artifact_refs, 'execution_ticket', 'ticket_id'): + raise GovApiError('runtime_admission_proof_ticket_ref_missing') + binds = {str(item) for item in item.receipt_obligation.get('binds', ()) if not isinstance(item, Mapping)} + if not {'admission', 'ticket'} <= binds: + raise GovApiError('runtime_admission_proof_receipt_binding_incomplete') + return item + + def compose_runtime_admission_result( *, admission_id: str, @@ -1320,6 +1433,24 @@ def _receipt_obligation_required(payload: Mapping[str, Any]) -> bool: return _status_in(_signal_status(payload, ('status', 'obligation_status')), RECEIPT_OBLIGATION_STATUSES) +def _receipt_obligation_public_status(payload: Mapping[str, Any]) -> str: + if not payload: + return 'missing' + if _receipt_obligation_required(payload): + return 'required' + return str(payload.get('status') or 'missing').strip() or 'missing' + + +def _proof_ref(refs: Mapping[str, Any], group: str, key: str) -> str: + value = refs.get(group) + if not isinstance(value, Mapping): + return '' + item = value.get(key) + if isinstance(item, Mapping) or isinstance(item, (list, tuple, set)): + return '' + return str(item or '').strip() + + def _dedupe(values: Iterable[Any]) -> tuple[str, ...]: seen: set[str] = set() out: list[str] = [] diff --git a/govengine/execution/approved_spec.py b/govengine/execution/approved_spec.py index c3ec13e..3996832 100644 --- a/govengine/execution/approved_spec.py +++ b/govengine/execution/approved_spec.py @@ -33,10 +33,18 @@ def approved_execution_steps(approved_execution_spec: Dict[str, Any]) -> List[Di if not isinstance(execution_plan, list) or not execution_plan: raise ValueError('missing_execution_plan') out: List[Dict[str, Any]] = [] - for step in execution_plan: + for index, step in enumerate(execution_plan): if not isinstance(step, dict): - continue - normalized_step = {'tool': str(step.get('tool') or ''), 'args': list(step.get('args') or [])} + raise ValueError(f'invalid_approved_execution_step:{index}') + tool = str(step.get('tool') or '').strip() + if not tool: + raise ValueError(f'missing_approved_execution_step_tool:{index}') + if 'args' not in step: + raise ValueError(f'missing_approved_execution_step_args:{index}') + raw_args = step.get('args') + if not isinstance(raw_args, list): + raise ValueError(f'invalid_approved_execution_step_args:{index}') + normalized_step = {'tool': tool, 'args': list(raw_args)} if step.get('stdin'): normalized_step['stdin'] = str(step.get('stdin') or '') out.append(normalized_step) diff --git a/govengine/execution/runner_protocol.py b/govengine/execution/runner_protocol.py index 05ff5a8..f654a44 100644 --- a/govengine/execution/runner_protocol.py +++ b/govengine/execution/runner_protocol.py @@ -29,6 +29,9 @@ "url", } RUNNER_RECEIPT_STATUSES = ("dry-run", "succeeded", "blocked", "failed", "interrupted") +RUNNER_REQUEST_SCHEMA_VERSION = "v0.1" +RUNNER_RECEIPT_SCHEMA_VERSION = "v0.1" +RUNNER_RECEIPT_BINDING_SCHEMA_VERSION = "v0.1" @dataclass(frozen=True) @@ -53,6 +56,7 @@ class GovRunnerRequest: request_id: str source: str steps: tuple[GovRunnerStep, ...] + schema_version: str = RUNNER_REQUEST_SCHEMA_VERSION approved_execution_spec: Mapping[str, Any] = field(default_factory=dict) execution_ticket_gate: Mapping[str, Any] = field(default_factory=dict) dry_run: bool = True @@ -61,6 +65,7 @@ def as_dict(self) -> dict[str, Any]: return { "request_id": self.request_id, "source": self.source, + "schema_version": self.schema_version, "steps": [step.as_dict() for step in self.steps], "approved_execution_spec": dict(self.approved_execution_spec), "execution_ticket_gate": dict(self.execution_ticket_gate), @@ -90,6 +95,7 @@ class GovRunnerReceiptBinding: """ admission_id: str = "" + schema_version: str = RUNNER_RECEIPT_BINDING_SCHEMA_VERSION admission_digest: str = "" ticket_id: str = "" ticket_digest: str = "" @@ -105,6 +111,7 @@ class GovRunnerReceiptBinding: def __post_init__(self) -> None: object.__setattr__(self, "admission_id", _clean_text(self.admission_id)) + object.__setattr__(self, "schema_version", _clean_text(self.schema_version) or RUNNER_RECEIPT_BINDING_SCHEMA_VERSION) object.__setattr__(self, "admission_digest", _clean_text(self.admission_digest)) object.__setattr__(self, "ticket_id", _clean_text(self.ticket_id)) object.__setattr__(self, "ticket_digest", _clean_text(self.ticket_digest)) @@ -130,6 +137,7 @@ def from_mapping(cls, value: Mapping[str, Any] | None) -> "GovRunnerReceiptBindi _reject_forbidden_binding(raw) return cls( admission_id=raw.get("admission_id") or "", + schema_version=raw.get("schema_version") or RUNNER_RECEIPT_BINDING_SCHEMA_VERSION, admission_digest=raw.get("admission_digest") or "", ticket_id=raw.get("ticket_id") or "", ticket_digest=raw.get("ticket_digest") or "", @@ -165,6 +173,7 @@ def present(self) -> bool: def as_dict(self) -> dict[str, Any]: return { "admission_id": self.admission_id, + "schema_version": self.schema_version, "admission_digest": self.admission_digest, "ticket_id": self.ticket_id, "ticket_digest": self.ticket_digest, @@ -185,6 +194,7 @@ class GovRunnerReceipt: status: str request_id: str source: str + schema_version: str = RUNNER_RECEIPT_SCHEMA_VERSION step_results: tuple[GovRunnerStepResult, ...] = () reason_code: str = "ok" control_decisions: tuple[Mapping[str, Any], ...] = () @@ -199,6 +209,7 @@ def as_dict(self) -> dict[str, Any]: "status": self.status, "request_id": self.request_id, "source": self.source, + "schema_version": self.schema_version, "reason_code": self.reason_code, "step_results": [result.as_dict() for result in self.step_results], "control_decisions": [dict(decision) for decision in self.control_decisions], @@ -253,6 +264,7 @@ def runner_request_from_approved_spec( request_id=str(request_id or "approved-spec-request"), source="approved_execution_spec", steps=normalize_runner_steps(raw_steps), + schema_version=RUNNER_REQUEST_SCHEMA_VERSION, approved_execution_spec=dict(approved_execution_spec), execution_ticket_gate=dict(execution_ticket_gate or {"status": "not_required"}), dry_run=bool(dry_run), @@ -268,6 +280,7 @@ def dry_run_runner_receipt( status="dry-run", request_id=request.request_id, source=request.source, + schema_version=RUNNER_RECEIPT_SCHEMA_VERSION, step_results=tuple( GovRunnerStepResult(index=step.index, status="dry-run", reason_code="dry_run_requested") for step in request.steps @@ -297,6 +310,7 @@ def runner_receipt_with_binding( status=receipt.status, request_id=receipt.request_id, source=receipt.source, + schema_version=receipt.schema_version, step_results=receipt.step_results, reason_code=receipt.reason_code, control_decisions=receipt.control_decisions, @@ -340,6 +354,52 @@ def runner_receipt_digest(receipt: GovRunnerReceipt) -> str: return govengine_record_digest(record, record_type="govengine.execution.runner_protocol.GovRunnerReceipt") +def runner_receipt_public_summary(value: GovRunnerReceipt | Mapping[str, Any]) -> dict[str, Any]: + """Return a bounded receipt summary without raw stdout/stderr payloads.""" + + receipt = value if isinstance(value, GovRunnerReceipt) else _receipt_from_mapping(value) + binding = receipt.binding + return { + "schema_version": receipt.schema_version, + "receipt_id": binding.receipt_id, + "request_id": receipt.request_id, + "status": receipt.status, + "reason_code": receipt.reason_code, + "step_count": len(receipt.step_results), + "admission_id": binding.admission_id, + "ticket_id": binding.ticket_id, + "request_digest": binding.request_digest, + "receipt_digest": binding.receipt_digest, + "output_digest_count": len(binding.output_digests), + "evidence_ref_count": len(binding.evidence_refs), + } + + +def _receipt_from_mapping(value: Mapping[str, Any]) -> GovRunnerReceipt: + raw = require_mapping(value, reason_code="invalid_runner_receipt") + return GovRunnerReceipt( + status=str(raw.get("status") or "").strip(), + request_id=str(raw.get("request_id") or "").strip(), + source=str(raw.get("source") or "").strip(), + schema_version=str(raw.get("schema_version") or RUNNER_RECEIPT_SCHEMA_VERSION).strip(), + reason_code=str(raw.get("reason_code") or "ok").strip() or "ok", + step_results=tuple( + GovRunnerStepResult( + index=int(item.get("index", -1)), + status=str(item.get("status") or "").strip(), + returncode=int(item.get("returncode", 0)), + stdout=str(item.get("stdout") or ""), + stderr=str(item.get("stderr") or ""), + reason_code=str(item.get("reason_code") or "ok").strip() or "ok", + ) + for item in list(raw.get("step_results") or ()) + if isinstance(item, Mapping) + ), + control_decisions=tuple(dict(item) for item in list(raw.get("control_decisions") or ()) if isinstance(item, Mapping)), + binding=raw.get("binding") if isinstance(raw.get("binding"), Mapping) else {}, + ) + + def validate_runner_receipt_binding( request: GovRunnerRequest, receipt: GovRunnerReceipt, diff --git a/govengine/replay.py b/govengine/replay.py index bce1305..87b02c0 100644 --- a/govengine/replay.py +++ b/govengine/replay.py @@ -11,6 +11,7 @@ GUARD_REPLAY_STORE_ARTIFACT_TYPE = "guard_replay_store" GUARD_REPLAY_STORE_SCHEMA_VERSION = "v0.1" +GUARD_REPLAY_RECORD_SCHEMA_VERSION = "v0.1" DEFAULT_GUARD_REPLAY_STORE_KEY = "guard_replay_store" @@ -30,6 +31,7 @@ class GuardReplayRecord: root_tag: str chain_id: str key_id: str + schema_version: str = GUARD_REPLAY_RECORD_SCHEMA_VERSION root_chain_digest: str = "" ticket_id: str = "" run_id: str = "" @@ -54,6 +56,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> "GuardReplayRecord": root_tag=root_tag, chain_id=chain_id, key_id=key_id, + schema_version=str(raw.get("schema_version") or GUARD_REPLAY_RECORD_SCHEMA_VERSION), root_chain_digest=str(raw.get("root_chain_digest") or ""), ticket_id=str(raw.get("ticket_id") or ""), run_id=str(raw.get("run_id") or ""), diff --git a/govengine/review.py b/govengine/review.py index 0cc1265..ad3a8f6 100644 --- a/govengine/review.py +++ b/govengine/review.py @@ -295,6 +295,31 @@ def validate_evidence_review_chain( return qualified +def evidence_claim_public_summary(value: GovEvidenceClaim | Mapping[str, Any]) -> dict[str, Any]: + """Return a public-safe evidence claim summary without raw evidence.""" + + item = validate_evidence_claim(value) + return { + 'claim_id': item.claim_id, + 'subject_ref': item.subject_ref, + 'claim_type': item.claim_type, + 'receipt_ref_count': len(item.receipt_refs), + 'evidence_ref_count': len(item.evidence_refs), + } + + +def review_result_public_summary(value: GovReviewResult | Mapping[str, Any]) -> dict[str, Any]: + """Return a public-safe review result summary without raw review payloads.""" + + item = validate_review_result(value) + return { + 'review_id': item.review_id, + 'subject_ref': item.subject_ref, + 'verdict': item.verdict, + 'qualification_ref_count': len(item.qualification_refs), + } + + def _receipt_rank(status: str) -> int: return { 'blocked': 0, diff --git a/scripts/inspect_runtime_admission.py b/scripts/inspect_runtime_admission.py index 7e1e82a..5d26737 100644 --- a/scripts/inspect_runtime_admission.py +++ b/scripts/inspect_runtime_admission.py @@ -9,6 +9,8 @@ from govengine.admission import RuntimeAdmissionResult from govengine.api import GovApiError +DEFAULT_MAX_BYTES = 1_048_576 + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( @@ -26,11 +28,21 @@ def _parser() -> argparse.ArgumentParser: action='store_true', help='Include already-bounded artifact references accepted by the admission validator.', ) + parser.add_argument( + '--max-bytes', + type=int, + default=DEFAULT_MAX_BYTES, + help='Maximum input size in bytes. Defaults to 1048576.', + ) return parser -def _read_record(path: Path) -> Mapping[str, Any]: +def _read_record(path: Path, *, max_bytes: int = DEFAULT_MAX_BYTES) -> Mapping[str, Any]: + if max_bytes < 1: + raise GovApiError('runtime_admission_invalid_max_bytes') try: + if path.stat().st_size > max_bytes: + raise GovApiError('runtime_admission_input_too_large') payload = json.loads(path.read_text(encoding='utf-8')) except OSError as exc: raise GovApiError('runtime_admission_read_failed', str(exc)) from exc @@ -111,8 +123,9 @@ def inspect_runtime_admission( *, output_format: str = 'text', show_artifact_refs: bool = False, + max_bytes: int = DEFAULT_MAX_BYTES, ) -> str: - record = RuntimeAdmissionResult.from_mapping(_read_record(path)) + record = RuntimeAdmissionResult.from_mapping(_read_record(path, max_bytes=max_bytes)) summary = _summary(record, show_artifact_refs=show_artifact_refs) if output_format == 'json': return json.dumps(summary, sort_keys=True, separators=(',', ':')) + '\n' @@ -126,6 +139,7 @@ def main(argv: list[str] | None = None) -> int: Path(args.record), output_format=args.format, show_artifact_refs=args.show_artifact_refs, + max_bytes=args.max_bytes, ) except GovApiError as exc: print(f'runtime_admission_inspect_error: {exc.reason_code}', file=sys.stderr) diff --git a/scripts/validate_public_truth.py b/scripts/validate_public_truth.py index 31e48dc..3593357 100644 --- a/scripts/validate_public_truth.py +++ b/scripts/validate_public_truth.py @@ -89,6 +89,12 @@ 'LocalSubprocessRunner', 'Current stage decision: `not_applicable`.', ), + 'docs/SECURITY_INTEGRATION.md': ( + 'SCLite secure verification', + '`RuntimeAdmissionResult` is not proof and not execution authority', + 'PKI, KMS, CA, HSM, private key storage', + '`JsonlAuditLedgerAdapter` is a development JSONL hash-chain adapter', + ), } diff --git a/tests/test_admission_contracts.py b/tests/test_admission_contracts.py index 444c0e6..9240f4a 100644 --- a/tests/test_admission_contracts.py +++ b/tests/test_admission_contracts.py @@ -20,8 +20,11 @@ JsonlAuditLedgerAdapter, RuntimeAdmissionResult, admission_decision_from_host_gate, + audit_ledger_verification_public_summary, audit_ledger_entry_digest, + audit_record_public_summary, compose_runtime_admission_result, + runtime_admission_public_summary, validate_admission_decision, validate_approval_request, validate_audit_record, @@ -29,6 +32,7 @@ validate_audit_ledger_entry, validate_audit_ledger_verification_result, validate_policy_decision, + validate_runtime_admission_proof_inputs, validate_runtime_admission_result, ) from govengine.admission import normalize_admission_artifact_refs @@ -131,6 +135,61 @@ def test_policy_approval_and_audit_contracts_are_shape_only() -> None: assert policy.as_dict()['controls'] == ['operator_review'] assert approval.as_dict()['policy_refs'] == ['policy-1'] assert audit.as_dict()['event_refs'] == ['event-1'] + assert audit.as_dict()['schema_version'] == 'v0.1' + + +def test_schema_versions_are_explicit_and_unknown_versions_fail_closed() -> None: + audit = validate_audit_record({ + 'record_id': 'audit-1', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:task-ref', + }) + entry = validate_audit_ledger_entry({ + 'entry_id': 'ledger-entry-1', + 'sequence': 0, + 'record': audit.as_dict(), + 'record_digest': 'sha256:audit-record', + }) + admission = validate_runtime_admission_result({ + 'admission_id': 'runtime-admission-1', + 'subject_ref': 'sha256:prepared-contract', + 'status': 'blocked', + 'allowed': False, + 'reason_code': 'blocked', + 'blockers': ['blocked'], + }) + + assert audit.schema_version == 'v0.1' + assert entry.schema_version == '' + assert admission.schema_version == 'v0.1' + + with pytest.raises(GovApiError, match='unknown_audit_record_schema_version:v9'): + validate_audit_record({ + 'schema_version': 'v9', + 'record_id': 'audit-2', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:task-ref', + }) + + with pytest.raises(GovApiError, match='unknown_audit_ledger_entry_schema_version:v9'): + validate_audit_ledger_entry({ + 'schema_version': 'v9', + 'entry_id': 'ledger-entry-2', + 'sequence': 0, + 'record': audit.as_dict(), + 'record_digest': 'sha256:audit-record', + }) + + with pytest.raises(GovApiError, match='unknown_runtime_admission_schema_version:v9'): + validate_runtime_admission_result({ + 'schema_version': 'v9', + 'admission_id': 'runtime-admission-2', + 'subject_ref': 'sha256:prepared-contract', + 'status': 'blocked', + 'allowed': False, + 'reason_code': 'blocked', + 'blockers': ['blocked'], + }) def test_policy_approval_and_audit_reject_runtime_ownership_claims() -> None: @@ -452,6 +511,7 @@ def test_runtime_admission_result_allows_bounded_gate_summaries() -> None: assert isinstance(result, RuntimeAdmissionResult) assert payload['allowed'] is True + assert payload['schema_version'] == 'v0.1' assert payload['status'] == 'allowed' assert payload['blockers'] == [] assert payload['runner_profile']['profile'] == 'dry_run' @@ -474,6 +534,67 @@ def test_runtime_admission_result_blocks_with_next_actions() -> None: assert result.required_next_actions == ('evaluate_policy',) +def test_runtime_admission_public_summary_hides_refs_by_default() -> None: + result = compose_runtime_admission_result(**_runtime_admission_inputs( + artifact_refs={ + 'admission_digest': 'sha256:' + ('a' * 64), + 'path': 'artifacts/runtime-admission.json', + }, + )) + + summary = runtime_admission_public_summary(result) + expanded = runtime_admission_public_summary(result, show_artifact_refs=True) + + assert summary == { + 'schema_version': 'v0.1', + 'admission_id': 'runtime-admission-composed-1', + 'subject_ref': 'sha256:prepared-contract', + 'status': 'allowed', + 'allowed': True, + 'reason_code': 'all_required_gates_passed', + 'blocker_count': 0, + 'required_next_action_count': 0, + 'receipt_obligation': 'required', + } + assert 'artifact_refs' not in summary + assert expanded['artifact_refs']['explicit']['admission_digest'] == 'sha256:' + ('a' * 64) + + +def test_audit_public_summaries_hide_records_and_metadata() -> None: + record = validate_audit_record({ + 'record_id': 'audit-public-1', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:policy-subject', + 'decision_ref': 'policy-1', + 'event_refs': ['event-1', 'event-2'], + 'metadata': {'retention': 'host-owned'}, + }) + verification = validate_audit_ledger_verification_result({ + 'status': 'verified', + 'verified': True, + 'checked_entries': 1, + 'last_entry_id': 'ledger-entry-1', + 'last_entry_digest': 'sha256:ledger-entry-1', + }) + + record_summary = audit_record_public_summary(record) + verification_summary = audit_ledger_verification_public_summary(verification) + + assert record_summary == { + 'schema_version': 'v0.1', + 'record_id': 'audit-public-1', + 'record_type': 'policy_decision', + 'subject_ref': 'sha256:policy-subject', + 'decision_ref': 'policy-1', + 'reason_code': 'recorded', + 'event_ref_count': 2, + } + assert 'metadata' not in record_summary + assert verification_summary['checked_entries'] == 1 + assert verification_summary['last_entry_digest'] == 'sha256:ledger-entry-1' + assert 'record' not in verification_summary + + def test_runtime_admission_result_rejects_status_allowed_mismatch() -> None: with pytest.raises(GovApiError, match='runtime_admission_allowed_status_mismatch'): validate_runtime_admission_result(RuntimeAdmissionResult( @@ -628,6 +749,63 @@ def test_compose_runtime_admission_result_allows_guarded_fresh_runtime_bundle() assert result.replay_freshness['replay_status'] == 'fresh' +def test_validate_runtime_admission_proof_inputs_accepts_complete_bounded_chain() -> None: + result = compose_runtime_admission_result(**_runtime_admission_inputs( + runtime_consumable=True, + sclite_guarded_strict={ + 'status': 'allowed', + 'verification_status': 'passed', + 'root_chain_digest': 'sha256:' + ('b' * 64), + }, + replay_freshness={'status': 'allowed', 'replay_status': 'fresh'}, + artifact_refs={ + 'sclite_guarded_strict': {'root_chain_digest': 'sha256:' + ('b' * 64)}, + 'execution_ticket': {'ticket_id': 'ticket-1'}, + }, + )) + + assert validate_runtime_admission_proof_inputs(result) == result + + +@pytest.mark.parametrize( + ('overrides', 'error'), + ( + ({'sclite_guarded_strict': {'status': 'blocked'}}, 'runtime_admission_proof_guarded_strict_missing'), + ( + { + 'runtime_consumable': True, + 'sclite_guarded_strict': { + 'status': 'allowed', + 'verification_status': 'passed', + 'root_chain_digest': 'sha256:' + ('b' * 64), + }, + 'replay_freshness': {'status': 'allowed', 'replay_status': 'fresh'}, + 'receipt_obligation': {'required': True, 'binds': ['admission']}, + 'artifact_refs': { + 'sclite_guarded_strict': {'root_chain_digest': 'sha256:' + ('b' * 64)}, + 'execution_ticket': {'ticket_id': 'ticket-1'}, + }, + }, + 'runtime_admission_proof_receipt_binding_incomplete', + ), + ( + { + 'runtime_consumable': True, + 'sclite_guarded_strict': {'status': 'allowed', 'verification_status': 'passed'}, + 'replay_freshness': {'status': 'allowed', 'replay_status': 'fresh'}, + 'artifact_refs': {'execution_ticket': {'ticket_id': 'ticket-1'}}, + }, + 'runtime_admission_proof_guard_digest_missing', + ), + ), +) +def test_validate_runtime_admission_proof_inputs_fails_closed(overrides, error) -> None: + result = compose_runtime_admission_result(**_runtime_admission_inputs(**overrides)) + + with pytest.raises(GovApiError, match=error): + validate_runtime_admission_proof_inputs(result) + + def test_compose_runtime_admission_result_blocks_missing_policy() -> None: result = compose_runtime_admission_result(**_runtime_admission_inputs(policy_decision=None)) @@ -936,6 +1114,17 @@ def test_inspect_runtime_admission_fails_closed_for_malformed_input(tmp_path) -> assert 'runtime_admission_inspect_error: runtime_admission_json_invalid' in result.stderr +def test_inspect_runtime_admission_rejects_oversized_input_before_parsing(tmp_path) -> None: + path = tmp_path / 'runtime-admission.json' + path.write_text(' ' * 32, encoding='utf-8') + + result = _run_inspect(path, '--max-bytes', '8') + + assert result.returncode == 2 + assert result.stdout == '' + assert 'runtime_admission_inspect_error: runtime_admission_input_too_large' in result.stderr + + def test_inspect_runtime_admission_rejects_forbidden_raw_runtime_data(tmp_path) -> None: payload = compose_runtime_admission_result(**_runtime_admission_inputs()).as_dict() payload['metadata'] = {'raw_output': 'do-not-print'} diff --git a/tests/test_boundary_hardening.py b/tests/test_boundary_hardening.py index 1b8e376..c82b850 100644 --- a/tests/test_boundary_hardening.py +++ b/tests/test_boundary_hardening.py @@ -98,6 +98,33 @@ def test_neutral_public_surfaces_do_not_import_host_runtime_or_carrier_packages( assert violations == [] +def test_sclite_imports_stay_on_delegation_allowlist() -> None: + allowed = { + 'sclite.bundles', + 'sclite.integrity', + 'sclite.integrity.chain', + 'sclite.secure', + 'sclite.tickets', + } + violations: list[str] = [] + for path in (ROOT / 'govengine').rglob('*.py'): + if path.name == '__init__.py': + continue + tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names if alias.name == 'sclite' or alias.name.startswith('sclite.')) + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module == 'sclite' or node.module.startswith('sclite.'): + found.add(node.module) + forbidden = sorted(module for module in found if module not in allowed) + if forbidden: + violations.append(f'{path.relative_to(ROOT).as_posix()} -> {forbidden}') + + assert violations == [] + + def test_neutral_surfaces_keep_runtime_authority_as_non_claims() -> None: required_by_surface = { 'artifact_governance_core': ('key-store', 'storage', 'scheduling'), @@ -137,3 +164,26 @@ def test_public_docs_keep_live_backend_disabled_by_default_non_claims() -> None: for marker in required_markers: assert marker in combined + + +def test_security_integration_doc_records_order_and_non_claims() -> None: + text = (ROOT / 'docs' / 'SECURITY_INTEGRATION.md').read_text(encoding='utf-8') + required_markers = ( + 'SCLite secure verification', + 'GovEngine replay freshness', + 'Host trust decision', + 'Execution ticket gate', + 'Runtime admission composition', + 'Runner receipt binding', + '`RuntimeAdmissionResult` is not proof and not execution authority', + 'not verify SCLite artifacts', + 'PKI, KMS, CA, HSM, private key storage', + 'raw evidence storage', + '`DemoDigestSigner` and `DemoDigestVerifier` are deterministic demo helpers', + '`InMemoryReplayClaimStore` is a development claim-once adapter', + '`record_guard_replay_file()` is a local JSON helper', + '`JsonlAuditLedgerAdapter` is a development JSONL hash-chain adapter', + ) + + for marker in required_markers: + assert marker in text diff --git a/tests/test_execution_helpers.py b/tests/test_execution_helpers.py index 911b8ba..4102f52 100644 --- a/tests/test_execution_helpers.py +++ b/tests/test_execution_helpers.py @@ -36,6 +36,32 @@ def test_approved_spec_and_dry_run_result() -> None: assert result['execution_ticket_gate'] == {'status': 'not_required'} +@pytest.mark.parametrize('bad_step', ['curl https://example.com', None, ['curl']]) +def test_approved_execution_steps_rejects_non_mapping_steps(bad_step) -> None: + approved = _approved_spec() + approved['execution_truth']['execution_plan'] = [bad_step] + + with pytest.raises(ValueError, match='invalid_approved_execution_step:0'): + approved_execution_steps(approved) + + +@pytest.mark.parametrize( + ('bad_step', 'reason'), + [ + ({'args': ['https://example.com']}, 'missing_approved_execution_step_tool:0'), + ({'tool': '', 'args': ['https://example.com']}, 'missing_approved_execution_step_tool:0'), + ({'tool': 'curl'}, 'missing_approved_execution_step_args:0'), + ({'tool': 'curl', 'args': 'https://example.com'}, 'invalid_approved_execution_step_args:0'), + ], +) +def test_approved_execution_steps_rejects_malformed_tool_or_args(bad_step, reason: str) -> None: + approved = _approved_spec() + approved['execution_truth']['execution_plan'] = [bad_step] + + with pytest.raises(ValueError, match=reason): + approved_execution_steps(approved) + + def test_execution_ticket_gate_requires_ticket_when_called() -> None: approved = _approved_spec() with pytest.raises(ValueError, match='missing_execution_ticket'): diff --git a/tests/test_guard_replay.py b/tests/test_guard_replay.py index 1601521..329efcd 100644 --- a/tests/test_guard_replay.py +++ b/tests/test_guard_replay.py @@ -13,6 +13,7 @@ from govengine.api import GovApiError from govengine.replay import ( evaluate_guard_replay, + GuardReplayRecord, guard_replay_record_from_guard, InMemoryReplayClaimStore, ReplayClaimStore, @@ -84,6 +85,17 @@ def test_guard_replay_record_from_guard_captures_runtime_ids() -> None: assert record.ticket_id == "ticket-1" assert record.run_id == "run-1" assert record.guard_profile == "kernel_guard_hmac_v1" + assert record.schema_version == "v0.1" + + +def test_guard_replay_record_accepts_legacy_mapping_without_schema_version() -> None: + record = GuardReplayRecord.from_mapping({ + "root_tag": "tag-legacy", + "chain_id": "chain-1", + "key_id": "key-1", + }) + + assert record.schema_version == "v0.1" def test_guard_replay_record_requires_core_guard_identifiers() -> None: @@ -233,10 +245,19 @@ def test_record_guard_replay_file_round_trip(tmp_path) -> None: assert "tag-1" in path.read_text(encoding="utf-8") -def _install_fake_sclite_secure(monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_sclite_secure(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: module = types.ModuleType("sclite.secure") + calls: list[dict[str, Any]] = [] def verify_secure_bundle(manifest_path, *, guard_path, key, root, validate_schemas, strict_jsonschema): + calls.append({ + "manifest_path": manifest_path, + "guard_path": guard_path, + "key": key, + "root": root, + "validate_schemas": validate_schemas, + "strict_jsonschema": strict_jsonschema, + }) return { "status": "passed", "secure_profile": "guarded-strict", @@ -248,6 +269,7 @@ def verify_secure_bundle(manifest_path, *, guard_path, key, root, validate_schem module.verify_secure_bundle = verify_secure_bundle monkeypatch.setitem(sys.modules, "sclite.secure", module) + return calls def _write_guarded_bundle(tmp_path: Path, *, root_tag: str = "tag-1") -> tuple[Path, Path]: @@ -276,7 +298,7 @@ def _write_guarded_bundle(tmp_path: Path, *, root_tag: str = "tag-1") -> tuple[P def test_verify_guard_and_record_replay_allows_first_use_and_blocks_second(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_sclite_secure(monkeypatch) + calls = _install_fake_sclite_secure(monkeypatch) manifest_path, guard_path = _write_guarded_bundle(tmp_path) store = MemoryStore() @@ -291,6 +313,11 @@ def test_verify_guard_and_record_replay_allows_first_use_and_blocks_second(tmp_p assert second.status == "blocked" assert second.replay_status == "replayed" assert second.blocker.startswith("replayed_guarded_payload:") + assert len(calls) == 2 + assert calls[0]["manifest_path"] == manifest_path.resolve() + assert calls[0]["guard_path"] == guard_path.resolve() + assert calls[0]["root"] == tmp_path.resolve() + assert calls[0]["validate_schemas"] is True def test_verify_guard_and_record_replay_blocks_reguarded_same_payload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_public_truth_consistency.py b/tests/test_public_truth_consistency.py index 6c8ebd7..07c71e7 100644 --- a/tests/test_public_truth_consistency.py +++ b/tests/test_public_truth_consistency.py @@ -136,6 +136,7 @@ def test_public_truth_validator_tracks_current_mvp_surface_docs() -> None: 'docs/ADMISSION_POLICY.md': ('AuditLedgerPort', 'JsonlAuditLedgerAdapter'), 'docs/SCLITE_INTEGRATION.md': ('ReplayClaimStore', 'claim-once adapter'), 'docs/RUNNER_SUPERVISION.md': ('Live Runner Safety Specification', 'LocalSubprocessRunner'), + 'docs/SECURITY_INTEGRATION.md': ('SCLite secure verification', 'not proof and not execution authority'), }) diff --git a/tests/test_review_contracts.py b/tests/test_review_contracts.py index 70c2b27..f526b1a 100644 --- a/tests/test_review_contracts.py +++ b/tests/test_review_contracts.py @@ -6,7 +6,9 @@ GovEvidenceClaim, GovEvidenceRequirement, GovReviewResult, + evidence_claim_public_summary, qualify_evidence_claim, + review_result_public_summary, validate_evidence_claim, validate_evidence_requirement, validate_evidence_review_chain, @@ -87,6 +89,43 @@ def test_review_result_is_shape_only() -> None: assert review.as_dict()['qualification_refs'] == ['claim-1:qualification'] +def test_review_public_summaries_exclude_raw_metadata() -> None: + claim = validate_evidence_claim({ + 'claim_id': 'claim-public', + 'subject_ref': 'sha256:subject', + 'claim_type': 'execution_truth', + 'receipt_refs': ['receipt-1'], + 'evidence_refs': ['evidence-1', 'evidence-2'], + 'metadata': {'admission_id': 'admission-1'}, + }) + review = validate_review_result({ + 'review_id': 'review-public', + 'subject_ref': 'sha256:subject', + 'verdict': 'passed', + 'qualification_refs': ['claim-public:qualification'], + 'metadata': {'reviewer': 'host-owned'}, + }) + + claim_summary = evidence_claim_public_summary(claim) + review_summary = review_result_public_summary(review) + + assert claim_summary == { + 'claim_id': 'claim-public', + 'subject_ref': 'sha256:subject', + 'claim_type': 'execution_truth', + 'receipt_ref_count': 1, + 'evidence_ref_count': 2, + } + assert review_summary == { + 'review_id': 'review-public', + 'subject_ref': 'sha256:subject', + 'verdict': 'passed', + 'qualification_ref_count': 1, + } + assert 'metadata' not in claim_summary + assert 'metadata' not in review_summary + + def test_evidence_review_chain_validates_admission_receipt_and_review_refs() -> None: qualification = validate_evidence_review_chain( { diff --git a/tests/test_runner_protocol.py b/tests/test_runner_protocol.py index 740f260..3b63b9f 100644 --- a/tests/test_runner_protocol.py +++ b/tests/test_runner_protocol.py @@ -9,6 +9,7 @@ GovRunnerReceiptBinding, dry_run_runner_receipt, normalize_runner_steps, + runner_receipt_public_summary, runner_receipt_digest, runner_receipt_with_binding, runner_request_digest, @@ -35,6 +36,7 @@ def _approved_spec() -> dict: def test_runner_request_from_approved_spec_is_carrier_neutral() -> None: request = runner_request_from_approved_spec(_approved_spec(), request_id="r1") + assert request.schema_version == "v0.1" assert request.request_id == "r1" assert request.source == "approved_execution_spec" assert request.dry_run is True @@ -48,6 +50,7 @@ def test_dry_run_runner_receipt_records_each_step() -> None: receipt = dry_run_runner_receipt(request) assert receipt.status == "dry-run" + assert receipt.schema_version == "v0.1" assert receipt.reason_code == "dry_run_requested" assert receipt.step_results[0].status == "dry-run" assert receipt.as_dict()["step_results"][0]["reason_code"] == "dry_run_requested" @@ -72,6 +75,7 @@ def test_runner_receipt_with_binding_adds_bounded_references() -> None: binding = receipt.as_dict()["binding"] assert binding["admission_id"] == "admission-1" + assert binding["schema_version"] == "v0.1" assert binding["admission_digest"] == "sha256:admission" assert binding["ticket_id"] == "ticket-1" assert binding["ticket_digest"] == "sha256:ticket" @@ -82,6 +86,40 @@ def test_runner_receipt_with_binding_adds_bounded_references() -> None: assert binding["evidence_refs"] == {"review": "artifact://review/1"} +def test_runner_receipt_public_summary_excludes_raw_step_output() -> None: + request = runner_request_from_approved_spec(_approved_spec(), request_id="r-public") + receipt = runner_receipt_with_binding( + dry_run_runner_receipt(request), + admission_id="admission-1", + admission_digest="sha256:" + "a" * 64, + ticket_id="ticket-1", + ticket_digest="sha256:" + "b" * 64, + request_digest=runner_request_digest(request), + receipt_id="receipt-1", + output_digests={"stdout": "sha256:stdout"}, + evidence_refs={"review": "artifact://review/1"}, + ) + + summary = runner_receipt_public_summary(receipt) + + assert summary == { + "schema_version": "v0.1", + "receipt_id": "receipt-1", + "request_id": "r-public", + "status": "dry-run", + "reason_code": "dry_run_requested", + "step_count": 1, + "admission_id": "admission-1", + "ticket_id": "ticket-1", + "request_digest": runner_request_digest(request), + "receipt_digest": receipt.binding.receipt_digest, + "output_digest_count": 1, + "evidence_ref_count": 1, + } + assert "stdout" not in summary + assert "stderr" not in summary + + def test_runner_receipt_binding_auto_digest_changes_when_receipt_mutates() -> None: request = runner_request_from_approved_spec(_approved_spec(), request_id="r-digest") receipt = runner_receipt_with_binding(