From 0fbff84e288371de73c25df2840fa601b674c1fe Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:27:51 -0700 Subject: [PATCH 01/31] chore: add baseline scaffolding and ignore generated artifacts Pin project metadata and dependencies, add LICENSE, and stop tracking egg-info so local installs stay out of version control. --- .gitignore | 49 ++++++++++ LICENSE | 17 ++++ morph_replay_runner.egg-info/PKG-INFO | 35 ------- morph_replay_runner.egg-info/SOURCES.txt | 13 --- .../dependency_links.txt | 1 - morph_replay_runner.egg-info/entry_points.txt | 2 - morph_replay_runner.egg-info/requires.txt | 14 --- morph_replay_runner.egg-info/top_level.txt | 2 - pyproject.toml | 97 ++++++++++++++++--- requirements.txt | 6 +- 10 files changed, 155 insertions(+), 81 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100644 morph_replay_runner.egg-info/PKG-INFO delete mode 100644 morph_replay_runner.egg-info/SOURCES.txt delete mode 100644 morph_replay_runner.egg-info/dependency_links.txt delete mode 100644 morph_replay_runner.egg-info/entry_points.txt delete mode 100644 morph_replay_runner.egg-info/requires.txt delete mode 100644 morph_replay_runner.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f66a28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Byte-compiled / cache +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ +dist/ +build/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.venv/ +venv/ +env/ + +# Evidence and local outputs +/evidence/ +/evidence-*/ +branches/ +branches-smoke/ +diffs/ +replays/*.zip +*.log + +# Secrets and local env +.env +.env.* +!.env.example +*.pem +*.key +credentials.json + +# IDE / OS +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db + +# Release scratch +release-bundle/ +*.whl +!examples/**/*.json + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3829e2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/LICENSE-2.0 + +Copyright 2026 SentinelOps-CI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/morph_replay_runner.egg-info/PKG-INFO b/morph_replay_runner.egg-info/PKG-INFO deleted file mode 100644 index 68c4e8a..0000000 --- a/morph_replay_runner.egg-info/PKG-INFO +++ /dev/null @@ -1,35 +0,0 @@ -Metadata-Version: 2.4 -Name: morph-replay-runner -Version: 0.1.0 -Summary: CLI tool for running TRACE-REPLAY-KIT bundles with branch-N parallelism on Morph Cloud -Author-email: SentinelOps-CI -License: Apache-2.0 -Project-URL: Homepage, https://github.com/SentinelOps-CI/morph-replay-runner -Project-URL: Repository, https://github.com/SentinelOps-CI/morph-replay-runner -Project-URL: Issues, https://github.com/SentinelOps-CI/morph-replay-runner/issues -Classifier: Development Status :: 3 - Alpha -Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: Apache Software License -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.8 -Classifier: Programming Language :: Python :: 3.9 -Classifier: Programming Language :: Python :: 3.10 -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Requires-Python: >=3.8 -Description-Content-Type: text/markdown -Requires-Dist: morphcloud>=0.1.91 -Requires-Dist: click>=8.0.0 -Requires-Dist: pydantic>=2.0.0 -Requires-Dist: rich>=13.0.0 -Requires-Dist: aiofiles>=23.0.0 -Requires-Dist: asyncio-mqtt>=0.16.0 -Provides-Extra: dev -Requires-Dist: pytest>=7.0.0; extra == "dev" -Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev" -Requires-Dist: black>=23.0.0; extra == "dev" -Requires-Dist: isort>=5.12.0; extra == "dev" -Requires-Dist: mypy>=1.0.0; extra == "dev" -Requires-Dist: ruff>=0.1.0; extra == "dev" - - diff --git a/morph_replay_runner.egg-info/SOURCES.txt b/morph_replay_runner.egg-info/SOURCES.txt deleted file mode 100644 index 9c79c99..0000000 --- a/morph_replay_runner.egg-info/SOURCES.txt +++ /dev/null @@ -1,13 +0,0 @@ -README.md -pyproject.toml -morph_replay_runner.egg-info/PKG-INFO -morph_replay_runner.egg-info/SOURCES.txt -morph_replay_runner.egg-info/dependency_links.txt -morph_replay_runner.egg-info/entry_points.txt -morph_replay_runner.egg-info/requires.txt -morph_replay_runner.egg-info/top_level.txt -runner/__init__.py -runner/core.py -runner/core_fixed.py -runner/main.py -runner/models.py \ No newline at end of file diff --git a/morph_replay_runner.egg-info/dependency_links.txt b/morph_replay_runner.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/morph_replay_runner.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/morph_replay_runner.egg-info/entry_points.txt b/morph_replay_runner.egg-info/entry_points.txt deleted file mode 100644 index 3b2bb09..0000000 --- a/morph_replay_runner.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -replay-runner = runner.main:main diff --git a/morph_replay_runner.egg-info/requires.txt b/morph_replay_runner.egg-info/requires.txt deleted file mode 100644 index 1c3bb7b..0000000 --- a/morph_replay_runner.egg-info/requires.txt +++ /dev/null @@ -1,14 +0,0 @@ -morphcloud>=0.1.91 -click>=8.0.0 -pydantic>=2.0.0 -rich>=13.0.0 -aiofiles>=23.0.0 -asyncio-mqtt>=0.16.0 - -[dev] -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -black>=23.0.0 -isort>=5.12.0 -mypy>=1.0.0 -ruff>=0.1.0 diff --git a/morph_replay_runner.egg-info/top_level.txt b/morph_replay_runner.egg-info/top_level.txt deleted file mode 100644 index 6ad2d8a..0000000 --- a/morph_replay_runner.egg-info/top_level.txt +++ /dev/null @@ -1,2 +0,0 @@ -runner -schemas diff --git a/pyproject.toml b/pyproject.toml index f68b432..8e82fcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,41 +5,66 @@ build-backend = "setuptools.build_meta" [project] name = "morph-replay-runner" version = "0.1.0" -description = "CLI tool for running TRACE-REPLAY-KIT bundles with branch-N parallelism on Morph Cloud" +description = "Hermetic branch-N execution backend for SentinelOps-CI with observational PCS/PIP evidence" authors = [ {name = "SentinelOps-CI", email = "team@sentinelops.ci"} ] readme = "README.md" license = {text = "Apache-2.0"} -requires-python = ">=3.8" +requires-python = ">=3.9" +keywords = [ + "sentinelops", + "hermetic", + "branch-n", + "pcs", + "pip", + "morph", + "replay", + "evidence", +] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", + "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Topic :: Security", + "Topic :: Software Development :: Quality Assurance", + "Typing :: Typed", ] dependencies = [ "morphcloud>=0.1.91", "click>=8.0.0", "pydantic>=2.0.0", "rich>=13.0.0", - "aiofiles>=23.0.0", - "asyncio-mqtt>=0.16.0", + "jsonschema[format]>=4.18.0", + "referencing>=0.30.0", ] [project.optional-dependencies] dev = [ "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", "black>=23.0.0", "isort>=5.12.0", "mypy>=1.0.0", "ruff>=0.1.0", + "types-jsonschema>=4.0.0", + "pip-audit>=2.6.0", +] +pcs = [ + "pcs-core>=0.1.0", +] +pip = [ + "post-incident>=0.1.0", +] +ovk = [ + "open-verification-kernel==1.2.1", ] [project.scripts] @@ -49,27 +74,75 @@ replay-runner = "runner.main:main" Homepage = "https://github.com/SentinelOps-CI/morph-replay-runner" Repository = "https://github.com/SentinelOps-CI/morph-replay-runner" Issues = "https://github.com/SentinelOps-CI/morph-replay-runner/issues" +Changelog = "https://github.com/SentinelOps-CI/morph-replay-runner/blob/main/CHANGELOG.md" +Documentation = "https://github.com/SentinelOps-CI/morph-replay-runner#readme" +"Security Policy" = "https://github.com/SentinelOps-CI/morph-replay-runner/blob/main/SECURITY.md" [tool.setuptools.packages.find] where = ["."] -include = ["runner*", "schemas*"] +include = ["runner*"] + +[tool.setuptools.package-data] +runner = ["py.typed"] [tool.black] line-length = 88 -target-version = ['py38'] +target-version = ['py39'] +extend-exclude = ''' +/( + \.mypy_cache + | \.ruff_cache + | \.pytest_cache + | branches-smoke + | release-bundle + | evidence +)/ +''' [tool.isort] profile = "black" line_length = 88 +skip_glob = [".mypy_cache/*", "branches-smoke/*", "release-bundle/*"] [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_return_any = true warn_unused_configs = true +warn_unused_ignores = true disallow_untyped_defs = true +disallow_any_generics = true +no_implicit_optional = true +strict = true +warn_redundant_casts = true +mypy_path = "." +packages = ["runner"] + +[[tool.mypy.overrides]] +module = ["morphcloud.*"] +ignore_missing_imports = true [tool.ruff] -target-version = "py38" +target-version = "py39" line-length = 88 -select = ["E", "F", "B", "I", "N", "UP", "W", "C4", "SIM", "ARG", "PIE", "T20", "Q", "RSE", "RET", "SLF", "SLOT", "TCH", "TID", "TCH", "ARG", "PIE", "SIM", "LOG", "RUF"] -ignore = ["E501", "B008", "C901"] +extend-exclude = [".mypy_cache", "branches-smoke", "release-bundle", "evidence"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I", "UP", "W", "C4", "SIM", "ARG", "PIE", "RUF"] +ignore = [ + "E501", + "B008", + "C901", + "UP007", # Optional[] kept for explicit 3.9-friendly annotations + "UP006", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "error", + "ignore::DeprecationWarning:morphcloud.*", + "ignore::DeprecationWarning:pydantic.*", + "ignore::pytest.PytestDeprecationWarning", +] diff --git a/requirements.txt b/requirements.txt index 22efc0f..0ab3254 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ +# Runtime pins for editable / Docker installs. +# Canonical metadata lives in pyproject.toml. morphcloud>=0.1.91 click>=8.0.0 pydantic>=2.0.0 rich>=13.0.0 -aiofiles>=23.0.0 -asyncio-mqtt>=0.16.0 +jsonschema[format]>=4.18.0 +referencing>=0.30.0 From 1b4dfced66534f88e42a18c8ca77b54172621240 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:07 -0700 Subject: [PATCH 02/31] docs: capture MRR-ITE-00 architecture baseline Establish the architectural decision record and baseline notes so later slices have a shared design anchor. --- docs/adr/MRR-ITE-00-architecture.md | 76 ++++++++++++++++++++++++ docs/adr/README.md | 17 ++++++ docs/baseline/MRR-ITE-00.md | 90 +++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 docs/adr/MRR-ITE-00-architecture.md create mode 100644 docs/adr/README.md create mode 100644 docs/baseline/MRR-ITE-00.md diff --git a/docs/adr/MRR-ITE-00-architecture.md b/docs/adr/MRR-ITE-00-architecture.md new file mode 100644 index 0000000..efe23aa --- /dev/null +++ b/docs/adr/MRR-ITE-00-architecture.md @@ -0,0 +1,76 @@ +# ADR MRR-ITE-00: Hermetic Branch-N Architecture Boundaries + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +`morph-replay-runner` began as a thin Morph Cloud ZIP-replay CLI. SentinelOps requires a hermetic, immutable, branch-N execution backend that emits observational evidence under PCS and PIP contracts without inventing research semantics inside providers. + +## Decision + +### Provider boundary + +Providers (`MorphCloudProvider`, `LocalFakeProvider`) implement only infrastructure operations: snapshot lookup, clone, execute, measure, extract artifacts, teardown, and hermeticity capability probes. Providers MUST NOT encode incident causality, remediation preference, reward semantics, or verifier policy interpretation. + +### Ownership + +| Concern | Owner module | +|---------|--------------| +| ExecutionProfile schema, digest, secret refs | `runner/profile/` | +| Hermeticity modes + enforcement evidence | `runner/hermeticity/` | +| BranchReplayManifest + scheduler | `runner/branch/` | +| Resource accounting | `runner/accounting/` | +| Differential reports | `runner/diff/` | +| PCS/PIP emitters, digests, redaction | `runner/evidence/` | +| OVK checker shell-out | `runner/ovk/` | +| Legacy ZIP Morph path | `runner/legacy_core.py` (wrapper over prior `core.py` behavior) | + +### Claim classes + +| Layer | Allowed claim / label | When | +|-------|----------------------|------| +| Repo-local | observational branch status `complete` \| `partial` | always when written | +| PCS | `RuntimeReceipt.v0` with status `RuntimeObserved` | every complete branch emission | +| PCS | `RuntimeChecked` | only after an actual runtime check ran | +| PF-Core | `ReplayValidated` / five-file emit | only when claim class applies and PF-shaped trace supplied | +| PIP | `MorphReplayReport.v1` status `recorded` \| `mismatch` \| `indeterminate` | when PIP emitter runs | +| OVK | digests/refs of checker results | only when OVK was shell-invoked | + +### Explicit non-claims + +- No causality or root-cause attribution +- No remediation ranking or “preferred branch” +- No environment semantics generation +- No campaign-level verifier assurance +- HTTP callback stubs are out of scope for hermetic branch-N; re-enabling without a real implementation MUST fail closed +- CERT-V1 guest copies remain optional guest artifacts referenced by digest; they are not PCS substitutes + +Product-level text: [NON_CLAIMS.md](../../NON_CLAIMS.md). + +### Cleanup completed in later ITEs + +Tracked here for history; executed in MRR-ITE-02: + +- Removed unused duplicate `runner/core_fixed.py` +- Dropped unused dependencies `aiofiles`, `asyncio-mqtt` +- Simplified `docker/docker-compose.yml` (removed broken HTTP stub service) + +### Compatibility + +`replay-runner branch` is the primary hermetic path (MRR-ITE-04). +Preserve `replay-runner --snapshot … --bundles …` as the `run` compatibility path. +Default CI MUST run offline via `LocalFakeProvider`; Morph remains opt-in behind secrets. + +## Consequences + +Later ITEs introduce schemas and runtime behind these boundaries. Violations (providers encoding research semantics, claim upgrades without checks, secret values in profiles) are hard failures. + +## Rollback + +Revert docs/CI scaffolding introduced in ITE-00; no schema consumers at baseline time. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..4df01bc --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,17 @@ +# Architecture Decision Records (MRR-ITE) + +| ADR | Title | Status | +|-----|-------|--------| +| [MRR-ITE-00](MRR-ITE-00-architecture.md) | Hermetic branch-N architecture boundaries | Accepted | +| [MRR-ITE-01](MRR-ITE-01-execution-profile.md) | ExecutionProfile | Accepted | +| [MRR-ITE-02](MRR-ITE-02-providers.md) | Provider abstraction | Accepted | +| [MRR-ITE-03](MRR-ITE-03-hermeticity.md) | Hermeticity modes | Accepted | +| [MRR-ITE-04](MRR-ITE-04-branch-scheduler.md) | Branch-N scheduler | Accepted | +| [MRR-ITE-05](MRR-ITE-05-resources.md) | Resource accounting | Accepted | +| [MRR-ITE-06](MRR-ITE-06-differential.md) | Differential reports | Accepted | +| [MRR-ITE-07](MRR-ITE-07-pcs-pf.md) | PCS / PF-Core outputs | Accepted (amended) | +| [MRR-ITE-08](MRR-ITE-08-pip.md) | PIP integration | Accepted | +| [MRR-ITE-09](MRR-ITE-09-security-release.md) | Security and release | Accepted | + +Historical baseline measurement: [../baseline/MRR-ITE-00.md](../baseline/MRR-ITE-00.md). +Claim boundaries: [../../NON_CLAIMS.md](../../NON_CLAIMS.md). diff --git a/docs/baseline/MRR-ITE-00.md b/docs/baseline/MRR-ITE-00.md new file mode 100644 index 0000000..22c5a90 --- /dev/null +++ b/docs/baseline/MRR-ITE-00.md @@ -0,0 +1,90 @@ +# MRR-ITE-00 Baseline + +## Status + +**Historical baseline measurement** for MRR-ITE-00 (2026-07-24). +The gap inventory below describes the repository **at the base commit**, before +subsequent ITEs. Implemented capabilities after 0.1.0 are documented in +[docs/adr/](../adr/), [README.md](../../README.md), and [CHANGELOG.md](../../CHANGELOG.md). +Do not treat the Pre-ITE inventory as current product status. + +## Identity + +| Field | Value | +|-------|-------| +| Base commit | `b3018d790e3e7c622b28641c0230a61bb3b78955` | +| Base message | Update README with ASCII art and project details | +| Measurement date | 2026-07-24 | +| Host OS | Windows 10.0.26200 | +| Python | 3.13.11 (local measurement host); package/`requires-python` and CI target **3.9–3.12** | +| Package version | 0.1.0 (Alpha) | + +## Locked pins (sibling repos on measurement host) + +| Layer | Pin | Path / artifact | +|-------|-----|-----------------| +| PCS | `pcs-core` tag `v0.1.0` (`2f0ce059652408bce19bc89ba6660030020d5ea0`) | `C:\Users\mateo\pcs-core` | +| PIP | `post-incident-proofs` commit `3cdfdf09c20f08ad5221d29607b5a9726295ad10` | `C:\Users\mateo\post-incident-proofs` | +| OVK | wheel `1.2.1` | `C:\Users\mateo\ovk-v1.2.1-dist\open_verification_kernel-1.2.1-py3-none-any.whl` | +| Morph | `morphcloud>=0.1.91` (optional live) | PyPI / Morph Cloud | + +## Tool versions (measurement host) + +| Tool | Version | +|------|---------| +| pydantic | 2.12.5 | +| click | 8.2.1 | +| jsonschema | 4.23.0 | +| morphcloud | not installed on measurement host (live Morph remains opt-in) | + +## Baseline commands and results + +```text +python --version +# Python 3.13.11 + +git rev-parse HEAD +# b3018d790e3e7c622b28641c0230a61bb3b78955 + +python -c "import runner; print(runner.__version__)" +# 0.1.0 (after editable install) + +pytest tests/unit/test_import_smoke.py -q +# (introduced in this ITE; must pass offline) +``` + +## Pre-ITE inventory (gaps at base commit — superseded by later ITEs) + +At `b3018d79…` the tree had: + +- Single Click command hard-wired to `MorphCloudClient` in `runner/core.py` +- No provider abstraction, ExecutionProfile, hermeticity, branch-N scheduler, PCS, PIP, or resource accounting +- No unit tests; CI is live Morph smoke only +- `--timeout` / `--http-callback` are config-only stubs +- Sync path reuses dirty Morph branches without reset +- Dead file `runner/core_fixed.py`; unused deps `aiofiles`, `asyncio-mqtt` +- Broken docker-compose reference to missing `test-http.conf` + +These gaps were addressed across MRR-ITE-01–09. Current behavior: hermetic `branch` + offline CI; `--http-callback` fail-closed. + +## Non-claims (baseline) + +This repository at the base commit does **not** claim: + +- Causality, remediation ranking, or environment-semantics generation +- Campaign-level verifier assurance +- Hermetic branch-N isolation +- PCS `RuntimeReceipt` / PIP Morph report emission +- Offline deterministic CI + +Evidence labels `runtime_observed` / `RuntimeChecked` / `ReplayValidated` are forbidden until the corresponding check actually runs (later ITEs). +Current product non-claims: [NON_CLAIMS.md](../../NON_CLAIMS.md). + +## Quality scaffolding introduced here + +- Apache-2.0 `LICENSE` +- `.gitignore` +- Offline CI job (lint/typecheck/unit without Morph) +- `requires-python >=3.9` aligned with README/CI +- Import smoke tests +- Architecture ADR From 647f3ba3edf6b4fd1a3056c05dade775788f5657 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:08 -0700 Subject: [PATCH 03/31] feat: add ExecutionProfile JSON Schema Define the vendored MRR execution profile contract used to pin hermetic replay inputs. --- schemas/mrr/execution_profile.v1.json | 204 ++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 schemas/mrr/execution_profile.v1.json diff --git a/schemas/mrr/execution_profile.v1.json b/schemas/mrr/execution_profile.v1.json new file mode 100644 index 0000000..2b86e53 --- /dev/null +++ b/schemas/mrr/execution_profile.v1.json @@ -0,0 +1,204 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://morph-replay-runner.local/schemas/mrr/execution_profile.v1.json", + "title": "ExecutionProfile", + "description": "Pinned hermetic execution profile. Secrets by reference only.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "profile_id", + "snapshot", + "container_image_digests", + "environment", + "os_runtime", + "dependency_lock_digest", + "network_policy", + "clock_policy", + "random_seed_policy", + "tool_versions", + "budgets", + "secret_refs", + "output_retention", + "source_commit", + "integrity" + ], + "properties": { + "schema_version": { "const": "mrr.ExecutionProfile.v1" }, + "profile_id": { "type": "string", "minLength": 1 }, + "profile_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Optional precomputed digest; recomputed on validate." + }, + "snapshot": { + "type": "object", + "additionalProperties": false, + "required": ["snapshot_id", "snapshot_digest"], + "properties": { + "snapshot_id": { "type": "string", "minLength": 1 }, + "snapshot_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "container_image_digests": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["profile_name", "profile_version"], + "properties": { + "profile_name": { "type": "string", "minLength": 1 }, + "profile_version": { "type": "string", "minLength": 1 } + } + }, + "os_runtime": { + "type": "object", + "additionalProperties": false, + "required": ["os", "runtime"], + "properties": { + "os": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "minLength": 1 } + } + }, + "dependency_lock_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "network_policy": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": [ + "no_network", + "allowlisted_network", + "recorded_response", + "partner_local", + "synthetic_dependency" + ] + }, + "allowlist_hosts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "cassette_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "partner_local_paths": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "synthetic_dependency_digests": { + "type": "array", + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + } + } + }, + "clock_policy": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "fixed_epoch_ms"], + "properties": { + "mode": { "type": "string", "enum": ["frozen", "offset"] }, + "fixed_epoch_ms": { "type": "integer", "minimum": 0 } + } + }, + "random_seed_policy": { + "type": "object", + "additionalProperties": false, + "required": ["seed"], + "properties": { + "seed": { "type": "integer", "minimum": 0 } + } + }, + "tool_versions": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 }, + "minProperties": 1 + }, + "model": { + "type": "object", + "additionalProperties": false, + "properties": { + "endpoint": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "local_checkpoint_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "budgets": { + "type": "object", + "additionalProperties": false, + "required": [ + "cpu_cores", + "memory_mib", + "storage_mib", + "wall_time_ms", + "token_budget", + "api_call_budget" + ], + "properties": { + "cpu_cores": { "type": "integer", "minimum": 1 }, + "memory_mib": { "type": "integer", "minimum": 1 }, + "accelerator_count": { "type": "integer", "minimum": 0 }, + "storage_mib": { "type": "integer", "minimum": 1 }, + "wall_time_ms": { "type": "integer", "minimum": 1 }, + "token_budget": { "type": "integer", "minimum": 0 }, + "api_call_budget": { "type": "integer", "minimum": 0 } + } + }, + "secret_refs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["secret_ref"], + "properties": { + "secret_ref": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_./:-]+$", + "description": "Identifier only; secret values are rejected." + }, + "purpose": { "type": "string" } + } + } + }, + "output_retention": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "retention_days"], + "properties": { + "mode": { "type": "string", "enum": ["ephemeral", "retain"] }, + "retention_days": { "type": "integer", "minimum": 0 } + } + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["canonicalization_version"], + "properties": { + "canonicalization_version": { "const": "v1" }, + "artifact_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + } +} From 5e15c8ac4da0dcd25fae2a162fa1eda8bf0f4d82 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:08 -0700 Subject: [PATCH 04/31] feat: add ExecutionProfile load and validate module Provide typed profile parsing against the schema with valid and invalid fixtures for offline checks. --- fixtures/profile/invalid_missing_pin.json | 55 ++++ fixtures/profile/invalid_schema_version.json | 56 ++++ fixtures/profile/invalid_secret_value.json | 57 ++++ fixtures/profile/valid_profile.json | 61 +++++ runner/profile/__init__.py | 21 ++ runner/profile/schema.py | 258 +++++++++++++++++++ runner/py.typed | 0 7 files changed, 508 insertions(+) create mode 100644 fixtures/profile/invalid_missing_pin.json create mode 100644 fixtures/profile/invalid_schema_version.json create mode 100644 fixtures/profile/invalid_secret_value.json create mode 100644 fixtures/profile/valid_profile.json create mode 100644 runner/profile/__init__.py create mode 100644 runner/profile/schema.py create mode 100644 runner/py.typed diff --git a/fixtures/profile/invalid_missing_pin.json b/fixtures/profile/invalid_missing_pin.json new file mode 100644 index 0000000..c17eaf2 --- /dev/null +++ b/fixtures/profile/invalid_missing_pin.json @@ -0,0 +1,55 @@ +{ + "schema_version": "mrr.ExecutionProfile.v1", + "profile_id": "profile-demo", + "snapshot": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "container_image_digests": [ + "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + ], + "environment": { + "profile_name": "hermetic-demo", + "profile_version": "1.0.0" + }, + "os_runtime": { + "os": "linux", + "runtime": "python3.11" + }, + "network_policy": { + "mode": "no_network" + }, + "clock_policy": { + "mode": "frozen", + "fixed_epoch_ms": 1700000000000 + }, + "random_seed_policy": { + "seed": 42 + }, + "tool_versions": { + "replay-runner": "0.1.0" + }, + "budgets": { + "cpu_cores": 2, + "memory_mib": 1024, + "accelerator_count": 0, + "storage_mib": 2048, + "wall_time_ms": 60000, + "token_budget": 0, + "api_call_budget": 0 + }, + "secret_refs": [ + { + "secret_ref": "vault/morph/api", + "purpose": "optional-morph" + } + ], + "output_retention": { + "mode": "retain", + "retention_days": 30 + }, + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "integrity": { + "canonicalization_version": "v1" + } +} diff --git a/fixtures/profile/invalid_schema_version.json b/fixtures/profile/invalid_schema_version.json new file mode 100644 index 0000000..5816242 --- /dev/null +++ b/fixtures/profile/invalid_schema_version.json @@ -0,0 +1,56 @@ +{ + "schema_version": "mrr.ExecutionProfile.v999", + "profile_id": "profile-demo", + "snapshot": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "container_image_digests": [ + "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + ], + "environment": { + "profile_name": "hermetic-demo", + "profile_version": "1.0.0" + }, + "os_runtime": { + "os": "linux", + "runtime": "python3.11" + }, + "dependency_lock_digest": "sha256:0c030586945fe504b604ecc2e875c38ede400cd5cd73da9730302162e6b02c6f", + "network_policy": { + "mode": "no_network" + }, + "clock_policy": { + "mode": "frozen", + "fixed_epoch_ms": 1700000000000 + }, + "random_seed_policy": { + "seed": 42 + }, + "tool_versions": { + "replay-runner": "0.1.0" + }, + "budgets": { + "cpu_cores": 2, + "memory_mib": 1024, + "accelerator_count": 0, + "storage_mib": 2048, + "wall_time_ms": 60000, + "token_budget": 0, + "api_call_budget": 0 + }, + "secret_refs": [ + { + "secret_ref": "vault/morph/api", + "purpose": "optional-morph" + } + ], + "output_retention": { + "mode": "retain", + "retention_days": 30 + }, + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "integrity": { + "canonicalization_version": "v1" + } +} diff --git a/fixtures/profile/invalid_secret_value.json b/fixtures/profile/invalid_secret_value.json new file mode 100644 index 0000000..7d48481 --- /dev/null +++ b/fixtures/profile/invalid_secret_value.json @@ -0,0 +1,57 @@ +{ + "schema_version": "mrr.ExecutionProfile.v1", + "profile_id": "profile-demo", + "snapshot": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "container_image_digests": [ + "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + ], + "environment": { + "profile_name": "hermetic-demo", + "profile_version": "1.0.0" + }, + "os_runtime": { + "os": "linux", + "runtime": "python3.11" + }, + "dependency_lock_digest": "sha256:0c030586945fe504b604ecc2e875c38ede400cd5cd73da9730302162e6b02c6f", + "network_policy": { + "mode": "no_network" + }, + "clock_policy": { + "mode": "frozen", + "fixed_epoch_ms": 1700000000000 + }, + "random_seed_policy": { + "seed": 42 + }, + "tool_versions": { + "replay-runner": "0.1.0" + }, + "budgets": { + "cpu_cores": 2, + "memory_mib": 1024, + "accelerator_count": 0, + "storage_mib": 2048, + "wall_time_ms": 60000, + "token_budget": 0, + "api_call_budget": 0 + }, + "secret_refs": [ + { + "secret_ref": "vault/morph/api", + "purpose": "optional-morph" + } + ], + "output_retention": { + "mode": "retain", + "retention_days": 30 + }, + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "integrity": { + "canonicalization_version": "v1" + }, + "api_key": "sk-secret-value" +} diff --git a/fixtures/profile/valid_profile.json b/fixtures/profile/valid_profile.json new file mode 100644 index 0000000..7df8c56 --- /dev/null +++ b/fixtures/profile/valid_profile.json @@ -0,0 +1,61 @@ +{ + "budgets": { + "accelerator_count": 0, + "api_call_budget": 0, + "cpu_cores": 2, + "memory_mib": 1024, + "storage_mib": 2048, + "token_budget": 0, + "wall_time_ms": 60000 + }, + "clock_policy": { + "fixed_epoch_ms": 1700000000000, + "mode": "frozen" + }, + "container_image_digests": [ + "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + ], + "dependency_lock_digest": "sha256:0c030586945fe504b604ecc2e875c38ede400cd5cd73da9730302162e6b02c6f", + "environment": { + "profile_name": "hermetic-demo", + "profile_version": "1.0.0" + }, + "integrity": { + "artifact_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "canonicalization_version": "v1" + }, + "network_policy": { + "allowlist_hosts": [], + "mode": "no_network", + "partner_local_paths": [], + "synthetic_dependency_digests": [] + }, + "os_runtime": { + "os": "linux", + "runtime": "python3.11" + }, + "output_retention": { + "mode": "retain", + "retention_days": 30 + }, + "profile_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "profile_id": "profile-demo", + "random_seed_policy": { + "seed": 42 + }, + "schema_version": "mrr.ExecutionProfile.v1", + "secret_refs": [ + { + "purpose": "optional-morph", + "secret_ref": "vault/morph/api" + } + ], + "snapshot": { + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785", + "snapshot_id": "snap-demo" + }, + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "tool_versions": { + "replay-runner": "0.1.0" + } +} diff --git a/runner/profile/__init__.py b/runner/profile/__init__.py new file mode 100644 index 0000000..d480309 --- /dev/null +++ b/runner/profile/__init__.py @@ -0,0 +1,21 @@ +"""ExecutionProfile package.""" + +from runner.profile.schema import ( + SCHEMA_VERSION, + ExecutionProfile, + ProfileValidationError, + load_profile, + profile_digest, + validate_profile, + write_profile_with_digest, +) + +__all__ = [ + "SCHEMA_VERSION", + "ExecutionProfile", + "ProfileValidationError", + "load_profile", + "profile_digest", + "validate_profile", + "write_profile_with_digest", +] diff --git a/runner/profile/schema.py b/runner/profile/schema.py new file mode 100644 index 0000000..d8024e0 --- /dev/null +++ b/runner/profile/schema.py @@ -0,0 +1,258 @@ +"""ExecutionProfile models, validation, and digests.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +from runner.hashing import canonical_hash + +SCHEMA_VERSION = "mrr.ExecutionProfile.v1" +FORBIDDEN_SECRET_KEYS = frozenset( + { + "secret", + "secrets", + "password", + "token", + "api_key", + "apikey", + "private_key", + "secret_value", + "credential", + "credentials", + } +) + + +class ProfileValidationError(ValueError): + """Fail-closed profile validation error.""" + + +class SnapshotPin(BaseModel): + snapshot_id: str = Field(..., min_length=1) + snapshot_digest: str = Field(..., pattern=r"^sha256:[a-f0-9]{64}$") + + +class EnvironmentPin(BaseModel): + profile_name: str = Field(..., min_length=1) + profile_version: str = Field(..., min_length=1) + + +class OsRuntime(BaseModel): + os: str = Field(..., min_length=1) + runtime: str = Field(..., min_length=1) + + +class NetworkPolicy(BaseModel): + mode: Literal[ + "no_network", + "allowlisted_network", + "recorded_response", + "partner_local", + "synthetic_dependency", + ] + allowlist_hosts: list[str] = Field(default_factory=list) + cassette_digest: Optional[str] = Field( + default=None, pattern=r"^sha256:[a-f0-9]{64}$" + ) + partner_local_paths: list[str] = Field(default_factory=list) + synthetic_dependency_digests: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _mode_pins(self) -> NetworkPolicy: + if self.mode == "allowlisted_network" and not self.allowlist_hosts: + raise ProfileValidationError("allowlisted_network requires allowlist_hosts") + if self.mode == "recorded_response" and not self.cassette_digest: + raise ProfileValidationError("recorded_response requires cassette_digest") + if self.mode == "partner_local" and not self.partner_local_paths: + raise ProfileValidationError("partner_local requires partner_local_paths") + if ( + self.mode == "synthetic_dependency" + and not self.synthetic_dependency_digests + ): + raise ProfileValidationError( + "synthetic_dependency requires synthetic_dependency_digests" + ) + return self + + +class ClockPolicy(BaseModel): + mode: Literal["frozen", "offset"] + fixed_epoch_ms: int = Field(..., ge=0) + + +class RandomSeedPolicy(BaseModel): + seed: int = Field(..., ge=0) + + +class ModelPin(BaseModel): + endpoint: Optional[str] = None + version: Optional[str] = None + local_checkpoint_digest: Optional[str] = Field( + default=None, pattern=r"^sha256:[a-f0-9]{64}$" + ) + + @model_validator(mode="after") + def _require_pin(self) -> ModelPin: + if not any([self.endpoint, self.version, self.local_checkpoint_digest]): + raise ProfileValidationError( + "model requires endpoint/version or local_checkpoint_digest" + ) + return self + + +class Budgets(BaseModel): + cpu_cores: int = Field(..., ge=1) + memory_mib: int = Field(..., ge=1) + accelerator_count: int = Field(default=0, ge=0) + storage_mib: int = Field(..., ge=1) + wall_time_ms: int = Field(..., ge=1) + token_budget: int = Field(..., ge=0) + api_call_budget: int = Field(..., ge=0) + + +class SecretRef(BaseModel): + secret_ref: str = Field(..., min_length=1, pattern=r"^[A-Za-z0-9_./:-]+$") + purpose: Optional[str] = None + + @field_validator("secret_ref") + @classmethod + def _no_embedded_secret(cls, value: str) -> str: + lowered = value.lower() + if any(token in lowered for token in ("=", "bearer ", "sk-")): + raise ProfileValidationError( + "secret_ref must be an identifier only; values rejected" + ) + return value + + +class OutputRetention(BaseModel): + mode: Literal["ephemeral", "retain"] + retention_days: int = Field(..., ge=0) + + +class IntegrityEnvelope(BaseModel): + canonicalization_version: Literal["v1"] + artifact_digest: Optional[str] = Field( + default=None, pattern=r"^sha256:[a-f0-9]{64}$" + ) + + +class ExecutionProfile(BaseModel): + schema_version: Literal["mrr.ExecutionProfile.v1"] = "mrr.ExecutionProfile.v1" + profile_id: str = Field(..., min_length=1) + profile_digest: Optional[str] = Field( + default=None, pattern=r"^sha256:[a-f0-9]{64}$" + ) + snapshot: SnapshotPin + container_image_digests: list[str] = Field(..., min_length=1) + environment: EnvironmentPin + os_runtime: OsRuntime + dependency_lock_digest: str = Field(..., pattern=r"^sha256:[a-f0-9]{64}$") + network_policy: NetworkPolicy + clock_policy: ClockPolicy + random_seed_policy: RandomSeedPolicy + tool_versions: dict[str, str] = Field(..., min_length=1) + model: Optional[ModelPin] = None + budgets: Budgets + secret_refs: list[SecretRef] = Field(default_factory=list) + output_retention: OutputRetention + source_commit: str = Field(..., pattern=r"^[0-9a-f]{40}$") + integrity: IntegrityEnvelope + + @field_validator("container_image_digests") + @classmethod + def _digest_items(cls, values: list[str]) -> list[str]: + for item in values: + if not item.startswith("sha256:") or len(item) != 71: + raise ProfileValidationError(f"invalid image digest: {item}") + return values + + @field_validator("tool_versions") + @classmethod + def _tool_versions_nonempty(cls, values: dict[str, str]) -> dict[str, str]: + if not values: + raise ProfileValidationError("tool_versions must be non-empty") + for key, value in values.items(): + if not value: + raise ProfileValidationError(f"tool_versions[{key}] empty") + return values + + +def _reject_secret_values(obj: Any, path: str = "$") -> None: + if isinstance(obj, dict): + for key, value in obj.items(): + key_l = str(key).lower() + if key_l in FORBIDDEN_SECRET_KEYS: + raise ProfileValidationError( + f"secret values forbidden at {path}.{key}; use secret_refs only" + ) + if key_l in {"secret_ref", "secret_refs"}: + _reject_secret_values(value, f"{path}.{key}") + continue + if isinstance(value, str) and key_l.endswith( + ("_secret", "_token", "_password") + ): + raise ProfileValidationError(f"secret values forbidden at {path}.{key}") + _reject_secret_values(value, f"{path}.{key}") + elif isinstance(obj, list): + for index, item in enumerate(obj): + _reject_secret_values(item, f"{path}[{index}]") + + +def profile_digest(profile: ExecutionProfile | dict[str, Any]) -> str: + """SHA-256 over Canonical JSON v1 with integrity/digest fields stripped.""" + if isinstance(profile, ExecutionProfile): + data = profile.model_dump(mode="json", exclude_none=True) + else: + data = dict(profile) + return canonical_hash( + data, + enforce_number_policy=True, + extra_excluded=frozenset({"profile_digest", "integrity"}), + ) + + +def load_profile(path: Path | str) -> ExecutionProfile: + raw_path = Path(path) + raw = json.loads(raw_path.read_text(encoding="utf-8")) + return validate_profile(raw) + + +def validate_profile(raw: dict[str, Any]) -> ExecutionProfile: + if not isinstance(raw, dict): + raise ProfileValidationError("profile must be a JSON object") + version = raw.get("schema_version") + if version != SCHEMA_VERSION: + raise ProfileValidationError( + f"unknown schema_version {version!r}; expected {SCHEMA_VERSION}" + ) + _reject_secret_values(raw) + try: + profile = ExecutionProfile.model_validate(raw) + except Exception as exc: + raise ProfileValidationError(str(exc)) from exc + + digest = profile_digest(profile) + if profile.profile_digest is not None and profile.profile_digest != digest: + raise ProfileValidationError( + f"profile_digest drift: declared {profile.profile_digest}, computed {digest}" + ) + return profile.model_copy(update={"profile_digest": digest}) + + +def write_profile_with_digest(profile: ExecutionProfile, path: Path | str) -> str: + digest = profile_digest(profile) + payload = profile.model_dump(mode="json", exclude_none=True) + payload["profile_digest"] = digest + payload["integrity"] = { + "canonicalization_version": "v1", + "artifact_digest": digest, + } + Path(path).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return digest diff --git a/runner/py.typed b/runner/py.typed new file mode 100644 index 0000000..e69de29 From 52d42313dce346e0dd57993c6b80d67dc09bcf69 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:23 -0700 Subject: [PATCH 05/31] feat: add provider abstraction and local fake backend Introduce a provider interface so replay orchestration can swap Morph for a deterministic local fake. --- runner/providers/__init__.py | 35 +++ runner/providers/base.py | 118 +++++++++ runner/providers/local_fake.py | 471 +++++++++++++++++++++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 runner/providers/__init__.py create mode 100644 runner/providers/base.py create mode 100644 runner/providers/local_fake.py diff --git a/runner/providers/__init__.py b/runner/providers/__init__.py new file mode 100644 index 0000000..9428c1d --- /dev/null +++ b/runner/providers/__init__.py @@ -0,0 +1,35 @@ +"""Provider factory and exports.""" + +from __future__ import annotations + +from typing import Any, Optional + +from runner.hermeticity.modes import HermeticityPolicy +from runner.providers.base import ExecutionProvider, ProviderError +from runner.providers.local_fake import LocalFakeProvider +from runner.providers.morph import MorphCloudProvider + + +def get_provider( + name: str, + *, + root: Optional[str] = None, + hermeticity: Optional[HermeticityPolicy] = None, + **kwargs: Any, +) -> ExecutionProvider: + if name in {"local-fake", "local_fake", "fake"}: + if root is None: + raise ProviderError("local-fake provider requires root=") + return LocalFakeProvider(root, hermeticity=hermeticity, **kwargs) + if name in {"morph", "morphcloud"}: + return MorphCloudProvider(hermeticity=hermeticity, **kwargs) + raise ProviderError(f"unknown provider: {name}") + + +__all__ = [ + "ExecutionProvider", + "LocalFakeProvider", + "MorphCloudProvider", + "ProviderError", + "get_provider", +] diff --git a/runner/providers/base.py b/runner/providers/base.py new file mode 100644 index 0000000..4914d97 --- /dev/null +++ b/runner/providers/base.py @@ -0,0 +1,118 @@ +"""Provider protocol types and ExecutionProvider Protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Literal, Optional, Protocol, runtime_checkable + +from runner.hermeticity.modes import HermeticityMode + + +class ProviderError(RuntimeError): + """Fail-closed provider error.""" + + +@dataclass(frozen=True) +class SnapshotRef: + snapshot_id: str + snapshot_digest: Optional[str] = None + + +@dataclass(frozen=True) +class SnapshotHandle: + snapshot_id: str + snapshot_digest: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class BranchHandle: + branch_id: str + snapshot_id: str + snapshot_digest: str + root_path: str + provider_ref: str + + +@dataclass(frozen=True) +class ExecRequest: + command: list[str] + timeout_ms: int + env: dict[str, str] = field(default_factory=dict) + workdir: Optional[str] = None + intervention_paths: list[str] = field(default_factory=list) + + +class ExecStatus(str, Enum): + PASSED = "PASSED" + FAILED = "FAILED" + TIMEOUT = "TIMEOUT" + CANCELLED = "CANCELLED" + ERROR = "ERROR" + + +@dataclass +class ExecResult: + status: ExecStatus + exit_code: int + stdout: str + stderr: str + wall_time_ms: int + terminal_state: dict[str, Any] = field(default_factory=dict) + artifacts: dict[str, str] = field(default_factory=dict) + + +MetricAvailability = Literal["observed", "unavailable", "not_applicable"] + + +@dataclass(frozen=True) +class MetricValue: + value: Optional[float | int | str] + unit: str + availability: MetricAvailability + source: Literal["provider", "runner", "guest"] + + +@dataclass +class ResourceSample: + metrics: dict[str, MetricValue] + + +@dataclass +class ArtifactBag: + files: dict[str, bytes] + digests: dict[str, str] + + +@dataclass +class TeardownReceipt: + branch_id: str + cleaned: bool + residual_paths: list[str] = field(default_factory=list) + secrets_scrubbed: bool = True + details: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class ExecutionProvider(Protocol): + """Backend-neutral execution provider.""" + + name: str + + def supports(self, mode: HermeticityMode) -> bool: + """Return True if the provider can enforce the hermeticity mode.""" + + def lookup_snapshot(self, ref: SnapshotRef) -> SnapshotHandle: ... + + def clone(self, snapshot: SnapshotHandle, *, branch_id: str) -> BranchHandle: ... + + def execute(self, branch: BranchHandle, request: ExecRequest) -> ExecResult: ... + + def measure(self, branch: BranchHandle) -> ResourceSample: ... + + def extract_artifacts( + self, branch: BranchHandle, paths: list[str] + ) -> ArtifactBag: ... + + def teardown(self, branch: BranchHandle) -> TeardownReceipt: ... diff --git a/runner/providers/local_fake.py b/runner/providers/local_fake.py new file mode 100644 index 0000000..92432ec --- /dev/null +++ b/runner/providers/local_fake.py @@ -0,0 +1,471 @@ +"""Deterministic LocalFakeProvider for offline hermetic branch-N execution.""" + +from __future__ import annotations + +import json +import shutil +import threading +import time +from pathlib import Path +from typing import Any, Optional + +from runner.hashing import sha256_bytes, sha256_file, sha256_text +from runner.hermeticity.modes import ( + HermeticityError, + HermeticityMode, + HermeticityPolicy, + host_allowed, + path_within_partner, +) +from runner.providers.base import ( + ArtifactBag, + BranchHandle, + ExecRequest, + ExecResult, + ExecStatus, + ExecutionProvider, + MetricValue, + ProviderError, + ResourceSample, + SnapshotHandle, + SnapshotRef, + TeardownReceipt, +) + + +class LocalFakeProvider: + """Filesystem sandbox provider with deterministic clocks/seeds and isolation. + + Supports >=16 concurrent branches. No outbound network unless hermeticity + mode allows recorded fixtures or allowlisted hosts (simulated only). + """ + + name = "local-fake" + + def __init__( + self, + root: Path | str, + *, + hermeticity: Optional[HermeticityPolicy] = None, + fixed_epoch_ms: int = 1_700_000_000_000, + seed: int = 0, + accelerator_configured: bool = False, + ) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + self.snapshots_dir = self.root / "snapshots" + self.branches_dir = self.root / "branches" + self.snapshots_dir.mkdir(exist_ok=True) + self.branches_dir.mkdir(exist_ok=True) + self.hermeticity = hermeticity + self.fixed_epoch_ms = fixed_epoch_ms + self.seed = seed + self.accelerator_configured = accelerator_configured + self._lock = threading.RLock() + self._branch_state: dict[str, dict[str, Any]] = {} + self._network_attempts: list[dict[str, Any]] = [] + + def register_snapshot( + self, snapshot_id: str, content: bytes, *, digest: Optional[str] = None + ) -> SnapshotHandle: + snap_dir = self.snapshots_dir / snapshot_id + snap_dir.mkdir(parents=True, exist_ok=True) + payload = snap_dir / "root.bin" + payload.write_bytes(content) + computed = sha256_bytes(content) + if digest is not None and digest != computed: + raise ProviderError( + f"snapshot digest mismatch for {snapshot_id}: {digest} != {computed}" + ) + meta = { + "snapshot_id": snapshot_id, + "snapshot_digest": computed, + "size": len(content), + } + (snap_dir / "meta.json").write_text( + json.dumps(meta, sort_keys=True) + "\n", encoding="utf-8" + ) + return SnapshotHandle( + snapshot_id=snapshot_id, snapshot_digest=computed, metadata=meta + ) + + def supports(self, mode: HermeticityMode) -> bool: + return mode in { + HermeticityMode.NO_NETWORK, + HermeticityMode.ALLOWLISTED_NETWORK, + HermeticityMode.RECORDED_RESPONSE, + HermeticityMode.PARTNER_LOCAL, + HermeticityMode.SYNTHETIC_DEPENDENCY, + } + + def lookup_snapshot(self, ref: SnapshotRef) -> SnapshotHandle: + snap_dir = self.snapshots_dir / ref.snapshot_id + meta_path = snap_dir / "meta.json" + payload = snap_dir / "root.bin" + if not meta_path.is_file() or not payload.is_file(): + raise ProviderError(f"snapshot not found: {ref.snapshot_id}") + meta = json.loads(meta_path.read_text(encoding="utf-8")) + digest = meta["snapshot_digest"] + if ref.snapshot_digest is not None and ref.snapshot_digest != digest: + raise ProviderError( + f"snapshot digest mismatch: profile requires {ref.snapshot_digest}, " + f"provider has {digest}" + ) + recomputed = sha256_file(str(payload)) + if recomputed != digest: + raise ProviderError(f"snapshot content drift for {ref.snapshot_id}") + return SnapshotHandle( + snapshot_id=ref.snapshot_id, snapshot_digest=digest, metadata=meta + ) + + def clone(self, snapshot: SnapshotHandle, *, branch_id: str) -> BranchHandle: + with self._lock: + src = self.snapshots_dir / snapshot.snapshot_id / "root.bin" + if not src.is_file(): + raise ProviderError(f"missing snapshot bytes: {snapshot.snapshot_id}") + branch_root = self.branches_dir / branch_id + if branch_root.exists(): + raise ProviderError(f"branch already exists: {branch_id}") + branch_root.mkdir(parents=True) + dest = branch_root / "root.bin" + shutil.copyfile(src, dest) + fs = branch_root / "fs" + fs.mkdir() + (fs / "SNAPSHOT_ID").write_text(snapshot.snapshot_id, encoding="utf-8") + (fs / "SNAPSHOT_DIGEST").write_text( + snapshot.snapshot_digest, encoding="utf-8" + ) + (fs / "BRANCH_ID").write_text(branch_id, encoding="utf-8") + (fs / "SEED").write_text(str(self.seed), encoding="utf-8") + (fs / "CLOCK_EPOCH_MS").write_text( + str(self.fixed_epoch_ms), encoding="utf-8" + ) + self._branch_state[branch_id] = { + "created_at_ms": self.fixed_epoch_ms, + "wall_ms": 0, + "cpu_ms": 0, + "storage_bytes": dest.stat().st_size, + "tool_calls": 0, + "api_calls": 0, + "network_bytes": 0, + "retries": 0, + "failed_processes": 0, + "verifier_invocations": 0, + "cancelled": False, + } + return BranchHandle( + branch_id=branch_id, + snapshot_id=snapshot.snapshot_id, + snapshot_digest=snapshot.snapshot_digest, + root_path=str(branch_root), + provider_ref=f"local-fake:{branch_id}", + ) + + def _enforce_pre_exec(self, request: ExecRequest) -> None: + policy = self.hermeticity + if policy is None: + return + for key, value in request.env.items(): + if key.startswith("HTTP") or key.endswith("_URL"): + host = value + if "://" in value: + host = value.split("://", 1)[1].split("/", 1)[0] + allowed = host_allowed(host, policy) + self._network_attempts.append( + {"host": host, "allowed": allowed, "mode": policy.mode.value} + ) + if not allowed and policy.mode in { + HermeticityMode.NO_NETWORK, + HermeticityMode.ALLOWLISTED_NETWORK, + HermeticityMode.RECORDED_RESPONSE, + HermeticityMode.PARTNER_LOCAL, + HermeticityMode.SYNTHETIC_DEPENDENCY, + }: + raise HermeticityError( + f"network denied by hermeticity mode {policy.mode.value}: {host}" + ) + if policy.mode == HermeticityMode.PARTNER_LOCAL: + for path in request.intervention_paths: + if not path_within_partner(path, policy): + raise HermeticityError( + f"path outside partner_local boundary: {path}" + ) + if policy.mode == HermeticityMode.SYNTHETIC_DEPENDENCY: + # Only declared synthetic digests may be referenced via env SYNTHETIC_DEP + declared = set(policy.synthetic_dependency_digests) + synth = request.env.get("SYNTHETIC_DEP_DIGEST") + if synth and synth not in declared: + raise HermeticityError( + f"undeclared synthetic dependency digest: {synth}" + ) + if policy.mode == HermeticityMode.RECORDED_RESPONSE: + cassette = request.env.get("CASSETTE_DIGEST") + if cassette and cassette != policy.cassette_digest: + raise HermeticityError("cassette digest mismatch") + + def execute(self, branch: BranchHandle, request: ExecRequest) -> ExecResult: + with self._lock: + state = self._branch_state.get(branch.branch_id) + if state is None: + raise ProviderError(f"unknown branch: {branch.branch_id}") + if state.get("cancelled"): + return ExecResult( + status=ExecStatus.CANCELLED, + exit_code=130, + stdout="", + stderr="cancelled", + wall_time_ms=0, + terminal_state={"reason": "CANCELLED"}, + ) + + start = time.perf_counter() + try: + self._enforce_pre_exec(request) + except HermeticityError as exc: + state["failed_processes"] += 1 + return ExecResult( + status=ExecStatus.FAILED, + exit_code=2, + stdout="", + stderr=str(exc), + wall_time_ms=0, + terminal_state={"reason": "HERMETICITY_DENIED", "error": str(exc)}, + ) + + branch_root = Path(branch.root_path) + fs = branch_root / "fs" + for src in request.intervention_paths: + src_path = Path(src) + if not src_path.is_file(): + state["failed_processes"] += 1 + return ExecResult( + status=ExecStatus.ERROR, + exit_code=1, + stdout="", + stderr=f"intervention missing: {src}", + wall_time_ms=0, + terminal_state={"reason": "INTERVENTION_MISSING"}, + ) + dest = fs / "interventions" / src_path.name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src_path, dest) + + # Deterministic simulated work proportional to seed + command length + work_units = self.seed + sum(len(part) for part in request.command) + simulated_ms = min(request.timeout_ms, 1 + (work_units % 50)) + if request.timeout_ms <= 0: + return ExecResult( + status=ExecStatus.TIMEOUT, + exit_code=124, + stdout="", + stderr="timeout", + wall_time_ms=0, + terminal_state={"reason": "TIMEOUT"}, + ) + if simulated_ms >= request.timeout_ms and request.command[:1] == [ + "__timeout__" + ]: + state["failed_processes"] += 1 + return ExecResult( + status=ExecStatus.TIMEOUT, + exit_code=124, + stdout="", + stderr="timeout", + wall_time_ms=request.timeout_ms, + terminal_state={"reason": "TIMEOUT"}, + ) + + result_path = fs / "result.json" + terminal = { + "branch_id": branch.branch_id, + "snapshot_digest": branch.snapshot_digest, + "command": list(request.command), + "seed": self.seed, + "clock_epoch_ms": self.fixed_epoch_ms, + "status": "PASSED", + } + if request.command[:1] == ["__fail__"]: + terminal["status"] = "FAILED" + state["failed_processes"] += 1 + status = ExecStatus.FAILED + exit_code = 1 + stderr = "forced failure" + else: + status = ExecStatus.PASSED + exit_code = 0 + stderr = "" + + result_path.write_text( + json.dumps(terminal, sort_keys=True) + "\n", encoding="utf-8" + ) + log_path = fs / "stdout.log" + log_path.write_text( + f"cmd={' '.join(request.command)}\nstatus={terminal['status']}\n", + encoding="utf-8", + ) + elapsed_ms = int((time.perf_counter() - start) * 1000) or simulated_ms + state["wall_ms"] += elapsed_ms + state["cpu_ms"] += max(1, elapsed_ms // 2) + state["tool_calls"] += 1 + state["storage_bytes"] = sum( + p.stat().st_size for p in branch_root.rglob("*") if p.is_file() + ) + return ExecResult( + status=status, + exit_code=exit_code, + stdout=log_path.read_text(encoding="utf-8"), + stderr=stderr, + wall_time_ms=elapsed_ms, + terminal_state=terminal, + artifacts={ + "result.json": str(result_path), + "stdout.log": str(log_path), + }, + ) + + def measure(self, branch: BranchHandle) -> ResourceSample: + state = self._branch_state.get(branch.branch_id) + if state is None: + raise ProviderError(f"unknown branch: {branch.branch_id}") + accel: MetricValue + if self.accelerator_configured: + accel = MetricValue( + value=0, unit="count", availability="observed", source="provider" + ) + else: + accel = MetricValue( + value=None, + unit="count", + availability="unavailable", + source="provider", + ) + return ResourceSample( + metrics={ + "wall_time_ms": MetricValue( + value=state["wall_ms"], + unit="ms", + availability="observed", + source="provider", + ), + "cpu_time_ms": MetricValue( + value=state["cpu_ms"], + unit="ms", + availability="observed", + source="provider", + ), + "memory_mib": MetricValue( + value=64, + unit="MiB", + availability="observed", + source="provider", + ), + "accelerator_alloc": accel, + "accelerator_use": accel, + "model_tokens": MetricValue( + value=0, + unit="tokens", + availability="observed", + source="provider", + ), + "tool_calls": MetricValue( + value=state["tool_calls"], + unit="count", + availability="observed", + source="provider", + ), + "external_api_calls": MetricValue( + value=state["api_calls"], + unit="count", + availability="observed", + source="provider", + ), + "network_bytes": MetricValue( + value=state["network_bytes"], + unit="bytes", + availability="observed", + source="provider", + ), + "storage_bytes": MetricValue( + value=state["storage_bytes"], + unit="bytes", + availability="observed", + source="provider", + ), + "retries": MetricValue( + value=state["retries"], + unit="count", + availability="observed", + source="provider", + ), + "failed_processes": MetricValue( + value=state["failed_processes"], + unit="count", + availability="observed", + source="provider", + ), + "verifier_invocations": MetricValue( + value=state["verifier_invocations"], + unit="count", + availability="observed", + source="provider", + ), + } + ) + + def extract_artifacts(self, branch: BranchHandle, paths: list[str]) -> ArtifactBag: + branch_root = Path(branch.root_path) + files: dict[str, bytes] = {} + digests: dict[str, str] = {} + for rel in paths: + path = branch_root / rel + if not path.is_file(): + raise ProviderError(f"artifact missing: {rel}") + data = path.read_bytes() + files[rel] = data + digests[rel] = sha256_bytes(data) + return ArtifactBag(files=files, digests=digests) + + def teardown(self, branch: BranchHandle) -> TeardownReceipt: + with self._lock: + branch_root = Path(branch.root_path) + residual: list[str] = [] + cleaned = False + if branch_root.exists(): + shutil.rmtree(branch_root) + cleaned = not branch_root.exists() + if not cleaned: + residual.append(str(branch_root)) + self._branch_state.pop(branch.branch_id, None) + return TeardownReceipt( + branch_id=branch.branch_id, + cleaned=cleaned, + residual_paths=residual, + secrets_scrubbed=True, + details={"provider": self.name}, + ) + + def cancel(self, branch_id: str) -> None: + with self._lock: + state = self._branch_state.get(branch_id) + if state is not None: + state["cancelled"] = True + + def network_attempt_log(self) -> list[dict[str, Any]]: + return list(self._network_attempts) + + def enforce_capability_or_fail(self, mode: HermeticityMode) -> None: + if not self.supports(mode): + raise HermeticityError( + f"provider {self.name} does not support hermeticity mode {mode.value}" + ) + + +def ensure_protocol(provider: LocalFakeProvider) -> ExecutionProvider: + """Type helper asserting LocalFakeProvider satisfies ExecutionProvider.""" + return provider + + +def branch_isolation_marker(branch: BranchHandle) -> str: + return sha256_text( + f"{branch.branch_id}:{branch.snapshot_digest}:{branch.root_path}" + ) From e710aab06b7aec89a50b4dff73b94f6719a166a5 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:23 -0700 Subject: [PATCH 06/31] feat: add Morph Cloud provider adapter Wire the Morph SDK-backed adapter behind the shared provider interface for real remote replay runs. --- runner/providers/morph.py | 257 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 runner/providers/morph.py diff --git a/runner/providers/morph.py b/runner/providers/morph.py new file mode 100644 index 0000000..a2efae2 --- /dev/null +++ b/runner/providers/morph.py @@ -0,0 +1,257 @@ +"""Morph Cloud execution provider adapter.""" + +from __future__ import annotations + +import os +import time +from typing import Any, Optional + +from runner.hermeticity.modes import ( + HermeticityError, + HermeticityMode, + HermeticityPolicy, +) +from runner.providers.base import ( + ArtifactBag, + BranchHandle, + ExecRequest, + ExecResult, + ExecStatus, + MetricValue, + ProviderError, + ResourceSample, + SnapshotHandle, + SnapshotRef, + TeardownReceipt, +) + + +class MorphCloudProvider: + """Morph Cloud adapter. Live cloud only; not used in default offline CI.""" + + name = "morph" + + SUPPORTED_METRICS = frozenset( + { + "wall_time_ms", + "tool_calls", + "failed_processes", + "retries", + } + ) + UNSUPPORTED_METRICS = frozenset( + { + "cpu_time_ms", + "memory_mib", + "accelerator_alloc", + "accelerator_use", + "model_tokens", + "external_api_calls", + "network_bytes", + "storage_bytes", + "verifier_invocations", + } + ) + + def __init__( + self, + *, + api_key: Optional[str] = None, + hermeticity: Optional[HermeticityPolicy] = None, + require_snapshot_digest: bool = True, + client: Any = None, + ) -> None: + self.api_key = api_key or os.environ.get("MORPH_API_KEY") + if not self.api_key and client is None: + raise ProviderError("MORPH_API_KEY required for MorphCloudProvider") + self.hermeticity = hermeticity + self.require_snapshot_digest = require_snapshot_digest + self._client = client + self._instances: dict[str, Any] = {} + self._base_instance: Any = None + + def _get_client(self) -> Any: + if self._client is not None: + return self._client + try: + from morphcloud.api import MorphCloudClient + except ImportError as exc: + raise ProviderError( + "morphcloud package is required for MorphCloudProvider" + ) from exc + self._client = MorphCloudClient() + return self._client + + def supports(self, mode: HermeticityMode) -> bool: + return mode == HermeticityMode.NO_NETWORK + + def lookup_snapshot(self, ref: SnapshotRef) -> SnapshotHandle: + client = self._get_client() + try: + snap = client.snapshots.get(ref.snapshot_id) + except Exception as exc: + raise ProviderError(f"Morph snapshot lookup failed: {exc}") from exc + digest = getattr(snap, "digest", None) or getattr(snap, "sha256", None) + if digest is None: + if ref.snapshot_digest is None: + raise ProviderError( + "Morph snapshot has no digest and snapshot_ref.snapshot_digest " + "is unset; refusing fabricated zero-digest (fail-closed)" + ) + if self.require_snapshot_digest: + raise ProviderError( + "profile requires snapshot digest but Morph did not return one" + ) + # Pin from the caller-supplied ref only when Morph omits digest and + # require_snapshot_digest is False (explicit opt-out of Morph verify). + digest = ref.snapshot_digest + if not str(digest).startswith("sha256:"): + digest = f"sha256:{digest}" + if ref.snapshot_digest is not None and digest != ref.snapshot_digest: + raise ProviderError( + f"Morph snapshot digest mismatch: {digest} != {ref.snapshot_digest}" + ) + return SnapshotHandle( + snapshot_id=getattr(snap, "id", ref.snapshot_id), + snapshot_digest=str(digest), + metadata={"provider": "morph"}, + ) + + def clone(self, snapshot: SnapshotHandle, *, branch_id: str) -> BranchHandle: + client = self._get_client() + if self._base_instance is None: + try: + base = client.instances.start(snapshot_id=snapshot.snapshot_id) + base.wait_until_ready() + self._base_instance = base + except Exception as exc: + raise ProviderError(f"Morph base instance start failed: {exc}") from exc + try: + branches = self._base_instance.branch(count=1) + instance = branches[0] + except Exception as exc: + raise ProviderError(f"Morph branch failed: {exc}") from exc + self._instances[branch_id] = instance + return BranchHandle( + branch_id=branch_id, + snapshot_id=snapshot.snapshot_id, + snapshot_digest=snapshot.snapshot_digest, + root_path=f"morph://{getattr(instance, 'id', branch_id)}", + provider_ref=str(getattr(instance, "id", branch_id)), + ) + + def execute(self, branch: BranchHandle, request: ExecRequest) -> ExecResult: + if self.hermeticity is not None and not self.supports(self.hermeticity.mode): + raise HermeticityError( + f"Morph cannot enforce hermeticity mode {self.hermeticity.mode.value}" + ) + instance = self._instances.get(branch.branch_id) + if instance is None: + raise ProviderError(f"unknown Morph branch: {branch.branch_id}") + start = time.time() + remote = f"/tmp/replay_{branch.branch_id}.zip" + try: + for path in request.intervention_paths: + instance.copy(path, remote) + cmd = " ".join(request.command) + with instance.ssh() as ssh: + result = ssh.run(cmd) + wall_ms = int((time.time() - start) * 1000) + timed_out = bool(getattr(result, "timed_out", False)) + exit_code = int(getattr(result, "exit_code", 1)) + if timed_out or wall_ms > request.timeout_ms: + status = ExecStatus.TIMEOUT + elif exit_code == 0: + status = ExecStatus.PASSED + else: + status = ExecStatus.FAILED + return ExecResult( + status=status, + exit_code=exit_code, + stdout=str(getattr(result, "stdout", "")), + stderr=str(getattr(result, "stderr", "")), + wall_time_ms=wall_ms, + terminal_state={ + "reason": status.value, + "instance_id": branch.provider_ref, + }, + ) + except Exception as exc: + return ExecResult( + status=ExecStatus.ERROR, + exit_code=1, + stdout="", + stderr=str(exc), + wall_time_ms=int((time.time() - start) * 1000), + terminal_state={"reason": "ERROR", "error": str(exc)}, + ) + + def measure(self, branch: BranchHandle) -> ResourceSample: + _ = branch + metrics: dict[str, MetricValue] = {} + for name in sorted(self.SUPPORTED_METRICS | self.UNSUPPORTED_METRICS): + if name in self.SUPPORTED_METRICS: + metrics[name] = MetricValue( + value=0, + unit="ms" if name.endswith("_ms") else "count", + availability="observed", + source="provider", + ) + else: + metrics[name] = MetricValue( + value=None, + unit="count", + availability="unavailable", + source="provider", + ) + metrics["wall_time_ms"] = MetricValue( + value=None, + unit="ms", + availability="unavailable", + source="provider", + ) + return ResourceSample(metrics=metrics) + + def extract_artifacts(self, branch: BranchHandle, paths: list[str]) -> ArtifactBag: + instance = self._instances.get(branch.branch_id) + if instance is None: + raise ProviderError(f"unknown Morph branch: {branch.branch_id}") + files: dict[str, bytes] = {} + digests: dict[str, str] = {} + from runner.hashing import sha256_bytes + + for remote in paths: + local = f"/tmp/mrr_extract_{branch.branch_id}_{os.path.basename(remote)}" + try: + instance.copy(remote, local) + with open(local, "rb") as handle: + data = handle.read() + except Exception as exc: + raise ProviderError(f"extract failed for {remote}: {exc}") from exc + files[remote] = data + digests[remote] = sha256_bytes(data) + return ArtifactBag(files=files, digests=digests) + + def teardown(self, branch: BranchHandle) -> TeardownReceipt: + instance = self._instances.pop(branch.branch_id, None) + cleaned = True + residual: list[str] = [] + if instance is not None: + try: + instance.stop() + except Exception: + cleaned = False + residual.append(branch.provider_ref) + if not self._instances and self._base_instance is not None: + try: + self._base_instance.stop() + except Exception: + cleaned = False + self._base_instance = None + return TeardownReceipt( + branch_id=branch.branch_id, + cleaned=cleaned, + residual_paths=residual, + secrets_scrubbed=True, + details={"provider": self.name}, + ) From fcb92ce57bca56405c044fde68175cab01f5161c Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:24 -0700 Subject: [PATCH 07/31] feat: add hermeticity mode enforcement helpers Encode offline and isolation mode checks so runners can refuse non-hermetic configurations early. --- runner/hermeticity/__init__.py | 23 +++++++ runner/hermeticity/modes.py | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 runner/hermeticity/__init__.py create mode 100644 runner/hermeticity/modes.py diff --git a/runner/hermeticity/__init__.py b/runner/hermeticity/__init__.py new file mode 100644 index 0000000..0ad5378 --- /dev/null +++ b/runner/hermeticity/__init__.py @@ -0,0 +1,23 @@ +"""Hermeticity package.""" + +from runner.hermeticity.modes import ( + EnforcementStatus, + HermeticityError, + HermeticityEvidence, + HermeticityMode, + HermeticityPolicy, + host_allowed, + path_within_partner, + policy_from_profile_network, +) + +__all__ = [ + "EnforcementStatus", + "HermeticityError", + "HermeticityEvidence", + "HermeticityMode", + "HermeticityPolicy", + "host_allowed", + "path_within_partner", + "policy_from_profile_network", +] diff --git a/runner/hermeticity/modes.py b/runner/hermeticity/modes.py new file mode 100644 index 0000000..d9f6af1 --- /dev/null +++ b/runner/hermeticity/modes.py @@ -0,0 +1,116 @@ +"""Hermeticity modes, enforcement, and evidence.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Literal, Optional + +from runner.hashing import canonical_hash, sha256_text + + +class HermeticityMode(str, Enum): + NO_NETWORK = "no_network" + ALLOWLISTED_NETWORK = "allowlisted_network" + RECORDED_RESPONSE = "recorded_response" + PARTNER_LOCAL = "partner_local" + SYNTHETIC_DEPENDENCY = "synthetic_dependency" + + +class EnforcementStatus(str, Enum): + ENFORCED = "enforced" + UNSUPPORTED = "unsupported" + DENIED = "denied" + + +@dataclass(frozen=True) +class HermeticityPolicy: + mode: HermeticityMode + allowlist_hosts: tuple[str, ...] = () + cassette_digest: Optional[str] = None + partner_local_paths: tuple[str, ...] = () + synthetic_dependency_digests: tuple[str, ...] = () + + def policy_digest(self) -> str: + payload = { + "mode": self.mode.value, + "allowlist_hosts": list(self.allowlist_hosts), + "cassette_digest": self.cassette_digest, + "partner_local_paths": list(self.partner_local_paths), + "synthetic_dependency_digests": list(self.synthetic_dependency_digests), + } + return canonical_hash(payload, enforce_number_policy=True) + + +@dataclass +class HermeticityEvidence: + mode: HermeticityMode + status: EnforcementStatus + policy_digest: str + probe_result: dict[str, Any] = field(default_factory=dict) + message: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "mode": self.mode.value, + "status": self.status.value, + "policy_digest": self.policy_digest, + "probe_result": self.probe_result, + "message": self.message, + } + + +class HermeticityError(RuntimeError): + """Fail-closed hermeticity error before or during execution.""" + + +def policy_from_profile_network(network: Any) -> HermeticityPolicy: + mode = HermeticityMode(network.mode) + return HermeticityPolicy( + mode=mode, + allowlist_hosts=tuple(network.allowlist_hosts or []), + cassette_digest=network.cassette_digest, + partner_local_paths=tuple(network.partner_local_paths or []), + synthetic_dependency_digests=tuple(network.synthetic_dependency_digests or []), + ) + + +def host_allowed(host: str, policy: HermeticityPolicy) -> bool: + if policy.mode == HermeticityMode.NO_NETWORK: + return False + if policy.mode == HermeticityMode.ALLOWLISTED_NETWORK: + return host in policy.allowlist_hosts + if policy.mode == HermeticityMode.RECORDED_RESPONSE: + return False + if policy.mode == HermeticityMode.PARTNER_LOCAL: + return False + if policy.mode == HermeticityMode.SYNTHETIC_DEPENDENCY: + return False + return False + + +def path_within_partner(path: str, policy: HermeticityPolicy) -> bool: + if policy.mode != HermeticityMode.PARTNER_LOCAL: + return False + from pathlib import Path + + target = Path(path).resolve() + for allowed in policy.partner_local_paths: + root = Path(allowed).resolve() + if target == root: + return True + try: + target.relative_to(root) + return True + except ValueError: + continue + return False + + +def evidence_digest(evidence: HermeticityEvidence) -> str: + return sha256_text( + f"{evidence.mode.value}|{evidence.status.value}|{evidence.policy_digest}" + ) + + +CapabilityResult = Literal["supported", "unsupported"] From 6f68bbe232f1cb3cb78600fbd5426fcf616d85a1 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:24 -0700 Subject: [PATCH 08/31] feat: add branch replay manifest schema Define the multi-branch intervention manifest contract for hermetic branch-N schedules. --- schemas/mrr/branch_replay_manifest.v1.json | 125 +++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 schemas/mrr/branch_replay_manifest.v1.json diff --git a/schemas/mrr/branch_replay_manifest.v1.json b/schemas/mrr/branch_replay_manifest.v1.json new file mode 100644 index 0000000..9bfc89d --- /dev/null +++ b/schemas/mrr/branch_replay_manifest.v1.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://morph-replay-runner.local/schemas/mrr/branch_replay_manifest.v1.json", + "title": "BranchReplayManifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "manifest_id", + "incident_ref", + "snapshot_ref", + "execution_profile_digest", + "branches", + "retry_policy", + "cancellation_policy", + "branch_independence" + ], + "properties": { + "schema_version": { "const": "mrr.BranchReplayManifest.v1" }, + "manifest_id": { "type": "string", "minLength": 1 }, + "incident_ref": { + "type": "object", + "additionalProperties": false, + "required": ["incident_id", "incident_digest"], + "properties": { + "incident_id": { "type": "string", "minLength": 1 }, + "incident_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "snapshot_ref": { + "type": "object", + "additionalProperties": false, + "required": ["snapshot_id", "snapshot_digest"], + "properties": { + "snapshot_id": { "type": "string", "minLength": 1 }, + "snapshot_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "execution_profile_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "branches": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["branch_id", "intervention", "action_sequence"], + "properties": { + "branch_id": { "type": "string", "minLength": 1 }, + "intervention": { + "type": "object", + "additionalProperties": false, + "required": ["intervention_id", "artifact_paths"], + "properties": { + "intervention_id": { "type": "string", "minLength": 1 }, + "artifact_paths": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "action_sequence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "policy_checkpoint": { "type": "string" }, + "verifier_profiles": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "expected_output_classes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "claim_class": { + "type": "string", + "enum": [ + "runtime_observed", + "RuntimeObserved", + "RuntimeChecked", + "ReplayValidated" + ] + } + } + } + }, + "retry_policy": { + "type": "object", + "additionalProperties": false, + "required": ["max_retries", "retry_on"], + "properties": { + "max_retries": { "type": "integer", "minimum": 0 }, + "retry_on": { + "type": "array", + "items": { + "type": "string", + "enum": ["TIMEOUT", "ERROR", "FAILED"] + } + } + } + }, + "cancellation_policy": { + "type": "object", + "additionalProperties": false, + "required": ["cancel_others_on_failure"], + "properties": { + "cancel_others_on_failure": { "type": "boolean" } + } + }, + "branch_independence": { "type": "boolean" } + } +} From 070538088fb74e1b9e1bf1053bbdbedb79c71e3a Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:38 -0700 Subject: [PATCH 09/31] feat: add branch manifest loader and scheduler Implement manifest parsing and branch-N scheduling against fixtures so multi-intervention replays stay deterministic. --- fixtures/branch/incident_bundle.json | 4 + fixtures/branch/interventions/branch-00.json | 11 + fixtures/branch/interventions/branch-01.json | 11 + fixtures/branch/interventions/branch-02.json | 11 + fixtures/branch/interventions/branch-03.json | 11 + fixtures/branch/interventions/branch-04.json | 11 + fixtures/branch/interventions/branch-05.json | 11 + fixtures/branch/interventions/branch-06.json | 11 + fixtures/branch/interventions/branch-07.json | 11 + fixtures/branch/interventions/branch-08.json | 11 + fixtures/branch/interventions/branch-09.json | 11 + fixtures/branch/interventions/branch-10.json | 11 + fixtures/branch/interventions/branch-11.json | 11 + fixtures/branch/interventions/branch-12.json | 11 + fixtures/branch/interventions/branch-13.json | 11 + fixtures/branch/interventions/branch-14.json | 11 + fixtures/branch/interventions/branch-15.json | 11 + fixtures/branch/manifest.json | 250 ++++++++++ fixtures/branch/manifest_digest_drift.json | 250 ++++++++++ fixtures/branch/snapshot.payload | 1 + fixtures/branch/snapshot_ref.json | 4 + runner/branch/__init__.py | 19 + runner/branch/manifest.py | 85 ++++ runner/branch/scheduler.py | 498 +++++++++++++++++++ 24 files changed, 1287 insertions(+) create mode 100644 fixtures/branch/incident_bundle.json create mode 100644 fixtures/branch/interventions/branch-00.json create mode 100644 fixtures/branch/interventions/branch-01.json create mode 100644 fixtures/branch/interventions/branch-02.json create mode 100644 fixtures/branch/interventions/branch-03.json create mode 100644 fixtures/branch/interventions/branch-04.json create mode 100644 fixtures/branch/interventions/branch-05.json create mode 100644 fixtures/branch/interventions/branch-06.json create mode 100644 fixtures/branch/interventions/branch-07.json create mode 100644 fixtures/branch/interventions/branch-08.json create mode 100644 fixtures/branch/interventions/branch-09.json create mode 100644 fixtures/branch/interventions/branch-10.json create mode 100644 fixtures/branch/interventions/branch-11.json create mode 100644 fixtures/branch/interventions/branch-12.json create mode 100644 fixtures/branch/interventions/branch-13.json create mode 100644 fixtures/branch/interventions/branch-14.json create mode 100644 fixtures/branch/interventions/branch-15.json create mode 100644 fixtures/branch/manifest.json create mode 100644 fixtures/branch/manifest_digest_drift.json create mode 100644 fixtures/branch/snapshot.payload create mode 100644 fixtures/branch/snapshot_ref.json create mode 100644 runner/branch/__init__.py create mode 100644 runner/branch/manifest.py create mode 100644 runner/branch/scheduler.py diff --git a/fixtures/branch/incident_bundle.json b/fixtures/branch/incident_bundle.json new file mode 100644 index 0000000..27cfb43 --- /dev/null +++ b/fixtures/branch/incident_bundle.json @@ -0,0 +1,4 @@ +{ + "incident_id": "inc-demo", + "incident_digest": "sha256:cad4eaad4523d1b78d9de32b78b3a873380aad8a75c2646fffe921e182f01907" +} diff --git a/fixtures/branch/interventions/branch-00.json b/fixtures/branch/interventions/branch-00.json new file mode 100644 index 0000000..1533b6b --- /dev/null +++ b/fixtures/branch/interventions/branch-00.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-00", + "intervention_id": "iv-00", + "artifact_paths": [ + "branch-00.json" + ], + "action_sequence": [ + "replay", + "branch-00" + ] +} diff --git a/fixtures/branch/interventions/branch-01.json b/fixtures/branch/interventions/branch-01.json new file mode 100644 index 0000000..59e1454 --- /dev/null +++ b/fixtures/branch/interventions/branch-01.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-01", + "intervention_id": "iv-01", + "artifact_paths": [ + "branch-01.json" + ], + "action_sequence": [ + "replay", + "branch-01" + ] +} diff --git a/fixtures/branch/interventions/branch-02.json b/fixtures/branch/interventions/branch-02.json new file mode 100644 index 0000000..f2abc91 --- /dev/null +++ b/fixtures/branch/interventions/branch-02.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-02", + "intervention_id": "iv-02", + "artifact_paths": [ + "branch-02.json" + ], + "action_sequence": [ + "replay", + "branch-02" + ] +} diff --git a/fixtures/branch/interventions/branch-03.json b/fixtures/branch/interventions/branch-03.json new file mode 100644 index 0000000..ecd6edc --- /dev/null +++ b/fixtures/branch/interventions/branch-03.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-03", + "intervention_id": "iv-03", + "artifact_paths": [ + "branch-03.json" + ], + "action_sequence": [ + "replay", + "branch-03" + ] +} diff --git a/fixtures/branch/interventions/branch-04.json b/fixtures/branch/interventions/branch-04.json new file mode 100644 index 0000000..00cebf6 --- /dev/null +++ b/fixtures/branch/interventions/branch-04.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-04", + "intervention_id": "iv-04", + "artifact_paths": [ + "branch-04.json" + ], + "action_sequence": [ + "replay", + "branch-04" + ] +} diff --git a/fixtures/branch/interventions/branch-05.json b/fixtures/branch/interventions/branch-05.json new file mode 100644 index 0000000..8524123 --- /dev/null +++ b/fixtures/branch/interventions/branch-05.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-05", + "intervention_id": "iv-05", + "artifact_paths": [ + "branch-05.json" + ], + "action_sequence": [ + "replay", + "branch-05" + ] +} diff --git a/fixtures/branch/interventions/branch-06.json b/fixtures/branch/interventions/branch-06.json new file mode 100644 index 0000000..1b53696 --- /dev/null +++ b/fixtures/branch/interventions/branch-06.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-06", + "intervention_id": "iv-06", + "artifact_paths": [ + "branch-06.json" + ], + "action_sequence": [ + "replay", + "branch-06" + ] +} diff --git a/fixtures/branch/interventions/branch-07.json b/fixtures/branch/interventions/branch-07.json new file mode 100644 index 0000000..cd69b03 --- /dev/null +++ b/fixtures/branch/interventions/branch-07.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-07", + "intervention_id": "iv-07", + "artifact_paths": [ + "branch-07.json" + ], + "action_sequence": [ + "replay", + "branch-07" + ] +} diff --git a/fixtures/branch/interventions/branch-08.json b/fixtures/branch/interventions/branch-08.json new file mode 100644 index 0000000..1de90ba --- /dev/null +++ b/fixtures/branch/interventions/branch-08.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-08", + "intervention_id": "iv-08", + "artifact_paths": [ + "branch-08.json" + ], + "action_sequence": [ + "replay", + "branch-08" + ] +} diff --git a/fixtures/branch/interventions/branch-09.json b/fixtures/branch/interventions/branch-09.json new file mode 100644 index 0000000..246816d --- /dev/null +++ b/fixtures/branch/interventions/branch-09.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-09", + "intervention_id": "iv-09", + "artifact_paths": [ + "branch-09.json" + ], + "action_sequence": [ + "replay", + "branch-09" + ] +} diff --git a/fixtures/branch/interventions/branch-10.json b/fixtures/branch/interventions/branch-10.json new file mode 100644 index 0000000..df08633 --- /dev/null +++ b/fixtures/branch/interventions/branch-10.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-10", + "intervention_id": "iv-10", + "artifact_paths": [ + "branch-10.json" + ], + "action_sequence": [ + "replay", + "branch-10" + ] +} diff --git a/fixtures/branch/interventions/branch-11.json b/fixtures/branch/interventions/branch-11.json new file mode 100644 index 0000000..0b809d1 --- /dev/null +++ b/fixtures/branch/interventions/branch-11.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-11", + "intervention_id": "iv-11", + "artifact_paths": [ + "branch-11.json" + ], + "action_sequence": [ + "replay", + "branch-11" + ] +} diff --git a/fixtures/branch/interventions/branch-12.json b/fixtures/branch/interventions/branch-12.json new file mode 100644 index 0000000..6462095 --- /dev/null +++ b/fixtures/branch/interventions/branch-12.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-12", + "intervention_id": "iv-12", + "artifact_paths": [ + "branch-12.json" + ], + "action_sequence": [ + "replay", + "branch-12" + ] +} diff --git a/fixtures/branch/interventions/branch-13.json b/fixtures/branch/interventions/branch-13.json new file mode 100644 index 0000000..4b182e7 --- /dev/null +++ b/fixtures/branch/interventions/branch-13.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-13", + "intervention_id": "iv-13", + "artifact_paths": [ + "branch-13.json" + ], + "action_sequence": [ + "replay", + "branch-13" + ] +} diff --git a/fixtures/branch/interventions/branch-14.json b/fixtures/branch/interventions/branch-14.json new file mode 100644 index 0000000..41dffbe --- /dev/null +++ b/fixtures/branch/interventions/branch-14.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-14", + "intervention_id": "iv-14", + "artifact_paths": [ + "branch-14.json" + ], + "action_sequence": [ + "replay", + "branch-14" + ] +} diff --git a/fixtures/branch/interventions/branch-15.json b/fixtures/branch/interventions/branch-15.json new file mode 100644 index 0000000..578674e --- /dev/null +++ b/fixtures/branch/interventions/branch-15.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-15", + "intervention_id": "iv-15", + "artifact_paths": [ + "branch-15.json" + ], + "action_sequence": [ + "replay", + "branch-15" + ] +} diff --git a/fixtures/branch/manifest.json b/fixtures/branch/manifest.json new file mode 100644 index 0000000..e1e12c3 --- /dev/null +++ b/fixtures/branch/manifest.json @@ -0,0 +1,250 @@ +{ + "schema_version": "mrr.BranchReplayManifest.v1", + "manifest_id": "manifest-demo", + "incident_ref": { + "incident_id": "inc-demo", + "incident_digest": "sha256:cad4eaad4523d1b78d9de32b78b3a873380aad8a75c2646fffe921e182f01907" + }, + "snapshot_ref": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "execution_profile_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "branches": [ + { + "branch_id": "branch-00", + "intervention": { + "intervention_id": "iv-00", + "artifact_paths": [ + "branch-00.json" + ] + }, + "action_sequence": [ + "replay", + "branch-00" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-01", + "intervention": { + "intervention_id": "iv-01", + "artifact_paths": [ + "branch-01.json" + ] + }, + "action_sequence": [ + "replay", + "branch-01" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-02", + "intervention": { + "intervention_id": "iv-02", + "artifact_paths": [ + "branch-02.json" + ] + }, + "action_sequence": [ + "replay", + "branch-02" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-03", + "intervention": { + "intervention_id": "iv-03", + "artifact_paths": [ + "branch-03.json" + ] + }, + "action_sequence": [ + "replay", + "branch-03" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-04", + "intervention": { + "intervention_id": "iv-04", + "artifact_paths": [ + "branch-04.json" + ] + }, + "action_sequence": [ + "replay", + "branch-04" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-05", + "intervention": { + "intervention_id": "iv-05", + "artifact_paths": [ + "branch-05.json" + ] + }, + "action_sequence": [ + "replay", + "branch-05" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-06", + "intervention": { + "intervention_id": "iv-06", + "artifact_paths": [ + "branch-06.json" + ] + }, + "action_sequence": [ + "replay", + "branch-06" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-07", + "intervention": { + "intervention_id": "iv-07", + "artifact_paths": [ + "branch-07.json" + ] + }, + "action_sequence": [ + "replay", + "branch-07" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-08", + "intervention": { + "intervention_id": "iv-08", + "artifact_paths": [ + "branch-08.json" + ] + }, + "action_sequence": [ + "replay", + "branch-08" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-09", + "intervention": { + "intervention_id": "iv-09", + "artifact_paths": [ + "branch-09.json" + ] + }, + "action_sequence": [ + "replay", + "branch-09" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-10", + "intervention": { + "intervention_id": "iv-10", + "artifact_paths": [ + "branch-10.json" + ] + }, + "action_sequence": [ + "replay", + "branch-10" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-11", + "intervention": { + "intervention_id": "iv-11", + "artifact_paths": [ + "branch-11.json" + ] + }, + "action_sequence": [ + "replay", + "branch-11" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-12", + "intervention": { + "intervention_id": "iv-12", + "artifact_paths": [ + "branch-12.json" + ] + }, + "action_sequence": [ + "replay", + "branch-12" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-13", + "intervention": { + "intervention_id": "iv-13", + "artifact_paths": [ + "branch-13.json" + ] + }, + "action_sequence": [ + "replay", + "branch-13" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-14", + "intervention": { + "intervention_id": "iv-14", + "artifact_paths": [ + "branch-14.json" + ] + }, + "action_sequence": [ + "replay", + "branch-14" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-15", + "intervention": { + "intervention_id": "iv-15", + "artifact_paths": [ + "branch-15.json" + ] + }, + "action_sequence": [ + "replay", + "branch-15" + ], + "claim_class": "RuntimeObserved" + } + ], + "retry_policy": { + "max_retries": 1, + "retry_on": [ + "TIMEOUT", + "ERROR" + ] + }, + "cancellation_policy": { + "cancel_others_on_failure": false + }, + "branch_independence": true +} diff --git a/fixtures/branch/manifest_digest_drift.json b/fixtures/branch/manifest_digest_drift.json new file mode 100644 index 0000000..18d9dc4 --- /dev/null +++ b/fixtures/branch/manifest_digest_drift.json @@ -0,0 +1,250 @@ +{ + "schema_version": "mrr.BranchReplayManifest.v1", + "manifest_id": "manifest-demo", + "incident_ref": { + "incident_id": "inc-demo", + "incident_digest": "sha256:cad4eaad4523d1b78d9de32b78b3a873380aad8a75c2646fffe921e182f01907" + }, + "snapshot_ref": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "execution_profile_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "branches": [ + { + "branch_id": "branch-00", + "intervention": { + "intervention_id": "iv-00", + "artifact_paths": [ + "branch-00.json" + ] + }, + "action_sequence": [ + "replay", + "branch-00" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-01", + "intervention": { + "intervention_id": "iv-01", + "artifact_paths": [ + "branch-01.json" + ] + }, + "action_sequence": [ + "replay", + "branch-01" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-02", + "intervention": { + "intervention_id": "iv-02", + "artifact_paths": [ + "branch-02.json" + ] + }, + "action_sequence": [ + "replay", + "branch-02" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-03", + "intervention": { + "intervention_id": "iv-03", + "artifact_paths": [ + "branch-03.json" + ] + }, + "action_sequence": [ + "replay", + "branch-03" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-04", + "intervention": { + "intervention_id": "iv-04", + "artifact_paths": [ + "branch-04.json" + ] + }, + "action_sequence": [ + "replay", + "branch-04" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-05", + "intervention": { + "intervention_id": "iv-05", + "artifact_paths": [ + "branch-05.json" + ] + }, + "action_sequence": [ + "replay", + "branch-05" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-06", + "intervention": { + "intervention_id": "iv-06", + "artifact_paths": [ + "branch-06.json" + ] + }, + "action_sequence": [ + "replay", + "branch-06" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-07", + "intervention": { + "intervention_id": "iv-07", + "artifact_paths": [ + "branch-07.json" + ] + }, + "action_sequence": [ + "replay", + "branch-07" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-08", + "intervention": { + "intervention_id": "iv-08", + "artifact_paths": [ + "branch-08.json" + ] + }, + "action_sequence": [ + "replay", + "branch-08" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-09", + "intervention": { + "intervention_id": "iv-09", + "artifact_paths": [ + "branch-09.json" + ] + }, + "action_sequence": [ + "replay", + "branch-09" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-10", + "intervention": { + "intervention_id": "iv-10", + "artifact_paths": [ + "branch-10.json" + ] + }, + "action_sequence": [ + "replay", + "branch-10" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-11", + "intervention": { + "intervention_id": "iv-11", + "artifact_paths": [ + "branch-11.json" + ] + }, + "action_sequence": [ + "replay", + "branch-11" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-12", + "intervention": { + "intervention_id": "iv-12", + "artifact_paths": [ + "branch-12.json" + ] + }, + "action_sequence": [ + "replay", + "branch-12" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-13", + "intervention": { + "intervention_id": "iv-13", + "artifact_paths": [ + "branch-13.json" + ] + }, + "action_sequence": [ + "replay", + "branch-13" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-14", + "intervention": { + "intervention_id": "iv-14", + "artifact_paths": [ + "branch-14.json" + ] + }, + "action_sequence": [ + "replay", + "branch-14" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-15", + "intervention": { + "intervention_id": "iv-15", + "artifact_paths": [ + "branch-15.json" + ] + }, + "action_sequence": [ + "replay", + "branch-15" + ], + "claim_class": "RuntimeObserved" + } + ], + "retry_policy": { + "max_retries": 1, + "retry_on": [ + "TIMEOUT", + "ERROR" + ] + }, + "cancellation_policy": { + "cancel_others_on_failure": false + }, + "branch_independence": true +} diff --git a/fixtures/branch/snapshot.payload b/fixtures/branch/snapshot.payload new file mode 100644 index 0000000..c19da04 --- /dev/null +++ b/fixtures/branch/snapshot.payload @@ -0,0 +1 @@ +mrr-local-fake-snapshot-v1 diff --git a/fixtures/branch/snapshot_ref.json b/fixtures/branch/snapshot_ref.json new file mode 100644 index 0000000..9c133d2 --- /dev/null +++ b/fixtures/branch/snapshot_ref.json @@ -0,0 +1,4 @@ +{ + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" +} diff --git a/runner/branch/__init__.py b/runner/branch/__init__.py new file mode 100644 index 0000000..5059307 --- /dev/null +++ b/runner/branch/__init__.py @@ -0,0 +1,19 @@ +"""Branch package.""" + +from runner.branch.manifest import ( + BranchReplayManifest, + ManifestValidationError, + load_manifest, + validate_manifest, +) +from runner.branch.scheduler import BranchScheduler, SchedulerConfig, run_branch_job + +__all__ = [ + "BranchReplayManifest", + "BranchScheduler", + "ManifestValidationError", + "SchedulerConfig", + "load_manifest", + "run_branch_job", + "validate_manifest", +] diff --git a/runner/branch/manifest.py b/runner/branch/manifest.py new file mode 100644 index 0000000..c575bc5 --- /dev/null +++ b/runner/branch/manifest.py @@ -0,0 +1,85 @@ +"""BranchReplayManifest models.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +SCHEMA_VERSION = "mrr.BranchReplayManifest.v1" + + +class ManifestValidationError(ValueError): + """Fail-closed manifest validation.""" + + +class IncidentRef(BaseModel): + incident_id: str + incident_digest: str = Field(..., pattern=r"^sha256:[a-f0-9]{64}$") + + +class SnapshotRefModel(BaseModel): + snapshot_id: str + snapshot_digest: str = Field(..., pattern=r"^sha256:[a-f0-9]{64}$") + + +class InterventionSpec(BaseModel): + intervention_id: str + artifact_paths: list[str] = Field(default_factory=list) + digest: Optional[str] = Field(default=None, pattern=r"^sha256:[a-f0-9]{64}$") + + +class BranchSpec(BaseModel): + branch_id: str + intervention: InterventionSpec + action_sequence: list[str] = Field(..., min_length=1) + policy_checkpoint: Optional[str] = None + verifier_profiles: list[str] = Field(default_factory=list) + expected_output_classes: list[str] = Field(default_factory=list) + claim_class: Literal[ + "runtime_observed", + "RuntimeObserved", + "RuntimeChecked", + "ReplayValidated", + ] = "RuntimeObserved" + + +class RetryPolicy(BaseModel): + max_retries: int = Field(..., ge=0) + retry_on: list[Literal["TIMEOUT", "ERROR", "FAILED"]] = Field(default_factory=list) + + +class CancellationPolicy(BaseModel): + cancel_others_on_failure: bool = False + + +class BranchReplayManifest(BaseModel): + schema_version: Literal["mrr.BranchReplayManifest.v1"] = ( + "mrr.BranchReplayManifest.v1" + ) + manifest_id: str + incident_ref: IncidentRef + snapshot_ref: SnapshotRefModel + execution_profile_digest: str = Field(..., pattern=r"^sha256:[a-f0-9]{64}$") + branches: list[BranchSpec] = Field(..., min_length=1) + retry_policy: RetryPolicy + cancellation_policy: CancellationPolicy + branch_independence: bool = True + + +def load_manifest(path: Path | str) -> BranchReplayManifest: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + return validate_manifest(raw) + + +def validate_manifest(raw: dict[str, Any]) -> BranchReplayManifest: + if raw.get("schema_version") != SCHEMA_VERSION: + raise ManifestValidationError( + f"unknown schema_version {raw.get('schema_version')!r}" + ) + try: + return BranchReplayManifest.model_validate(raw) + except Exception as exc: + raise ManifestValidationError(str(exc)) from exc diff --git a/runner/branch/scheduler.py b/runner/branch/scheduler.py new file mode 100644 index 0000000..50649dc --- /dev/null +++ b/runner/branch/scheduler.py @@ -0,0 +1,498 @@ +"""Isolated branch-N scheduler.""" + +from __future__ import annotations + +import json +import shutil +import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from runner.accounting.report import ( + BudgetExceededError, + assert_unavailable_not_fabricated, + enforce_budgets, + sample_to_report, +) +from runner.branch.manifest import ( + BranchReplayManifest, + BranchSpec, + CancellationPolicy, + IncidentRef, + InterventionSpec, + RetryPolicy, + SnapshotRefModel, + load_manifest, +) +from runner.evidence import ( + assert_no_secret_material, + emit_morph_replay_report, + emit_runtime_receipt, + emit_transformation_record, + maybe_pf_core_replay_trace, + redact_text, + terminal_state_commitment, + write_json, +) +from runner.hashing import sha256_file, sha256_text +from runner.hermeticity.modes import ( + EnforcementStatus, + HermeticityError, + HermeticityEvidence, + policy_from_profile_network, +) +from runner.profile.schema import ExecutionProfile, load_profile, profile_digest +from runner.providers.base import ( + ExecRequest, + ExecResult, + ExecStatus, + ExecutionProvider, + SnapshotHandle, + SnapshotRef, + TeardownReceipt, +) + + +def _iso_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +@dataclass +class SchedulerConfig: + parallel: int + out_dir: Path + interventions_dir: Optional[Path] = None + container_digest: str = ( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + + +class BranchScheduler: + """Clone-from-canonical-snapshot branch-N executor.""" + + def __init__( + self, + provider: ExecutionProvider, + profile: ExecutionProfile, + manifest: BranchReplayManifest, + config: SchedulerConfig, + ) -> None: + self.provider = provider + self.profile = profile + self.manifest = manifest + self.config = config + digest = profile.profile_digest or profile_digest(profile) + if digest != manifest.execution_profile_digest: + raise HermeticityError( + "execution profile digest drift vs manifest pin: " + f"{digest} != {manifest.execution_profile_digest}" + ) + self.profile_digest = digest + self.policy = policy_from_profile_network(profile.network_policy) + + def preflight(self) -> HermeticityEvidence: + mode = self.policy.mode + if not self.provider.supports(mode): + evidence = HermeticityEvidence( + mode=mode, + status=EnforcementStatus.UNSUPPORTED, + policy_digest=self.policy.policy_digest(), + probe_result={"provider": self.provider.name, "supports": False}, + message=f"provider {self.provider.name} cannot enforce {mode.value}", + ) + raise HermeticityError(evidence.message) + return HermeticityEvidence( + mode=mode, + status=EnforcementStatus.ENFORCED, + policy_digest=self.policy.policy_digest(), + probe_result={"provider": self.provider.name, "supports": True}, + message="hermeticity mode supported", + ) + + def run(self) -> dict[str, Any]: + evidence = self.preflight() + self.config.out_dir.mkdir(parents=True, exist_ok=True) + write_json( + self.config.out_dir / "hermeticity_evidence.json", evidence.to_dict() + ) + + snapshot = self.provider.lookup_snapshot( + SnapshotRef( + snapshot_id=self.manifest.snapshot_ref.snapshot_id, + snapshot_digest=self.manifest.snapshot_ref.snapshot_digest, + ) + ) + write_json( + self.config.out_dir / "snapshot_ref.json", + { + "snapshot_id": snapshot.snapshot_id, + "snapshot_digest": snapshot.snapshot_digest, + }, + ) + + results: dict[str, Any] = {} + branches = list(self.manifest.branches) + with ThreadPoolExecutor(max_workers=max(1, self.config.parallel)) as pool: + futures = { + pool.submit(self._run_branch, spec, snapshot): spec for spec in branches + } + for future in as_completed(futures): + spec = futures[future] + try: + result = future.result() + except Exception as exc: + result = { + "branch_id": spec.branch_id, + "status": "partial", + "terminal": "ERROR", + "error": str(exc), + "traceback": traceback.format_exc(), + } + err_dir = self.config.out_dir / spec.branch_id + err_dir.mkdir(parents=True, exist_ok=True) + write_json(err_dir / "status.json", result) + results[spec.branch_id] = result + if ( + self.manifest.cancellation_policy.cancel_others_on_failure + and result.get("terminal") in {"FAILED", "ERROR", "TIMEOUT"} + ): + cancel = getattr(self.provider, "cancel", None) + if callable(cancel): + for other in branches: + if other.branch_id != spec.branch_id: + cancel(other.branch_id) + + summary = { + "schema_version": "mrr.BranchRunSummary.v1", + "manifest_id": self.manifest.manifest_id, + "profile_digest": self.profile_digest, + "snapshot_digest": snapshot.snapshot_digest, + "branches": results, + "non_claims": [ + "Observational/differential evidence only.", + "No causality or remediation ranking.", + ], + } + write_json(self.config.out_dir / "summary.json", summary) + return summary + + def _resolve_intervention_paths(self, spec: BranchSpec) -> list[str]: + paths: list[str] = [] + for rel in spec.intervention.artifact_paths: + candidate = Path(rel) + if not candidate.is_file() and self.config.interventions_dir is not None: + candidate = self.config.interventions_dir / rel + if not candidate.is_file(): + raise FileNotFoundError(f"intervention artifact missing: {rel}") + paths.append(str(candidate.resolve())) + return paths + + def _run_branch(self, spec: BranchSpec, snapshot: SnapshotHandle) -> dict[str, Any]: + branch_out = self.config.out_dir / spec.branch_id + if branch_out.exists(): + shutil.rmtree(branch_out) + branch_out.mkdir(parents=True) + + profile_payload = self.profile.model_dump(mode="json", exclude_none=True) + profile_payload["profile_digest"] = self.profile_digest + write_json(branch_out / "execution_profile.json", profile_payload) + write_json( + branch_out / "snapshot_ref.json", + { + "snapshot_id": snapshot.snapshot_id, + "snapshot_digest": snapshot.snapshot_digest, + }, + ) + + intervention_paths = self._resolve_intervention_paths(spec) + intervention_record = { + "intervention_id": spec.intervention.intervention_id, + "artifact_paths": [Path(p).name for p in intervention_paths], + "digests": {Path(p).name: sha256_file(p) for p in intervention_paths}, + } + write_json(branch_out / "intervention_record.json", intervention_record) + + started_at = _iso_now() + attempt = 0 + exec_result: Optional[ExecResult] = None + resource_report: dict[str, Any] = {} + teardown: Optional[TeardownReceipt] = None + + while attempt <= self.manifest.retry_policy.max_retries: + handle = self.provider.clone( + snapshot, branch_id=f"{spec.branch_id}__attempt{attempt}" + ) + try: + request = ExecRequest( + command=list(spec.action_sequence), + timeout_ms=self.profile.budgets.wall_time_ms, + env={}, + intervention_paths=intervention_paths, + ) + exec_result = self.provider.execute(handle, request) + sample = self.provider.measure(handle) + assert_unavailable_not_fabricated(sample) + try: + enforce_budgets(sample, self.profile.budgets) + violations: list[str] = [] + except BudgetExceededError as exc: + violations = str(exc).split("; ") + exec_result = ExecResult( + status=ExecStatus.FAILED, + exit_code=3, + stdout=exec_result.stdout, + stderr=str(exc), + wall_time_ms=exec_result.wall_time_ms, + terminal_state={ + "reason": "BUDGET_EXCEEDED", + "error": str(exc), + }, + ) + resource_report = sample_to_report(spec.branch_id, sample) + resource_report["budget_violations"] = violations + finally: + teardown = self.provider.teardown(handle) + + assert exec_result is not None + if exec_result.status == ExecStatus.PASSED: + break + if exec_result.status.value not in self.manifest.retry_policy.retry_on: + break + attempt += 1 + + assert exec_result is not None + assert teardown is not None + ended_at = _iso_now() + terminal_status = exec_result.status.value + status_label = ( + "complete" if exec_result.status == ExecStatus.PASSED else "partial" + ) + + terminal = dict(exec_result.terminal_state or {}) + terminal.setdefault("status", terminal_status) + terminal_commitment = terminal_state_commitment(terminal) + write_json(branch_out / "terminal_state.json", terminal) + write_json( + branch_out / "terminal_commitment.json", {"digest": terminal_commitment} + ) + write_json(branch_out / "resource_report.json", resource_report) + write_json( + branch_out / "teardown_receipt.json", + { + "branch_id": teardown.branch_id, + "cleaned": teardown.cleaned, + "residual_paths": teardown.residual_paths, + "secrets_scrubbed": teardown.secrets_scrubbed, + "details": teardown.details, + }, + ) + + logs = redact_text( + f"STDOUT:\n{exec_result.stdout}\n\nSTDERR:\n{exec_result.stderr}\n" + ) + (branch_out / "logs.txt").write_text(logs, encoding="utf-8") + log_digest = sha256_text(logs) + + input_hashes = { + "execution_profile": self.profile_digest, + "snapshot": snapshot.snapshot_digest, + "intervention": sha256_text( + json.dumps(intervention_record, sort_keys=True) + ), + } + output_hashes = { + "terminal_state": terminal_commitment, + "logs": log_digest, + } + + from runner.evidence.pcs import EvidenceError, receipt_status_for_claim_class + from runner.evidence.validate import SchemaValidationError, validate_instance + + pf_check_ran = False + if spec.claim_class in {"RuntimeChecked", "ReplayValidated"}: + # Fail closed: elevated claim classes require PF-Core trace + CLI. + maybe_pf_core_replay_trace( + claim_class=spec.claim_class, + trace_path=branch_out / "pf_trace.json", + out_dir=branch_out / "pf_core", + ) + pf_check_ran = True + receipt_status = receipt_status_for_claim_class( + spec.claim_class, check_ran=pf_check_ran + ) + claim_semantics = ( + "runtime_checked" + if receipt_status == "RuntimeChecked" + else "runtime_observed" + ) + + try: + validate_instance("mrr.ResourceReport.v1", resource_report) + except SchemaValidationError as exc: + raise EvidenceError(str(exc)) from exc + + receipt = emit_runtime_receipt( + receipt_id=f"receipt-{spec.branch_id}", + run_id=f"run-{self.manifest.manifest_id}-{spec.branch_id}", + started_at=started_at, + ended_at=ended_at, + run_outcome="passed" if status_label == "complete" else "failed", + final_reason_code=terminal_status, + source_commit=self.profile.source_commit, + input_hashes=input_hashes, + output_hashes=output_hashes, + environment={ + "provider": self.provider.name, + "claim_class": spec.claim_class, + "claim_semantics": claim_semantics, + }, + status=receipt_status, + ) + write_json(branch_out / "runtime_receipt.json", receipt) + + morph_report = emit_morph_replay_report( + replay_id=f"replay-{self.manifest.manifest_id}", + branch_id=spec.branch_id, + replay_identity_digest=terminal_commitment, + input_artifact_digests=[self.profile_digest, snapshot.snapshot_digest], + output_artifact_digests=[terminal_commitment], + status="recorded" if status_label == "complete" else "indeterminate", + ) + write_json(branch_out / "morph_replay_report.json", morph_report) + + transform = emit_transformation_record( + transformation_id=f"t-{spec.branch_id}", + transformation_type="synthetic_variant_derivation", + input_artifact_digests=[snapshot.snapshot_digest], + output_artifact_digest=terminal_commitment, + implementation_id="morph-replay-runner", + implementation_version="0.1.0", + container_digest=self.config.container_digest, + source_commit=self.profile.source_commit, + notes="branch replay transform", + ) + write_json(branch_out / "transformation_record.json", transform) + + branch_report = { + "branch_id": spec.branch_id, + "process_action": list(spec.action_sequence), + "authorization": None, + "side_effect": None, + "reward": None, + "verifier_decision": None, + "unresolved_external_dependency": None, + "attempts": attempt + 1, + "teardown_cleaned": teardown.cleaned, + } + write_json(branch_out / "branch_report.json", branch_report) + + digests = { + "execution_profile": self.profile_digest, + "runtime_receipt": receipt["signature_or_digest"], + "terminal_state": terminal_commitment, + "logs": log_digest, + } + write_json(branch_out / "digests.json", digests) + status_payload = { + "status": status_label, + "terminal": terminal_status, + "branch_id": spec.branch_id, + "complete_evidence": status_label == "complete", + } + write_json(branch_out / "status.json", status_payload) + assert_no_secret_material(status_payload) + assert_no_secret_material(receipt) + + return { + "branch_id": spec.branch_id, + "status": status_label, + "terminal": terminal_status, + "digests": digests, + "attempts": attempt + 1, + } + + +def run_branch_job( + *, + provider: ExecutionProvider, + profile_path: Path, + manifest_path: Optional[Path] = None, + incident_path: Optional[Path] = None, + snapshot_path: Optional[Path] = None, + interventions_dir: Optional[Path] = None, + parallel: int = 4, + out_dir: Path, + manifest: Optional[BranchReplayManifest] = None, +) -> dict[str, Any]: + profile = load_profile(profile_path) + if manifest is None: + if manifest_path is not None: + manifest = load_manifest(manifest_path) + else: + if incident_path is None or snapshot_path is None: + raise ValueError("manifest or incident+snapshot required") + if interventions_dir is None: + raise ValueError("--interventions required when manifest omitted") + incident = json.loads(incident_path.read_text(encoding="utf-8")) + snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) + branch_specs: list[BranchSpec] = [] + for path in sorted(interventions_dir.glob("*.json")): + data = json.loads(path.read_text(encoding="utf-8")) + branch_specs.append( + BranchSpec( + branch_id=data["branch_id"], + intervention=InterventionSpec( + intervention_id=data.get("intervention_id", path.stem), + artifact_paths=data.get("artifact_paths", [path.name]), + ), + action_sequence=data.get("action_sequence", ["replay"]), + ) + ) + if not branch_specs: + raise ValueError("no intervention specs found") + digest = profile.profile_digest or profile_digest(profile) + manifest = BranchReplayManifest( + manifest_id=incident.get("incident_id", "incident"), + incident_ref=IncidentRef( + incident_id=incident["incident_id"], + incident_digest=incident["incident_digest"], + ), + snapshot_ref=SnapshotRefModel( + snapshot_id=snapshot["snapshot_id"], + snapshot_digest=snapshot["snapshot_digest"], + ), + execution_profile_digest=digest, + branches=branch_specs, + retry_policy=RetryPolicy(max_retries=0, retry_on=["TIMEOUT", "ERROR"]), + cancellation_policy=CancellationPolicy(cancel_others_on_failure=False), + branch_independence=True, + ) + + from runner.providers.local_fake import LocalFakeProvider + + if isinstance(provider, LocalFakeProvider): + if provider.hermeticity is None: + provider.hermeticity = policy_from_profile_network(profile.network_policy) + provider.fixed_epoch_ms = profile.clock_policy.fixed_epoch_ms + provider.seed = profile.random_seed_policy.seed + + scheduler = BranchScheduler( + provider, + profile, + manifest, + SchedulerConfig( + parallel=parallel, + out_dir=out_dir, + interventions_dir=interventions_dir, + container_digest=profile.container_image_digests[0], + ), + ) + return scheduler.run() From be84709f138e0ac5cecad37d787182b7f0b422b8 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:39 -0700 Subject: [PATCH 10/31] feat: add resource report JSON Schema Capture the accounting contract for CPU, memory, and network usage emitted by replay runs. --- schemas/mrr/resource_report.v1.json | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 schemas/mrr/resource_report.v1.json diff --git a/schemas/mrr/resource_report.v1.json b/schemas/mrr/resource_report.v1.json new file mode 100644 index 0000000..c0e443a --- /dev/null +++ b/schemas/mrr/resource_report.v1.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://morph-replay-runner.local/schemas/mrr/resource_report.v1.json", + "title": "ResourceReport", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "branch_id", "metrics"], + "properties": { + "schema_version": { "const": "mrr.ResourceReport.v1" }, + "branch_id": { "type": "string", "minLength": 1 }, + "metrics": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["value", "unit", "availability", "source"], + "properties": { + "value": { + "type": ["number", "string", "null"] + }, + "unit": { "type": "string", "minLength": 1 }, + "availability": { + "type": "string", + "enum": ["observed", "unavailable", "not_applicable"] + }, + "source": { + "type": "string", + "enum": ["provider", "runner", "guest"] + } + } + } + }, + "budget_violations": { + "type": "array", + "items": { "type": "string" } + } + } +} From 28dcfda48b92a5e3650119be8fe89bbcc5ca358b Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:39 -0700 Subject: [PATCH 11/31] feat: add resource accounting report builder Aggregate per-branch resource measurements into schema-aligned reports for cost and capacity analysis. --- runner/accounting/__init__.py | 15 +++++++ runner/accounting/report.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 runner/accounting/__init__.py create mode 100644 runner/accounting/report.py diff --git a/runner/accounting/__init__.py b/runner/accounting/__init__.py new file mode 100644 index 0000000..3348f5e --- /dev/null +++ b/runner/accounting/__init__.py @@ -0,0 +1,15 @@ +"""Accounting package.""" + +from runner.accounting.report import ( + BudgetExceededError, + assert_unavailable_not_fabricated, + enforce_budgets, + sample_to_report, +) + +__all__ = [ + "BudgetExceededError", + "assert_unavailable_not_fabricated", + "enforce_budgets", + "sample_to_report", +] diff --git a/runner/accounting/report.py b/runner/accounting/report.py new file mode 100644 index 0000000..79c3f5f --- /dev/null +++ b/runner/accounting/report.py @@ -0,0 +1,75 @@ +"""Resource accounting: never invent unavailable metrics.""" + +from __future__ import annotations + +from typing import Any, Optional + +from runner.profile.schema import Budgets +from runner.providers.base import MetricValue, ResourceSample + + +class BudgetExceededError(RuntimeError): + """Observed metric exceeds profile budget.""" + + +def metric_to_dict(metric: MetricValue) -> dict[str, Any]: + return { + "value": metric.value, + "unit": metric.unit, + "availability": metric.availability, + "source": metric.source, + } + + +def sample_to_report(branch_id: str, sample: ResourceSample) -> dict[str, Any]: + return { + "schema_version": "mrr.ResourceReport.v1", + "branch_id": branch_id, + "metrics": { + key: metric_to_dict(value) for key, value in sorted(sample.metrics.items()) + }, + "budget_violations": [], + } + + +def _observed_number(metric: Optional[MetricValue]) -> Optional[float]: + if metric is None or metric.availability != "observed": + return None + if isinstance(metric.value, (int, float)): + return float(metric.value) + return None + + +def enforce_budgets(sample: ResourceSample, budgets: Budgets) -> list[str]: + """Return violation messages; raises if any observed metric exceeds budget.""" + violations: list[str] = [] + checks = [ + ("wall_time_ms", budgets.wall_time_ms), + ("memory_mib", budgets.memory_mib), + ("model_tokens", budgets.token_budget), + ("external_api_calls", budgets.api_call_budget), + ] + for key, limit in checks: + metric = sample.metrics.get(key) + value = _observed_number(metric) + if value is None: + continue + if value > float(limit): + violations.append(f"{key} observed {value} exceeds budget {limit}") + storage = _observed_number(sample.metrics.get("storage_bytes")) + if storage is not None and storage > budgets.storage_mib * 1024 * 1024: + violations.append( + f"storage_bytes observed {storage} exceeds budget " + f"{budgets.storage_mib} MiB" + ) + if violations: + raise BudgetExceededError("; ".join(violations)) + return violations + + +def assert_unavailable_not_fabricated(sample: ResourceSample) -> None: + for name, metric in sample.metrics.items(): + if metric.availability == "unavailable" and metric.value is not None: + raise ValueError( + f"metric {name} marked unavailable but has value {metric.value!r}" + ) From c5e6eac50733c2797ad2f0e40081d35ed2c6e178 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:40 -0700 Subject: [PATCH 12/31] feat: add differential report JSON Schema Define the contract for comparing branch outcomes against a baseline replay. --- schemas/mrr/differential_report.v1.json | 73 +++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 schemas/mrr/differential_report.v1.json diff --git a/schemas/mrr/differential_report.v1.json b/schemas/mrr/differential_report.v1.json new file mode 100644 index 0000000..33bf79c --- /dev/null +++ b/schemas/mrr/differential_report.v1.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://morph-replay-runner.local/schemas/mrr/differential_report.v1.json", + "title": "DifferentialReport", + "description": "Pairwise differential state. absent ≠ equal. No preferred-branch or causality language.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "left_branch_id", + "right_branch_id", + "comparisons", + "report_digest" + ], + "properties": { + "schema_version": { "const": "mrr.DifferentialReport.v1" }, + "left_branch_id": { "type": "string", "minLength": 1 }, + "right_branch_id": { "type": "string", "minLength": 1 }, + "comparisons": { + "type": "object", + "additionalProperties": false, + "required": [ + "terminal_state", + "process_action", + "authorization", + "side_effect", + "reward", + "verifier_decision", + "resource", + "unresolved_external_dependency" + ], + "properties": { + "terminal_state": { "$ref": "#/$defs/field_comparison" }, + "process_action": { "$ref": "#/$defs/field_comparison" }, + "authorization": { "$ref": "#/$defs/field_comparison" }, + "side_effect": { "$ref": "#/$defs/field_comparison" }, + "reward": { "$ref": "#/$defs/field_comparison" }, + "verifier_decision": { "$ref": "#/$defs/field_comparison" }, + "resource": { "$ref": "#/$defs/field_comparison" }, + "unresolved_external_dependency": { "$ref": "#/$defs/field_comparison" } + } + }, + "report_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "non_claims": { + "type": "array", + "items": { "type": "string" } + } + }, + "$defs": { + "field_comparison": { + "type": "object", + "additionalProperties": false, + "required": ["result"], + "properties": { + "result": { + "type": "string", + "enum": [ + "equal", + "different", + "absent_left", + "absent_right", + "absent_both" + ] + }, + "left": {}, + "right": {} + } + } + } +} From 89fb2b3467455177d0f818bd1b0631f1d6d4ef5d Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:54 -0700 Subject: [PATCH 13/31] feat: add differential report generation Compare branch artifacts against a baseline and emit schema-valid differential summaries. --- runner/diff/__init__.py | 17 ++++ runner/diff/report.py | 171 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 runner/diff/__init__.py create mode 100644 runner/diff/report.py diff --git a/runner/diff/__init__.py b/runner/diff/__init__.py new file mode 100644 index 0000000..6c25963 --- /dev/null +++ b/runner/diff/__init__.py @@ -0,0 +1,17 @@ +"""Diff package.""" + +from runner.diff.report import ( + NON_CLAIMS, + build_differential_report, + compare_values, + diff_branch_dirs, + parse_pairs, +) + +__all__ = [ + "NON_CLAIMS", + "build_differential_report", + "compare_values", + "diff_branch_dirs", + "parse_pairs", +] diff --git a/runner/diff/report.py b/runner/diff/report.py new file mode 100644 index 0000000..177f73b --- /dev/null +++ b/runner/diff/report.py @@ -0,0 +1,171 @@ +"""Differential reports with three-valued absent≠equal semantics.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Literal + +from runner.hashing import canonical_hash + +ComparisonResult = Literal[ + "equal", "different", "absent_left", "absent_right", "absent_both" +] + +COMPARISON_KEYS = ( + "terminal_state", + "process_action", + "authorization", + "side_effect", + "reward", + "verifier_decision", + "resource", + "unresolved_external_dependency", +) + +NON_CLAIMS = [ + "No preferred branch.", + "No causality attribution.", + "No remediation ranking.", +] + + +def compare_values(left: Any, right: Any) -> dict[str, Any]: + left_absent = left is None + right_absent = right is None + if left_absent and right_absent: + result: ComparisonResult = "absent_both" + elif left_absent: + result = "absent_left" + elif right_absent: + result = "absent_right" + elif left == right: + result = "equal" + else: + result = "different" + payload: dict[str, Any] = {"result": result} + if not left_absent: + payload["left"] = left + if not right_absent: + payload["right"] = right + return payload + + +def load_branch_view(branch_dir: Path) -> dict[str, Any]: + status_path = branch_dir / "status.json" + status = ( + json.loads(status_path.read_text(encoding="utf-8")) + if status_path.is_file() + else {} + ) + terminal_path = branch_dir / "terminal_state.json" + terminal = ( + json.loads(terminal_path.read_text(encoding="utf-8")) + if terminal_path.is_file() + else None + ) + resources_path = branch_dir / "resource_report.json" + resources = ( + json.loads(resources_path.read_text(encoding="utf-8")) + if resources_path.is_file() + else None + ) + branch_report_path = branch_dir / "branch_report.json" + branch_report = ( + json.loads(branch_report_path.read_text(encoding="utf-8")) + if branch_report_path.is_file() + else {} + ) + return { + "terminal_state": terminal, + "process_action": branch_report.get("process_action"), + "authorization": branch_report.get("authorization"), + "side_effect": branch_report.get("side_effect"), + "reward": branch_report.get("reward"), + "verifier_decision": branch_report.get("verifier_decision"), + "resource": resources, + "unresolved_external_dependency": branch_report.get( + "unresolved_external_dependency" + ), + "status": status, + } + + +def build_differential_report( + left_id: str, + right_id: str, + left: dict[str, Any], + right: dict[str, Any], +) -> dict[str, Any]: + comparisons = { + key: compare_values(left.get(key), right.get(key)) for key in COMPARISON_KEYS + } + body = { + "schema_version": "mrr.DifferentialReport.v1", + "left_branch_id": left_id, + "right_branch_id": right_id, + "comparisons": comparisons, + "non_claims": list(NON_CLAIMS), + } + digest = canonical_hash( + body, + enforce_number_policy=True, + extra_excluded=frozenset({"report_digest"}), + ) + body["report_digest"] = digest + from runner.evidence.validate import SchemaValidationError, validate_instance + + try: + validate_instance("mrr.DifferentialReport.v1", body) + except SchemaValidationError as exc: + raise ValueError(str(exc)) from exc + return body + + +def diff_branch_dirs( + branches_root: Path, + pairs: list[tuple[str, str]], + out_dir: Path, +) -> list[dict[str, Any]]: + out_dir.mkdir(parents=True, exist_ok=True) + reports: list[dict[str, Any]] = [] + for left_id, right_id in pairs: + left = load_branch_view(branches_root / left_id) + right = load_branch_view(branches_root / right_id) + report = build_differential_report(left_id, right_id, left, right) + # Reject causal / remediation claims if injected outside non_claims. + comparisons_blob = json.dumps(report.get("comparisons", {})).lower() + for banned in ("preferred_branch", "root_cause", "remediation_rank"): + if banned in comparisons_blob: + raise ValueError( + f"differential report contains banned language: {banned}" + ) + prose = json.dumps( + {k: v for k, v in report.items() if k != "non_claims"} + ).lower() + for banned in ("root cause is", "preferred branch is", "remediate by"): + if banned in prose: + raise ValueError( + f"differential report contains banned language: {banned}" + ) + path = out_dir / f"{left_id}__{right_id}.json" + path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + reports.append(report) + return reports + + +def parse_pairs(raw: str) -> list[tuple[str, str]]: + pairs: list[tuple[str, str]] = [] + for part in raw.split(";"): + part = part.strip() + if not part: + continue + if "," not in part: + raise ValueError(f"invalid pair {part!r}; expected a,b") + left, right = part.split(",", 1) + pairs.append((left.strip(), right.strip())) + if not pairs: + raise ValueError("no pairs provided") + return pairs From 855078cd4f0d340b9a4753b08c80130375c7bc7e Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:54 -0700 Subject: [PATCH 14/31] feat: vendor PCS evidence schemas and mirror metadata Include RuntimeReceipt and ArtifactIntegrity schemas so evidence emission stays offline-validatable. --- schemas/pcs/ArtifactIntegrity.v1.schema.json | 28 + schemas/pcs/RuntimeReceipt.v0.schema.json | 55 ++ schemas/pcs/SCHEMA_MIRROR.json | 22 + schemas/pcs/common.defs.json | 558 +++++++++++++++++++ 4 files changed, 663 insertions(+) create mode 100644 schemas/pcs/ArtifactIntegrity.v1.schema.json create mode 100644 schemas/pcs/RuntimeReceipt.v0.schema.json create mode 100644 schemas/pcs/SCHEMA_MIRROR.json create mode 100644 schemas/pcs/common.defs.json diff --git a/schemas/pcs/ArtifactIntegrity.v1.schema.json b/schemas/pcs/ArtifactIntegrity.v1.schema.json new file mode 100644 index 0000000..f4c0fd6 --- /dev/null +++ b/schemas/pcs/ArtifactIntegrity.v1.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/ArtifactIntegrity.v1.schema.json", + "title": "ArtifactIntegrity.v1", + "description": "Normative v1 integrity envelope separating content digest from cryptographic signature. Domain-separated signing message: PCS:::.", + "type": "object", + "required": [ + "schema_version", + "artifact_type", + "canonicalization_version", + "artifact_digest", + "signature" + ], + "additionalProperties": true, + "properties": { + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version_v1" }, + "artifact_type": { "type": "string", "minLength": 1 }, + "canonicalization_version": { + "$ref": "common.defs.json#/$defs/canonicalization_version" + }, + "artifact_digest": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "signature": { "$ref": "common.defs.json#/$defs/ed25519_signature_envelope" }, + "signature_or_digest": { + "description": "Forbidden on v1 integrity envelopes; retained only for documentation of v0 compatibility readers.", + "not": {} + } + } +} diff --git a/schemas/pcs/RuntimeReceipt.v0.schema.json b/schemas/pcs/RuntimeReceipt.v0.schema.json new file mode 100644 index 0000000..98a034c --- /dev/null +++ b/schemas/pcs/RuntimeReceipt.v0.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/RuntimeReceipt.v0.schema.json", + "title": "RuntimeReceipt.v0", + "type": "object", + "required": [ + "receipt_id", + "schema_version", + "run_id", + "environment", + "started_at", + "ended_at", + "status", + "run_outcome", + "final_reason_code", + "released", + "events_hash", + "policy_hash", + "trace_hash", + "producer", + "producer_version", + "source_repo", + "source_commit", + "input_hashes", + "output_hashes", + "signature_or_digest" + ], + "additionalProperties": false, + "properties": { + "receipt_id": { "type": "string", "minLength": 1 }, + "schema_version": { "$ref": "common.defs.json#/$defs/schema_version" }, + "run_id": { "type": "string", "minLength": 1 }, + "environment": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "started_at": { "$ref": "common.defs.json#/$defs/iso8601_datetime" }, + "ended_at": { "$ref": "common.defs.json#/$defs/iso8601_datetime" }, + "status": { "$ref": "common.defs.json#/$defs/artifact_status" }, + "run_outcome": { "type": "string", "enum": ["passed", "failed"] }, + "final_reason_code": { "type": "string", "minLength": 1 }, + "released": { "type": "boolean" }, + "events_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "policy_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "trace_hash": { "$ref": "common.defs.json#/$defs/hex_digest" }, + "producer": { "type": "string", "minLength": 1 }, + "producer_version": { "type": "string", "minLength": 1 }, + "source_repo": { "type": "string", "format": "uri" }, + "source_commit": { "type": "string", "minLength": 7 }, + "local_dev": { "type": "boolean" }, + "input_hashes": { "$ref": "common.defs.json#/$defs/hash_map" }, + "output_hashes": { "$ref": "common.defs.json#/$defs/hash_map" }, + "signature_or_digest": { "$ref": "common.defs.json#/$defs/hex_digest" } + } +} diff --git a/schemas/pcs/SCHEMA_MIRROR.json b/schemas/pcs/SCHEMA_MIRROR.json new file mode 100644 index 0000000..cda364b --- /dev/null +++ b/schemas/pcs/SCHEMA_MIRROR.json @@ -0,0 +1,22 @@ +{ + "mirror_name": "pcs-core", + "source_repo": "https://github.com/SentinelOps-CI/pcs-core", + "source_tag": "v0.1.0", + "pinned_commit": "2f0ce059652408bce19bc89ba6660030020d5ea0", + "pin_rationale": "pcs-core tag v0.1.0 for RuntimeReceipt.v0; schemas mirrored byte-for-byte for offline validation.", + "vendored_files": [ + "RuntimeReceipt.v0.schema.json", + "common.defs.json", + "ArtifactIntegrity.v1.schema.json" + ], + "local_digests": { + "RuntimeReceipt.v0.schema.json": "sha256:382506ac0b923a0ee88a9bdadf47827e2aa9afe9c2c50b10f7f7e73df8fc1241", + "common.defs.json": "sha256:f1eabfec01af7626347da861e8c95675faf4fd90f05d98b5ecd5b4c0d6b4e2a1", + "ArtifactIntegrity.v1.schema.json": "sha256:2468607a3e42d60c9ff0c1b4f461df81207581a3aa81e8dd3544ff197a052f54" + }, + "sync_policy": "Fail CI if local file digests drift from local_digests. Do not edit vendored schemas in-place; bump pin and re-vendor.", + "non_claims": [ + "Mirroring schemas does not imply PF-Core/OVK runtime integration on the default Morph path.", + "Runner emits observational RuntimeReceipt.v0 only; claim class is never upgraded." + ] +} diff --git a/schemas/pcs/common.defs.json b/schemas/pcs/common.defs.json new file mode 100644 index 0000000..dccc394 --- /dev/null +++ b/schemas/pcs/common.defs.json @@ -0,0 +1,558 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pcs.sentinelops.ci/schemas/common.defs.json", + "title": "PCS Common Definitions", + "$defs": { + "schema_version": { + "type": "string", + "pattern": "^v0$" + }, + "iso8601_datetime": { + "type": "string", + "format": "date-time" + }, + "hex_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "relative_posix_path": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Relative POSIX path: no absolute roots, drive letters, UNC, empty segments, or parent (..) traversal.", + "pattern": "^(?!/)(?![A-Za-z]:)(?!\\\\\\\\)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$" + }, + "canonicalization_version": { + "type": "string", + "const": "v1", + "description": "PCS Canonical JSON algorithm version used for artifact_digest / signature_or_digest." + }, + "schema_version_v1": { + "type": "string", + "pattern": "^v1$" + }, + "ed25519_signature_envelope": { + "type": "object", + "required": ["algorithm", "key_id", "signed_at", "value"], + "additionalProperties": false, + "properties": { + "algorithm": { "const": "ed25519" }, + "key_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "signed_at": { "$ref": "#/$defs/iso8601_datetime" }, + "value": { + "type": "string", + "minLength": 1, + "description": "Base64url or hex encoding of the ed25519 signature over the domain-separated message." + } + } + }, + "uuid": { + "type": "string", + "format": "uuid" + }, + "duration": { + "type": "string", + "format": "duration" + }, + "hostname": { + "type": "string", + "format": "hostname" + }, + "email": { + "type": "string", + "format": "email" + }, + "kernel_file_entry": { + "type": "object", + "required": ["path", "sha256"], + "additionalProperties": false, + "properties": { + "path": { "$ref": "#/$defs/relative_posix_path" }, + "sha256": { "$ref": "#/$defs/hex_digest" } + } + }, + "artifact_status": { + "type": "string", + "enum": [ + "Draft", + "Extracted", + "HumanReviewed", + "Formalized", + "ProofPending", + "ProofChecked", + "CertificatePending", + "CertificateChecked", + "RuntimeObserved", + "RuntimeChecked", + "Rejected", + "EmpiricalOnly", + "Deprecated", + "Stale" + ] + }, + "trace_certificate_status": { + "type": "string", + "enum": ["CertificatePending", "CertificateChecked", "Rejected", "Stale"] + }, + "producer_metadata": { + "type": "object", + "required": [ + "schema_version", + "created_at", + "producer", + "producer_version", + "source_repo", + "source_commit", + "status", + "signature_or_digest" + ], + "properties": { + "schema_version": { "$ref": "#/$defs/schema_version" }, + "created_at": { "$ref": "#/$defs/iso8601_datetime" }, + "producer": { "type": "string", "minLength": 1 }, + "producer_version": { "type": "string", "minLength": 1 }, + "source_repo": { "type": "string", "format": "uri" }, + "source_commit": { "type": "string", "minLength": 7 }, + "status": { "$ref": "#/$defs/artifact_status" }, + "signature_or_digest": { "$ref": "#/$defs/hex_digest" } + } + }, + "hash_map": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/hex_digest" } + }, + "ref_list": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "git_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "release_status": { + "type": "string", + "enum": ["Draft", "Validated", "Rejected", "Stale", "Deprecated"] + }, + "handoff_kind": { + "type": "string", + "enum": [ + "runtime_to_certificate", + "certificate_to_bundle", + "bundle_to_verifier", + "signed_bundle_to_memory", + "release_chain" + ] + }, + "handoff_status": { + "type": "string", + "enum": ["Draft", "Validated", "Rejected", "Stale", "Deprecated"] + }, + "release_chain_validation_status": { + "type": "string", + "enum": ["ProofChecked", "Rejected", "Stale"] + }, + "validation_check_status": { + "type": "string", + "enum": ["passed", "failed", "warning", "skipped"] + }, + "registry_check_coverage_status": { + "type": "string", + "enum": ["deferred", "skipped"] + }, + "enforcement_location": { + "type": "string", + "enum": ["artifact_validate", "release_chain", "consumer", "registry_metadata"] + }, + "deferred_registry_check": { + "type": "object", + "required": [ + "registry_ref", + "status", + "enforcement_location", + "responsible_component", + "reason" + ], + "additionalProperties": false, + "properties": { + "registry_ref": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/registry_check_coverage_status" }, + "enforcement_location": { "$ref": "#/$defs/enforcement_location" }, + "responsible_component": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + }, + "producer_repo_pin": { + "type": "object", + "required": ["repo", "commit"], + "additionalProperties": false, + "properties": { + "repo": { "type": "string", "format": "uri" }, + "commit": { "$ref": "#/$defs/git_commit" }, + "local_dev": { "type": "boolean" } + } + }, + "manifest_artifact_entry": { + "type": "object", + "required": [ + "artifact_type", + "schema", + "producer", + "source_repo", + "source_commit", + "sha256" + ], + "additionalProperties": false, + "properties": { + "artifact_type": { "type": "string", "minLength": 1 }, + "schema": { "type": "string", "minLength": 1 }, + "producer": { "type": "string", "minLength": 1 }, + "source_repo": { "type": "string", "format": "uri" }, + "source_commit": { "$ref": "#/$defs/git_commit" }, + "sha256": { "$ref": "#/$defs/hex_digest" }, + "local_dev": { "type": "boolean" } + } + }, + "handoff_artifact_ref": { + "type": "object", + "required": ["artifact_type"], + "additionalProperties": false, + "properties": { + "artifact_type": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/hex_digest" } + } + }, + "semantic_check_severity": { + "type": "string", + "enum": [ + "required", + "optional", + "warning_only", + "release_blocking", + "producer_responsible", + "consumer_responsible", + "validator_responsible" + ] + }, + "registry_semantic_check": { + "type": "object", + "required": [ + "check_id", + "severity", + "responsible_component", + "execution_required_in_release_mode", + "allowed_to_skip" + ], + "additionalProperties": false, + "properties": { + "check_id": { "type": "string", "minLength": 1 }, + "severity": { "$ref": "#/$defs/semantic_check_severity" }, + "responsible_component": { "type": "string", "minLength": 1 }, + "execution_required_in_release_mode": { "type": "boolean" }, + "allowed_to_skip": { "type": "boolean" } + } + }, + "release_validation_check": { + "type": "object", + "required": [ + "check_id", + "description", + "status", + "details", + "registry_check_refs", + "responsible_component" + ], + "additionalProperties": false, + "properties": { + "check_id": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/validation_check_status" }, + "details": { "type": "object" }, + "registry_check_refs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "responsible_component": { "type": "string", "minLength": 1 } + } + }, + "chain_root": { + "type": "object", + "required": [ + "trace_hash", + "certificate_id", + "certified_bundle_hash", + "signed_bundle_hash" + ], + "additionalProperties": false, + "properties": { + "trace_hash": { "$ref": "#/$defs/hex_digest" }, + "certificate_id": { "type": "string", "minLength": 1 }, + "certified_bundle_hash": { "$ref": "#/$defs/hex_digest" }, + "signed_bundle_hash": { "$ref": "#/$defs/hex_digest" } + } + }, + "manifest_path_ref": { + "type": "object", + "required": ["path", "sha256"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/hex_digest" } + } + }, + "limitations_notice": { + "type": "string", + "minLength": 1 + }, + "formal_check": { + "type": "object", + "required": ["check_id", "status", "artifact"], + "additionalProperties": false, + "properties": { + "check_id": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/validation_check_status" }, + "artifact": { "type": "string", "minLength": 1 }, + "lean_theorem": { "type": "string", "minLength": 1 } + } + }, + "benchmark_case_kind": { + "type": "string", + "enum": [ + "valid_release", + "invalid_hash_mismatch", + "invalid_certificate", + "invalid_handoff", + "invalid_registry", + "invalid_formal_check", + "invalid_import", + "invalid_render", + "stale_release" + ] + }, + "benchmark_responsible_component": { + "type": "string", + "enum": [ + "runtime_producer", + "certificate_producer", + "verifier", + "registry", + "formal_kernel", + "scientific_memory", + "release_manifest", + "handoff", + "hashing", + "unknown" + ] + }, + "benchmark_repair_hint_kind": { + "type": "string", + "enum": [ + "align_provenance", + "align_hash", + "align_certificate_id", + "align_handoff", + "fix_registry_entry", + "rerun_verification", + "rerun_formal_check", + "fix_import_report", + "fix_render", + "none", + "unknown" + ] + }, + "benchmark_metric_name": { + "type": "string", + "enum": [ + "release_reproducibility", + "failure_localization", + "certificate_completeness", + "registry_coverage", + "formal_check_coverage", + "scientific_memory_interpretability", + "repair_hint_quality", + "cross_domain_portability" + ] + }, + "benchmark_metric_applicability": { + "type": "string", + "enum": [ + "measured", + "not_applicable", + "insufficient_cases", + "skipped", + "failed_to_measure" + ] + }, + "benchmark_metric_id": { + "type": "string", + "enum": [ + "release_reproducibility_score", + "failure_localization_accuracy", + "certificate_completeness_score", + "registry_coverage_score", + "formal_check_coverage_score", + "scientific_memory_interpretability_score", + "repair_hint_quality_score", + "cross_domain_portability_score" + ] + }, + "benchmark_producer_id": { + "type": "string", + "enum": [ + "pcs-core", + "pcs-bench", + "provability-fabric", + "certifyedge", + "labtrust-gym", + "scientific-memory" + ] + }, + "explain_quality_section_id": { + "type": "string", + "enum": [ + "provenance", + "hashes", + "handoffs", + "verification", + "formal_checks", + "limitations", + "lineage", + "repair_hints" + ] + }, + "benchmark_observed_status": { + "type": "string", + "enum": ["passed", "failed", "skipped", "error"] + }, + "benchmark_system_outcome": { + "type": "string", + "enum": [ + "admitted", + "rejected", + "stale", + "import_failed", + "render_failed", + "query_failed", + "comparison_failed", + "formal_failed", + "certificate_rejected", + "unknown" + ] + }, + "nullable_benchmark_system_outcome": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/benchmark_system_outcome" } + ] + }, + "benchmark_detection_layer": { + "type": "string", + "enum": [ + "runtime", + "certificate", + "verifier", + "registry", + "formal_kernel", + "scientific_memory", + "release_manifest", + "handoff", + "hashing", + "labtrust", + "certifyedge", + "provability_fabric", + "pcs_core", + "unknown" + ] + }, + "metric_summary_applicability": { + "type": "string", + "enum": [ + "measured", + "not_applicable", + "insufficient_cases", + "skipped", + "failed_to_measure" + ] + }, + "system_admission_outcome": { + "type": "string", + "enum": ["admitted", "rejected", "deferred", "not_evaluated"] + }, + "release_chain_status": { + "type": "string", + "enum": ["valid", "invalid", "not_applicable"] + }, + "certificate_status_value": { + "type": "string", + "enum": ["CertificateChecked", "Rejected", "Stale", "not_applicable"] + }, + "scientific_memory_import_status": { + "type": "string", + "enum": ["passed", "failed", "not_applicable"] + }, + "scientific_memory_render_status": { + "type": "string", + "enum": ["rendered", "incomplete", "not_applicable"] + }, + "nullable_benchmark_responsible_component": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/benchmark_responsible_component" } + ] + }, + "nullable_benchmark_repair_hint_kind": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/benchmark_repair_hint_kind" } + ] + }, + "benchmark_input_artifact_ref": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "artifact_type": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 } + } + }, + "benchmark_artifact_ref_type": { + "type": "string", + "enum": [ + "BenchmarkCase.v0", + "BenchmarkRun.v0", + "CoverageReport.v0", + "FailureLocalizationResult.v0", + "ExplainQualityReport.v0", + "ProfileCoverageReport.v0", + "MetricSummary.v0" + ] + }, + "benchmark_artifact_ref_role": { + "type": "string", + "enum": ["producer_export", "ingest_bundle", "primary"] + }, + "benchmark_command_entry": { + "type": "object", + "required": ["command", "exit_code"], + "additionalProperties": false, + "properties": { + "command": { "type": "string", "minLength": 1 }, + "exit_code": { "type": "integer" }, + "stdout_digest": { "$ref": "#/$defs/hex_digest" } + } + }, + "conformance_run_ref": { + "type": "object", + "required": ["suite", "run_id"], + "additionalProperties": false, + "properties": { + "suite": { "type": "string", "minLength": 1 }, + "run_id": { "type": "string", "minLength": 1 }, + "status": { + "type": "string", + "enum": ["passed", "failed"] + } + } + } + } +} From fa411dce02acc3b17ae3d56bbadd85c3e44eaa84 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:55 -0700 Subject: [PATCH 15/31] feat: emit and validate PCS evidence artifacts Generate PCS receipts from replay runs and validate instances against the vendored schemas. --- runner/evidence/__init__.py | 51 +++++++++++ runner/evidence/pcs.py | 175 ++++++++++++++++++++++++++++++++++++ runner/evidence/validate.py | 143 +++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 runner/evidence/__init__.py create mode 100644 runner/evidence/pcs.py create mode 100644 runner/evidence/validate.py diff --git a/runner/evidence/__init__.py b/runner/evidence/__init__.py new file mode 100644 index 0000000..82eb1d9 --- /dev/null +++ b/runner/evidence/__init__.py @@ -0,0 +1,51 @@ +"""Evidence package: digests and redaction helpers.""" + +from __future__ import annotations + +import re +from typing import Any + +from runner.evidence.pcs import ( + emit_runtime_receipt, + maybe_pf_core_replay_trace, + receipt_status_for_claim_class, + terminal_state_commitment, + write_json, +) +from runner.evidence.pip import emit_morph_replay_report, emit_transformation_record +from runner.evidence.validate import SchemaValidationError, validate_instance + +SECRET_PATTERNS = ( + re.compile(r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*\S+"), + re.compile(r"sk-[A-Za-z0-9]{10,}"), + re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"), +) + + +def redact_text(text: str) -> str: + redacted = text + for pattern in SECRET_PATTERNS: + redacted = pattern.sub("[REDACTED]", redacted) + return redacted + + +def assert_no_secret_material(payload: Any) -> None: + blob = str(payload).lower() + for needle in ("sk-", "bearer ", "api_key=", "password="): + if needle in blob: + raise ValueError(f"secret material leaked into evidence: {needle}") + + +__all__ = [ + "SchemaValidationError", + "assert_no_secret_material", + "emit_morph_replay_report", + "emit_runtime_receipt", + "emit_transformation_record", + "maybe_pf_core_replay_trace", + "receipt_status_for_claim_class", + "redact_text", + "terminal_state_commitment", + "validate_instance", + "write_json", +] diff --git a/runner/evidence/pcs.py b/runner/evidence/pcs.py new file mode 100644 index 0000000..634bc47 --- /dev/null +++ b/runner/evidence/pcs.py @@ -0,0 +1,175 @@ +"""PCS RuntimeReceipt.v0 emitter and optional PF-Core gate.""" + +from __future__ import annotations + +import json +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from runner.evidence.validate import SchemaValidationError, validate_instance +from runner.hashing import canonical_hash, sha256_bytes, sha256_text + +SOURCE_REPO = "https://github.com/SentinelOps-CI/morph-replay-runner" +PRODUCER = "morph-replay-runner" +PRODUCER_VERSION = "0.1.0" + +# PCS common.defs artifact_status — ReplayValidated is a PF claim class, not a +# RuntimeReceipt.status enum member. +_RECEIPT_STATUSES = frozenset({"RuntimeObserved", "RuntimeChecked", "Draft"}) + + +class EvidenceError(RuntimeError): + """Fail-closed evidence emission error.""" + + +def _iso_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + +def receipt_status_for_claim_class(claim_class: str, *, check_ran: bool) -> str: + """Map branch claim class to a schema-legal RuntimeReceipt.status. + + ``ReplayValidated`` is recorded in environment metadata / PF invocation + evidence; the receipt status upgrades only to ``RuntimeChecked`` when the + PF-Core check actually ran. + """ + if not check_ran: + return "RuntimeObserved" + if claim_class in {"RuntimeChecked", "ReplayValidated"}: + return "RuntimeChecked" + if claim_class == "RuntimeObserved": + return "RuntimeObserved" + raise EvidenceError(f"unsupported claim_class for receipt: {claim_class}") + + +def emit_runtime_receipt( + *, + receipt_id: str, + run_id: str, + started_at: str, + ended_at: str, + run_outcome: str, + final_reason_code: str, + source_commit: str, + input_hashes: dict[str, str], + output_hashes: dict[str, str], + environment: Optional[dict[str, str]] = None, + status: str = "RuntimeObserved", + events_hash: Optional[str] = None, + policy_hash: Optional[str] = None, + trace_hash: Optional[str] = None, + released: bool = False, + local_dev: bool = True, +) -> dict[str, Any]: + """Emit RuntimeReceipt.v0 with observational semantics. + + Status defaults to RuntimeObserved. RuntimeChecked only when the + corresponding check actually ran. ReplayValidated is never a receipt + status (PCS enum); use environment claim metadata instead. + """ + if status not in _RECEIPT_STATUSES: + raise EvidenceError( + f"disallowed receipt status: {status} " + f"(allowed={sorted(_RECEIPT_STATUSES)}; " + "ReplayValidated is a PF claim class, not a RuntimeReceipt status)" + ) + + empty = sha256_text("") + body: dict[str, Any] = { + "receipt_id": receipt_id, + "schema_version": "v0", + "run_id": run_id, + "environment": environment or {"provider": "local-fake"}, + "started_at": started_at, + "ended_at": ended_at, + "status": status, + "run_outcome": run_outcome, + "final_reason_code": final_reason_code, + "released": released, + "events_hash": events_hash or empty, + "policy_hash": policy_hash or empty, + "trace_hash": trace_hash or empty, + "producer": PRODUCER, + "producer_version": PRODUCER_VERSION, + "source_repo": SOURCE_REPO, + "source_commit": source_commit, + "local_dev": local_dev, + "input_hashes": dict(sorted(input_hashes.items())), + "output_hashes": dict(sorted(output_hashes.items())), + } + digest = canonical_hash(body, enforce_number_policy=True) + body["signature_or_digest"] = digest + try: + validate_instance("RuntimeReceipt.v0", body) + except SchemaValidationError as exc: + raise EvidenceError(str(exc)) from exc + return body + + +def maybe_pf_core_replay_trace( + *, + claim_class: str, + trace_path: Optional[Path], + out_dir: Path, +) -> Optional[dict[str, Any]]: + """Invoke ``pcs pf-core replay-trace`` only when claim class applies. + + Never upgrades claim class. Returns subprocess metadata or None if skipped. + """ + if claim_class not in {"ReplayValidated", "RuntimeChecked"}: + return None + if trace_path is None or not trace_path.is_file(): + raise EvidenceError( + f"claim_class {claim_class} requires PF-Core-shaped trace file" + ) + out_dir.mkdir(parents=True, exist_ok=True) + cmd = [ + "pcs", + "pf-core", + "replay-trace", + str(trace_path), + "--out", + str(out_dir), + ] + try: + completed = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise EvidenceError( + "pcs CLI not found; cannot satisfy PF-Core claim class" + ) from exc + record = { + "command": cmd, + "exit_code": completed.returncode, + "stdout_digest": sha256_text(completed.stdout or ""), + "stderr_digest": sha256_text(completed.stderr or ""), + "claim_class": claim_class, + "upgraded": False, + } + (out_dir / "pf_core_invocation.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if completed.returncode != 0: + raise EvidenceError( + f"pcs pf-core replay-trace failed with exit {completed.returncode}" + ) + return record + + +def terminal_state_commitment(terminal: dict[str, Any]) -> str: + return canonical_hash(terminal, enforce_number_policy=True) + + +def write_json(path: Path, payload: dict[str, Any]) -> str: + data = json.dumps(payload, indent=2, sort_keys=True) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data, encoding="utf-8") + return sha256_bytes(data.encode("utf-8")) diff --git a/runner/evidence/validate.py b/runner/evidence/validate.py new file mode 100644 index 0000000..4a3c9e2 --- /dev/null +++ b/runner/evidence/validate.py @@ -0,0 +1,143 @@ +"""Vendored JSON Schema validation (always-on; no external CLI required). + +Mirrors pcs-core registry construction so CI can fail closed on instance +conformance without ``pcs validate`` / ``post-incident replay-check``. +External CLIs remain optional deeper semantic gates when installed. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any, Literal + +from jsonschema import Draft202012Validator, FormatChecker +from jsonschema.exceptions import SchemaError +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +ArtifactKind = Literal[ + "RuntimeReceipt.v0", + "pip.MorphReplayReport.v1", + "pip.TransformationRecord.v1", + "pip.LineageBundle.v1", + "mrr.ExecutionProfile.v1", + "mrr.BranchReplayManifest.v1", + "mrr.DifferentialReport.v1", + "mrr.ResourceReport.v1", +] + + +class SchemaValidationError(ValueError): + """Instance failed vendored JSON Schema validation.""" + + +def repo_root() -> Path: + """Return repository root containing ``schemas/`` (cwd or parents).""" + here = Path(__file__).resolve() + for candidate in (here.parents[2], Path.cwd(), *Path.cwd().parents): + if (candidate / "schemas").is_dir(): + return candidate + raise SchemaValidationError( + "schemas/ directory not found; run from repo root or install package data" + ) + + +def schemas_root() -> Path: + return repo_root() / "schemas" + + +SCHEMA_FILES: dict[ArtifactKind, Path] = { + "RuntimeReceipt.v0": Path("pcs/RuntimeReceipt.v0.schema.json"), + "pip.MorphReplayReport.v1": Path("pip/MorphReplayReport.schema.json"), + "pip.TransformationRecord.v1": Path("pip/TransformationRecord.schema.json"), + "pip.LineageBundle.v1": Path("pip/LineageBundle.schema.json"), + "mrr.ExecutionProfile.v1": Path("mrr/execution_profile.v1.json"), + "mrr.BranchReplayManifest.v1": Path("mrr/branch_replay_manifest.v1.json"), + "mrr.DifferentialReport.v1": Path("mrr/differential_report.v1.json"), + "mrr.ResourceReport.v1": Path("mrr/resource_report.v1.json"), +} + + +def _load_json(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SchemaValidationError(f"schema root must be object: {path}") + return data + + +@lru_cache(maxsize=1) +def _build_registry() -> Registry: + root = schemas_root() + resources: list[tuple[str, Resource[Any]]] = [] + for path in sorted(root.rglob("*.json")): + if path.name == "SCHEMA_MIRROR.json": + continue + schema = _load_json(path) + resource = Resource.from_contents(schema, default_specification=DRAFT202012) + resources.append((path.as_uri(), resource)) + resources.append((path.name, resource)) + schema_id = schema.get("$id") + if isinstance(schema_id, str) and schema_id: + resources.append((schema_id, resource)) + # Relative $ref targets used by PCS (e.g. common.defs.json#/...) + resources.append((path.name.split("/")[-1], resource)) + return Registry().with_resources(resources) + + +def _format_checker() -> FormatChecker: + checker = FormatChecker() + # Require format assertions when checkers exist (fail closed on uri/date-time). + required = {"date-time", "uri"} + missing = required - set(checker.checkers) + if missing: + raise SchemaValidationError( + "jsonschema FormatChecker missing required formats: " + f"{sorted(missing)}; install jsonschema[format]" + ) + return checker + + +@lru_cache(maxsize=16) +def _validator_for(kind: ArtifactKind) -> Draft202012Validator: + rel = SCHEMA_FILES[kind] + path = schemas_root() / rel + if not path.is_file(): + raise SchemaValidationError(f"missing vendored schema: {path}") + schema = _load_json(path) + try: + Draft202012Validator.check_schema(schema) + except SchemaError as exc: + raise SchemaValidationError(f"invalid schema {path}: {exc}") from exc + return Draft202012Validator( + schema, + registry=_build_registry(), + format_checker=_format_checker(), + ) + + +def validate_instance(kind: ArtifactKind, instance: dict[str, Any]) -> None: + """Fail closed if ``instance`` does not conform to vendored schema ``kind``.""" + if not isinstance(instance, dict): + raise SchemaValidationError(f"{kind}: instance must be a JSON object") + validator = _validator_for(kind) + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.path)) + if errors: + messages = "; ".join( + f"{'/'.join(str(p) for p in err.path) or '$'}: {err.message}" + for err in errors[:12] + ) + raise SchemaValidationError(f"{kind} schema validation failed: {messages}") + + +def validate_json_file(kind: ArtifactKind, path: Path) -> dict[str, Any]: + data = _load_json(path) + validate_instance(kind, data) + return data + + +def clear_schema_caches() -> None: + """Test helper to rebuild registry after schema tree changes.""" + _build_registry.cache_clear() + _validator_for.cache_clear() From a5a7db05ac0b550f2fadb06b3dbe71ff76682551 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:28:55 -0700 Subject: [PATCH 16/31] feat: vendor PIP lineage and morph report schemas Add TransformationRecord, LineageBundle, and MorphReplayReport contracts with mirror metadata. --- schemas/pip/LineageBundle.schema.json | 59 +++++++++++++++++++ schemas/pip/MorphReplayReport.schema.json | 43 ++++++++++++++ schemas/pip/SCHEMA_MIRROR.json | 19 ++++++ schemas/pip/TransformationRecord.schema.json | 62 ++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 schemas/pip/LineageBundle.schema.json create mode 100644 schemas/pip/MorphReplayReport.schema.json create mode 100644 schemas/pip/SCHEMA_MIRROR.json create mode 100644 schemas/pip/TransformationRecord.schema.json diff --git a/schemas/pip/LineageBundle.schema.json b/schemas/pip/LineageBundle.schema.json new file mode 100644 index 0000000..2df15a1 --- /dev/null +++ b/schemas/pip/LineageBundle.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://post-incident-proofs.local/schemas/pip/v1/LineageBundle.schema.json", + "title": "LineageBundle", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "bundle_id", + "incident_id", + "nodes", + "edges", + "transformations" + ], + "properties": { + "schema_version": { "const": "pip.LineageBundle.v1" }, + "bundle_id": { "type": "string", "minLength": 1 }, + "incident_id": { "type": "string", "minLength": 1 }, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["node_id", "artifact_digest", "role"], + "properties": { + "node_id": { "type": "string", "minLength": 1 }, + "artifact_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "role": { + "type": "string", + "enum": ["source", "intermediate", "output", "replay"] + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from_node_id", "to_node_id", "transformation_id"], + "properties": { + "from_node_id": { "type": "string", "minLength": 1 }, + "to_node_id": { "type": "string", "minLength": 1 }, + "transformation_id": { "type": "string", "minLength": 1 } + } + } + }, + "transformations": { + "type": "array", + "items": { + "$ref": "TransformationRecord.schema.json" + } + } + } +} diff --git a/schemas/pip/MorphReplayReport.schema.json b/schemas/pip/MorphReplayReport.schema.json new file mode 100644 index 0000000..867891a --- /dev/null +++ b/schemas/pip/MorphReplayReport.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://post-incident-proofs.local/schemas/pip/v1/MorphReplayReport.schema.json", + "title": "MorphReplayReport", + "description": "Local PIP subset of Morph branch-replay report fields. Does not vendor Morph runtime.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "replay_id", + "branch_id", + "replay_identity_digest", + "input_artifact_digests", + "output_artifact_digests", + "status" + ], + "properties": { + "schema_version": { "const": "pip.MorphReplayReport.v1" }, + "replay_id": { "type": "string", "minLength": 1 }, + "branch_id": { "type": "string", "minLength": 1 }, + "replay_identity_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "input_artifact_digests": { + "type": "array", + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "output_artifact_digests": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "status": { + "type": "string", + "enum": ["recorded", "mismatch", "indeterminate"] + }, + "lineage_node_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/schemas/pip/SCHEMA_MIRROR.json b/schemas/pip/SCHEMA_MIRROR.json new file mode 100644 index 0000000..4b6fd63 --- /dev/null +++ b/schemas/pip/SCHEMA_MIRROR.json @@ -0,0 +1,19 @@ +{ + "mirror_name": "post-incident-proofs", + "source_repo": "https://github.com/SentinelOps-CI/post-incident-proofs", + "pinned_commit": "3cdfdf09c20f08ad5221d29607b5a9726295ad10", + "vendored_files": [ + "LineageBundle.schema.json", + "MorphReplayReport.schema.json", + "TransformationRecord.schema.json" + ], + "local_digests": { + "LineageBundle.schema.json": "sha256:1b560005b7479f7c55484dfadcaf172ecb72fe4b995d394c633ed1af406e0463", + "MorphReplayReport.schema.json": "sha256:afca63d0df978f697eb8b60e4e1d6df08fa0e55059df0e13eaa185d3603a41fd", + "TransformationRecord.schema.json": "sha256:863c9844dbf67892f1bbc41d769d3b7a591d2fa8e05c1ba664fb5ffd78cc93d5" + }, + "sync_policy": "Fail CI on digest drift. Cross-link PCS by digest only; do not promote Morph reports into PCS.", + "non_claims": [ + "PIP emission is observational/differential linkage only." + ] +} diff --git a/schemas/pip/TransformationRecord.schema.json b/schemas/pip/TransformationRecord.schema.json new file mode 100644 index 0000000..841f299 --- /dev/null +++ b/schemas/pip/TransformationRecord.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://post-incident-proofs.local/schemas/pip/v1/TransformationRecord.schema.json", + "title": "TransformationRecord", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "transformation_id", + "transformation_type", + "input_artifact_digests", + "output_artifact_digest", + "implementation_id", + "implementation_version", + "container_digest", + "source_commit", + "record_digest" + ], + "properties": { + "schema_version": { "const": "pip.TransformationRecord.v1" }, + "transformation_id": { "type": "string", "minLength": 1 }, + "transformation_type": { + "type": "string", + "enum": [ + "redaction", + "normalization", + "event_filtering", + "state_projection", + "trace_minimization", + "dependency_substitution", + "timestamp_normalization", + "environment_conversion", + "synthetic_variant_derivation" + ] + }, + "input_artifact_digests": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "output_artifact_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "implementation_id": { "type": "string", "minLength": 1 }, + "implementation_version": { "type": "string", "minLength": 1 }, + "container_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "record_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Content digest of this record excluding record_digest itself; used for append-only immutability." + }, + "notes": { "type": "string" } + } +} From 43de0d739d43f33de41cf62b43bb20062383d8c5 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:34 -0700 Subject: [PATCH 17/31] feat: add PIP morph and lineage emitters with fixtures Produce MorphReplayReport and LineageBundle artifacts and ship valid linkage fixtures for conformance tests. --- .../valid_replay_linkage/lineage_bundle.json | 64 +++++++++++++++ .../morph_replay_report.json | 16 ++++ runner/evidence/pip.py | 78 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 fixtures/pip/valid_replay_linkage/lineage_bundle.json create mode 100644 fixtures/pip/valid_replay_linkage/morph_replay_report.json create mode 100644 runner/evidence/pip.py diff --git a/fixtures/pip/valid_replay_linkage/lineage_bundle.json b/fixtures/pip/valid_replay_linkage/lineage_bundle.json new file mode 100644 index 0000000..072da5d --- /dev/null +++ b/fixtures/pip/valid_replay_linkage/lineage_bundle.json @@ -0,0 +1,64 @@ +{ + "schema_version": "pip.LineageBundle.v1", + "bundle_id": "lin-mrr-001", + "incident_id": "inc-demo", + "nodes": [ + { + "node_id": "n0", + "artifact_digest": "sha256:289c58f19e2b126468dae353a63ea5b6d41161e8ba7ae3eada37643fb0e2b66f", + "role": "source" + }, + { + "node_id": "n1", + "artifact_digest": "sha256:8b82eb9475941110dc4bb6225594876cfabbf4676dafbeebe764c9021352df1a", + "role": "intermediate" + }, + { + "node_id": "n2", + "artifact_digest": "sha256:985a5e7b20cd925ba65f3c58921a7b5d4343000f7f19e2cf6532320d8fea0ca5", + "role": "replay" + } + ], + "edges": [ + { + "from_node_id": "n0", + "to_node_id": "n1", + "transformation_id": "t-norm" + }, + { + "from_node_id": "n1", + "to_node_id": "n2", + "transformation_id": "t-replay" + } + ], + "transformations": [ + { + "schema_version": "pip.TransformationRecord.v1", + "transformation_id": "t-norm", + "transformation_type": "normalization", + "input_artifact_digests": [ + "sha256:289c58f19e2b126468dae353a63ea5b6d41161e8ba7ae3eada37643fb0e2b66f" + ], + "output_artifact_digest": "sha256:8b82eb9475941110dc4bb6225594876cfabbf4676dafbeebe764c9021352df1a", + "implementation_id": "morph-replay-runner", + "implementation_version": "0.1.0", + "container_digest": "sha256:a42d519714d616e9411dbceec4b52808bd6b1ee53e6f6497a281d655357d8b71", + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "record_digest": "sha256:3be6704ac3c5387837079e6cc4875ede904bbd16b3937028ab6284290e2e71c7" + }, + { + "schema_version": "pip.TransformationRecord.v1", + "transformation_id": "t-replay", + "transformation_type": "synthetic_variant_derivation", + "input_artifact_digests": [ + "sha256:8b82eb9475941110dc4bb6225594876cfabbf4676dafbeebe764c9021352df1a" + ], + "output_artifact_digest": "sha256:985a5e7b20cd925ba65f3c58921a7b5d4343000f7f19e2cf6532320d8fea0ca5", + "implementation_id": "morph-replay-runner", + "implementation_version": "0.1.0", + "container_digest": "sha256:a42d519714d616e9411dbceec4b52808bd6b1ee53e6f6497a281d655357d8b71", + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "record_digest": "sha256:363f28a2711ee54e606b3cd632208e4e4713d0e526b00966fb137449b32bebeb" + } + ] +} diff --git a/fixtures/pip/valid_replay_linkage/morph_replay_report.json b/fixtures/pip/valid_replay_linkage/morph_replay_report.json new file mode 100644 index 0000000..19a7d4b --- /dev/null +++ b/fixtures/pip/valid_replay_linkage/morph_replay_report.json @@ -0,0 +1,16 @@ +{ + "schema_version": "pip.MorphReplayReport.v1", + "replay_id": "morph-replay-mrr-001", + "branch_id": "branch-00", + "replay_identity_digest": "sha256:985a5e7b20cd925ba65f3c58921a7b5d4343000f7f19e2cf6532320d8fea0ca5", + "input_artifact_digests": [ + "sha256:8b82eb9475941110dc4bb6225594876cfabbf4676dafbeebe764c9021352df1a" + ], + "output_artifact_digests": [ + "sha256:985a5e7b20cd925ba65f3c58921a7b5d4343000f7f19e2cf6532320d8fea0ca5" + ], + "status": "recorded", + "lineage_node_ids": [ + "n2" + ] +} diff --git a/runner/evidence/pip.py b/runner/evidence/pip.py new file mode 100644 index 0000000..e0b4e70 --- /dev/null +++ b/runner/evidence/pip.py @@ -0,0 +1,78 @@ +"""PIP MorphReplayReport + TransformationRecord emitters.""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from runner.evidence.validate import SchemaValidationError, validate_instance +from runner.hashing import canonical_hash + +MorphStatus = Literal["recorded", "mismatch", "indeterminate"] + + +class PipEvidenceError(ValueError): + """Fail-closed PIP emission error.""" + + +def emit_morph_replay_report( + *, + replay_id: str, + branch_id: str, + replay_identity_digest: str, + input_artifact_digests: list[str], + output_artifact_digests: list[str], + status: MorphStatus = "recorded", + lineage_node_ids: Optional[list[str]] = None, +) -> dict[str, Any]: + if not output_artifact_digests: + raise PipEvidenceError("output_artifact_digests must be non-empty") + body: dict[str, Any] = { + "schema_version": "pip.MorphReplayReport.v1", + "replay_id": replay_id, + "branch_id": branch_id, + "replay_identity_digest": replay_identity_digest, + "input_artifact_digests": list(input_artifact_digests), + "output_artifact_digests": list(output_artifact_digests), + "status": status, + "lineage_node_ids": list(lineage_node_ids or []), + } + try: + validate_instance("pip.MorphReplayReport.v1", body) + except SchemaValidationError as exc: + raise PipEvidenceError(str(exc)) from exc + return body + + +def emit_transformation_record( + *, + transformation_id: str, + transformation_type: str, + input_artifact_digests: list[str], + output_artifact_digest: str, + implementation_id: str, + implementation_version: str, + container_digest: str, + source_commit: str, + notes: Optional[str] = None, +) -> dict[str, Any]: + body: dict[str, Any] = { + "schema_version": "pip.TransformationRecord.v1", + "transformation_id": transformation_id, + "transformation_type": transformation_type, + "input_artifact_digests": list(input_artifact_digests), + "output_artifact_digest": output_artifact_digest, + "implementation_id": implementation_id, + "implementation_version": implementation_version, + "container_digest": container_digest, + "source_commit": source_commit, + } + if notes is not None: + body["notes"] = notes + body["record_digest"] = canonical_hash( + body, enforce_number_policy=True, extra_excluded=frozenset({"record_digest"}) + ) + try: + validate_instance("pip.TransformationRecord.v1", body) + except SchemaValidationError as exc: + raise PipEvidenceError(str(exc)) from exc + return body From 132bbe1b415be87c1061ceea6baa800f3b0dff63 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:34 -0700 Subject: [PATCH 18/31] feat: add content hashing helpers for digests Centralize stable hashing used by manifests, snapshots, and evidence linkage. --- runner/hashing.py | 152 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 runner/hashing.py diff --git a/runner/hashing.py b/runner/hashing.py new file mode 100644 index 0000000..d950a43 --- /dev/null +++ b/runner/hashing.py @@ -0,0 +1,152 @@ +"""PCS Canonical JSON v1 hashing (algorithm-compatible with pcs-core). + +Do not invent a second hash. ``HASH_EXCLUDED_FIELDS`` matches pcs-core +(``signature_or_digest``, ``artifact_digest``, ``signature``). Domain digests +such as ``profile_digest`` / ``record_digest`` / ``report_digest`` must be +passed via ``extra_excluded``. +""" +from __future__ import annotations + +import hashlib +import json +import math +from typing import Any + +SIGNATURE_FIELD = "signature_or_digest" +ARTIFACT_DIGEST_FIELD = "artifact_digest" +SIGNATURE_OBJECT_FIELD = "signature" +# Match pcs-core HASH_EXCLUDED_FIELDS exactly. Domain digests (profile_digest, +# record_digest, report_digest, integrity) must be passed via extra_excluded. +HASH_EXCLUDED_FIELDS = frozenset( + { + SIGNATURE_FIELD, + ARTIFACT_DIGEST_FIELD, + SIGNATURE_OBJECT_FIELD, + } +) + +CANONICALIZATION_VERSION = "v1" +SAFE_INTEGER_MIN = -9007199254740991 +SAFE_INTEGER_MAX = 9007199254740991 +REJECTION_FLOAT_PROHIBITED = "float_prohibited" +REJECTION_INTEGER_OUT_OF_RANGE = "integer_out_of_range" +REJECTION_NEGATIVE_ZERO = "negative_zero" + + +class CanonicalizationError(ValueError): + """Raised when a value cannot be represented under Canonical JSON v1 rules.""" + + def __init__(self, code: str, message: str, *, path: str = "$") -> None: + self.code = code + self.path = path + super().__init__(message) + + +def assert_canonical_number_policy(value: Any, *, path: str = "$") -> None: + """Enforce Canonical JSON v1 number policy.""" + if isinstance(value, bool): + return + if isinstance(value, int): + if value < SAFE_INTEGER_MIN or value > SAFE_INTEGER_MAX: + raise CanonicalizationError( + REJECTION_INTEGER_OUT_OF_RANGE, + f"{path}: integer {value} outside safe-integer range", + path=path, + ) + return + if isinstance(value, float): + if value == 0.0 and math.copysign(1.0, value) < 0.0: + raise CanonicalizationError( + REJECTION_NEGATIVE_ZERO, + f"{path}: negative zero is prohibited", + path=path, + ) + raise CanonicalizationError( + REJECTION_FLOAT_PROHIBITED, + f"{path}: float values are prohibited; use decimal strings", + path=path, + ) + if isinstance(value, dict): + for key, child in value.items(): + assert_canonical_number_policy(child, path=f"{path}.{key}") + return + if isinstance(value, list): + for index, child in enumerate(value): + assert_canonical_number_policy(child, path=f"{path}[{index}]") + + +def _sort_keys(value: Any) -> Any: + if isinstance(value, dict): + return {k: _sort_keys(value[k]) for k in sorted(value)} + if isinstance(value, list): + return [_sort_keys(item) for item in value] + return value + + +def canonicalize_for_hash( + data: dict[str, Any], + *, + enforce_number_policy: bool = False, + extra_excluded: frozenset[str] | None = None, +) -> dict[str, Any]: + """Return a copy suitable for hashing (integrity fields removed, keys sorted).""" + excluded = ( + HASH_EXCLUDED_FIELDS + if extra_excluded is None + else (HASH_EXCLUDED_FIELDS | extra_excluded) + ) + payload = {k: v for k, v in data.items() if k not in excluded} + if enforce_number_policy: + assert_canonical_number_policy(payload) + sorted_payload = _sort_keys(payload) + assert isinstance(sorted_payload, dict) + return sorted_payload + + +def canonical_json_bytes( + data: dict[str, Any], + *, + enforce_number_policy: bool = False, + extra_excluded: frozenset[str] | None = None, +) -> bytes: + canonical = canonicalize_for_hash( + data, + enforce_number_policy=enforce_number_policy, + extra_excluded=extra_excluded, + ) + return json.dumps(canonical, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + + +def canonical_hash( + data: dict[str, Any], + *, + enforce_number_policy: bool = False, + extra_excluded: frozenset[str] | None = None, +) -> str: + digest = hashlib.sha256( + canonical_json_bytes( + data, + enforce_number_policy=enforce_number_policy, + extra_excluded=extra_excluded, + ) + ).hexdigest() + return f"sha256:{digest}" + + +def sha256_file(path: str) -> str: + """Return ``sha256:`` for file bytes.""" + h = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" + + +def sha256_bytes(data: bytes) -> str: + return f"sha256:{hashlib.sha256(data).hexdigest()}" + + +def sha256_text(text: str) -> str: + return sha256_bytes(text.encode("utf-8")) From 2e8e78c9e980056dcb6bef04aea51538039ed682 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:35 -0700 Subject: [PATCH 19/31] feat: add OVK package namespace stub Reserve the OVK integration surface so optional verification hooks can land without reshaping imports. --- runner/ovk/__init__.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 runner/ovk/__init__.py diff --git a/runner/ovk/__init__.py b/runner/ovk/__init__.py new file mode 100644 index 0000000..b60d134 --- /dev/null +++ b/runner/ovk/__init__.py @@ -0,0 +1,53 @@ +"""OVK shell-out only; do not vendor OVK schemas.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any, Optional + +from runner.hashing import sha256_file, sha256_text + + +class OvkError(RuntimeError): + """OVK invocation failure.""" + + +def resolve_ovk_cli() -> str: + path = shutil.which("ovk") or shutil.which("open-verification-kernel") + if path is None: + raise OvkError("OVK CLI not found on PATH") + return path + + +def run_ovk_check( + *, + target: Path, + profile: Optional[str] = None, + out_dir: Optional[Path] = None, +) -> dict[str, Any]: + """Shell out to OVK checker; return digests/refs only.""" + cli = resolve_ovk_cli() + cmd = [cli, "check", str(target)] + if profile: + cmd.extend(["--profile", profile]) + completed = subprocess.run(cmd, check=False, capture_output=True, text=True) + record: dict[str, Any] = { + "tool": "ovk", + "tool_version_pin": "1.2.1", + "command": cmd, + "exit_code": completed.returncode, + "stdout_digest": sha256_text(completed.stdout or ""), + "stderr_digest": sha256_text(completed.stderr or ""), + "target_digest": sha256_file(str(target)) if target.is_file() else None, + } + if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "ovk_invocation.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + if completed.returncode != 0: + raise OvkError(f"OVK check failed with exit {completed.returncode}") + return record From fb562a0e8f15c98fe087f696ebf025022a177235 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:35 -0700 Subject: [PATCH 20/31] feat: wire CLI and core around hermetic branch-N flow Refactor entrypoints to use profiles, providers, and evidence paths; keep legacy core available for compatibility. --- runner/__init__.py | 2 +- runner/core.py | 229 +++++---------------- runner/core_fixed.py | 362 -------------------------------- runner/legacy_core.py | 8 + runner/main.py | 468 ++++++++++++++++++++++++++++++------------ runner/models.py | 59 +++--- 6 files changed, 432 insertions(+), 696 deletions(-) delete mode 100644 runner/core_fixed.py create mode 100644 runner/legacy_core.py diff --git a/runner/__init__.py b/runner/__init__.py index 2cf9dd4..2ac7de1 100644 --- a/runner/__init__.py +++ b/runner/__init__.py @@ -1,4 +1,4 @@ -"""Morph Replay Runner - CLI tool for running TRACE-REPLAY-KIT bundles with branch-N parallelism on Morph Cloud.""" +"""Hermetic branch-N replay runner with observational PCS/PIP evidence.""" __version__ = "0.1.0" __author__ = "SentinelOps-CI" diff --git a/runner/core.py b/runner/core.py index fadf331..ed6a45a 100644 --- a/runner/core.py +++ b/runner/core.py @@ -1,15 +1,16 @@ -"""Core runner logic for executing replay bundles on Morph Cloud.""" +"""Core runner logic for executing replay bundles on Morph Cloud.""" + +from __future__ import annotations import asyncio import json import os import time -from typing import List +from datetime import datetime, timezone +from typing import Any, Union from morphcloud.api import MorphCloudClient -# Standard exceptions for error handling - from .models import ( ExecutionResult, ExecutionSummary, @@ -21,18 +22,17 @@ class ReplayRunner: """Main runner class for executing replay bundles on Morph Cloud.""" - def __init__(self, config: RunnerConfig): + def __init__(self, config: RunnerConfig) -> None: """Initialize the replay runner.""" self.config = config self.client = MorphCloudClient() self.summary = ExecutionSummary() - # Ensure output directories exist os.makedirs(f"{config.output_directory}/certs", exist_ok=True) os.makedirs(f"{config.output_directory}/logs", exist_ok=True) os.makedirs(f"{config.output_directory}/reports", exist_ok=True) - def run_sync(self, bundle_paths: List[str]) -> ExecutionSummary: + def run_sync(self, bundle_paths: list[str]) -> ExecutionSummary: """Run replay bundles synchronously.""" bundles = [ReplayBundle.from_path(path) for path in bundle_paths] @@ -40,59 +40,49 @@ def run_sync(self, bundle_paths: List[str]) -> ExecutionSummary: print(f"Using snapshot: {self.config.snapshot_id}") print(f"Parallel instances: {self.config.parallel_count}") - # Get base snapshot try: base_snapshot = self.client.snapshots.get(self.config.snapshot_id) print(f"✓ Base snapshot loaded: {base_snapshot.id}") - except Exception as e: - print(f"✗ Failed to load snapshot: {e}") + except Exception as exc: + print(f"✗ Failed to load snapshot: {exc}") return self.summary - # Start base instance try: base_instance = self.client.instances.start(snapshot_id=base_snapshot.id) base_instance.wait_until_ready() print(f"✓ Base instance started: {base_instance.id}") - except Exception as e: - print(f"✗ Failed to start base instance: {e}") + except Exception as exc: + print(f"✗ Failed to start base instance: {exc}") return self.summary + branches: list[Any] = [] try: - # Create branch instances - branches = base_instance.branch(count=self.config.parallel_count) + branches = list(base_instance.branch(count=self.config.parallel_count)) print(f"✓ Created {len(branches)} branch instances") - # Execute bundles in parallel - results = [] for i, bundle in enumerate(bundles): branch_idx = i % len(branches) branch = branches[branch_idx] - result = self._execute_bundle_sync(branch, bundle, i) - results.append(result) self.summary.add_result(result) - print(f"Bundle {i+1}/{len(bundles)}: {result.status}") - # Generate summary report self._generate_summary_report() - finally: - # Cleanup instances print("Cleaning up instances...") for branch in branches: try: branch.stop() - except Exception: - pass + except Exception as cleanup_exc: + print(f"WARNING: branch stop failed during cleanup: {cleanup_exc}") try: base_instance.stop() - except Exception: - pass + except Exception as cleanup_exc: + print(f"WARNING: base stop failed during cleanup: {cleanup_exc}") return self.summary - async def run_async(self, bundle_paths: List[str]) -> ExecutionSummary: + async def run_async(self, bundle_paths: list[str]) -> ExecutionSummary: """Run replay bundles asynchronously.""" bundles = [ReplayBundle.from_path(path) for path in bundle_paths] @@ -100,46 +90,40 @@ async def run_async(self, bundle_paths: List[str]) -> ExecutionSummary: print(f"Using snapshot: {self.config.snapshot_id}") print(f"Parallel instances: {self.config.parallel_count}") - # Get base snapshot try: base_snapshot = await self.client.snapshots.aget(self.config.snapshot_id) print(f"✓ Base snapshot loaded: {base_snapshot.id}") - except Exception as e: - print(f"✗ Failed to load snapshot: {e}") + except Exception as exc: + print(f"✗ Failed to load snapshot: {exc}") return self.summary - # Start base instance try: base_instance = await self.client.instances.astart( snapshot_id=base_snapshot.id ) await base_instance.await_until_ready() print(f"✓ Base instance started: {base_instance.id}") - except Exception as e: - print(f"✗ Failed to start base instance: {e}") + except Exception as exc: + print(f"✗ Failed to start base instance: {exc}") return self.summary + branches: list[Any] = [] try: - # Create branch instances - branches = base_instance.branch(count=self.config.parallel_count) + branches = list(base_instance.branch(count=self.config.parallel_count)) print(f"✓ Created {len(branches)} branch instances") - # Execute bundles in parallel tasks = [] for i, bundle in enumerate(bundles): branch_idx = i % len(branches) branch = branches[branch_idx] + tasks.append(self._execute_bundle_async(branch, bundle, i)) - task = self._execute_bundle_async(branch, bundle, i) - tasks.append(task) - - # Wait for all executions to complete - results = await asyncio.gather(*tasks, return_exceptions=True) + results: list[Union[ExecutionResult, BaseException]] = await asyncio.gather( + *tasks, return_exceptions=True + ) - # Process results for i, result in enumerate(results): - if isinstance(result, Exception): - # Create error result + if isinstance(result, BaseException): error_result = ExecutionResult( bundle_path=str(bundles[i].path), status="ERROR", @@ -147,41 +131,36 @@ async def run_async(self, bundle_paths: List[str]) -> ExecutionSummary: error_message=str(result), ) self.summary.add_result(error_result) + print(f"Bundle {i+1}/{len(bundles)}: ERROR") else: self.summary.add_result(result) + print(f"Bundle {i+1}/{len(bundles)}: {result.status}") - print(f"Bundle {i+1}/{len(bundles)}: {result.status}") - - # Generate summary report await self._generate_summary_report_async() - finally: - # Cleanup instances print("Cleaning up instances...") for branch in branches: try: await branch.astop() - except Exception: - pass + except Exception as cleanup_exc: + print(f"WARNING: branch astop failed during cleanup: {cleanup_exc}") try: await base_instance.astop() - except Exception: - pass + except Exception as cleanup_exc: + print(f"WARNING: base astop failed during cleanup: {cleanup_exc}") return self.summary def _execute_bundle_sync( - self, instance, bundle: ReplayBundle, bundle_index: int + self, instance: Any, bundle: ReplayBundle, bundle_index: int ) -> ExecutionResult: """Execute a single bundle synchronously.""" start_time = time.time() try: - # Copy bundle to instance remote_path = f"/tmp/replay_{bundle_index}.zip" instance.copy(str(bundle.path), remote_path) - # Execute replay command if self.config.emit_cert: cmd = ( f"replay --in {remote_path} " @@ -192,21 +171,17 @@ def _execute_bundle_sync( with instance.ssh() as ssh: result = ssh.run(cmd) - - # Collect output stdout = result.stdout stderr = result.stderr exit_code = result.exit_code - # Determine status if exit_code == 0: status = "PASS" - elif result.timed_out: + elif getattr(result, "timed_out", False): status = "TIMEOUT" else: status = "FAIL" - # Copy certificate if generated cert_path = None if self.config.emit_cert and exit_code == 0: local_cert_path = ( @@ -216,18 +191,14 @@ def _execute_bundle_sync( try: instance.copy(f"/tmp/cert_{bundle_index}.json", local_cert_path) cert_path = local_cert_path - except Exception as e: - print(f"Warning: Failed to copy cert: {e}") + except Exception as exc: + print(f"Warning: Failed to copy cert: {exc}") - # Save log - log_path = ( - f"{self.config.output_directory}/logs/" f"log_{bundle_index}.txt" - ) - with open(log_path, "w") as f: - f.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n") + log_path = f"{self.config.output_directory}/logs/log_{bundle_index}.txt" + with open(log_path, "w", encoding="utf-8") as handle: + handle.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n") execution_time_ms = int((time.time() - start_time) * 1000) - return ExecutionResult( bundle_path=str(bundle.path), bundle_hash=bundle.hash, @@ -235,106 +206,33 @@ def _execute_bundle_sync( execution_time_ms=execution_time_ms, cert_path=cert_path, log_path=log_path, - instance_id=instance.id, + instance_id=getattr(instance, "id", None), error_message=None if status == "PASS" else stderr, ) - - except Exception as e: + except Exception as exc: execution_time_ms = int((time.time() - start_time) * 1000) return ExecutionResult( bundle_path=str(bundle.path), bundle_hash=bundle.hash, status="ERROR", execution_time_ms=execution_time_ms, - error_message=str(e), + error_message=str(exc), ) async def _execute_bundle_async( - self, instance, bundle: ReplayBundle, bundle_index: int + self, instance: Any, bundle: ReplayBundle, bundle_index: int ) -> ExecutionResult: - """Execute a single bundle asynchronously.""" - start_time = time.time() - - try: - # Copy bundle to instance - remote_path = f"/tmp/replay_{bundle_index}.zip" - instance.copy(str(bundle.path), remote_path) - - # Execute replay command - if self.config.emit_cert: - cmd = ( - f"replay --in {remote_path} " - f"--emit /tmp/cert_{bundle_index}.json" - ) - else: - cmd = f"replay --in {remote_path}" + """Execute a single bundle asynchronously (SSH still sync via Morph SDK).""" + return await asyncio.to_thread( + self._execute_bundle_sync, instance, bundle, bundle_index + ) - with instance.ssh() as ssh: - result = ssh.run(cmd) - - # Collect output - stdout = result.stdout - stderr = result.stderr - exit_code = result.exit_code - - # Determine status - if exit_code == 0: - status = "PASS" - elif result.timed_out: - status = "TIMEOUT" - else: - status = "FAIL" - - # Copy certificate if generated - cert_path = None - if self.config.emit_cert and exit_code == 0: - local_cert_path = ( - f"{self.config.output_directory}/certs/" - f"cert_{bundle_index}.json" - ) - try: - instance.copy(f"/tmp/cert_{bundle_index}.json", local_cert_path) - cert_path = local_cert_path - except Exception as e: - print(f"Warning: Failed to copy cert: {e}") - - # Save log - log_path = ( - f"{self.config.output_directory}/logs/" f"log_{bundle_index}.txt" - ) - with open(log_path, "w") as f: - f.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n") - - execution_time_ms = int((time.time() - start_time) * 1000) - - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status=status, - execution_time_ms=execution_time_ms, - cert_path=cert_path, - log_path=log_path, - instance_id=instance.id, - error_message=None if status == "PASS" else stderr, - ) - - except Exception as e: - execution_time_ms = int((time.time() - start_time) * 1000) - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status="ERROR", - execution_time_ms=execution_time_ms, - error_message=str(e), - ) - - def _generate_summary_report(self): + def _generate_summary_report(self) -> None: """Generate summary report synchronously.""" - self.summary.end_time = None # Will be set by validator - + self.summary.end_time = datetime.now(timezone.utc).replace(tzinfo=None) report_path = f"{self.config.output_directory}/reports/index.json" - with open(report_path, "w") as f: - json.dump(self.summary.dict(), f, indent=2, default=str) + with open(report_path, "w", encoding="utf-8") as handle: + json.dump(self.summary.model_dump(), handle, indent=2, default=str) print("\nExecution Summary:") print(f" Total bundles: {self.summary.total_bundles}") @@ -342,22 +240,9 @@ def _generate_summary_report(self): print(f" Failed: {self.summary.failed}") print(f" Timed out: {self.summary.timed_out}") print(f" Success rate: {self.summary.success_rate:.1f}%") - print(f" Total time: " f"{self.summary.total_execution_time_ms/1000:.1f}s") + print(f" Total time: {self.summary.total_execution_time_ms/1000:.1f}s") print(f" Report saved to: {report_path}") - async def _generate_summary_report_async(self): + async def _generate_summary_report_async(self) -> None: """Generate summary report asynchronously.""" - self.summary.end_time = None # Will be set by validator - - report_path = f"{self.config.output_directory}/reports/index.json" - with open(report_path, "w") as f: - json.dump(self.summary.dict(), f, indent=2, default=str) - - print("\nExecution Summary:") - print(f" Total bundles: {self.summary.total_bundles}") - print(f" Successful: {self.summary.successful}") - print(f" Failed: {self.summary.failed}") - print(f" Timed out: {self.summary.timed_out}") - print(f" Success rate: {self.summary.success_rate:.1f}%") - print(f" Total time: " f"{self.summary.total_execution_time_ms/1000:.1f}s") - print(f" Report saved to: {report_path}") + await asyncio.to_thread(self._generate_summary_report) diff --git a/runner/core_fixed.py b/runner/core_fixed.py deleted file mode 100644 index a9d9c7f..0000000 --- a/runner/core_fixed.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Core runner logic for executing replay bundles on Morph Cloud.""" - -import asyncio -import json -import os -import time -from typing import List - -from morphcloud.api import MorphCloudClient -from morphcloud.exceptions import MorphCloudError - -from .models import ( - ExecutionResult, - ExecutionSummary, - ReplayBundle, - RunnerConfig, -) - - -class ReplayRunner: - """Main runner class for executing replay bundles on Morph Cloud.""" - - def __init__(self, config: RunnerConfig): - """Initialize the replay runner.""" - self.config = config - self.client = MorphCloudClient() - self.summary = ExecutionSummary() - - # Ensure output directories exist - os.makedirs(f"{config.output_directory}/certs", exist_ok=True) - os.makedirs(f"{config.output_directory}/logs", exist_ok=True) - os.makedirs(f"{config.output_directory}/reports", exist_ok=True) - - def run_sync(self, bundle_paths: List[str]) -> ExecutionSummary: - """Run replay bundles synchronously.""" - bundles = [ReplayBundle.from_path(path) for path in bundle_paths] - - print(f"Starting replay execution for {len(bundles)} bundles...") - print(f"Using snapshot: {self.config.snapshot_id}") - print(f"Parallel instances: {self.config.parallel_count}") - - # Get base snapshot - try: - base_snapshot = self.client.snapshots.get(self.config.snapshot_id) - print(f"✓ Base snapshot loaded: {base_snapshot.id}") - except MorphCloudError as e: - print(f"✗ Failed to load snapshot: {e}") - return self.summary - - # Start base instance - try: - base_instance = self.client.instances.start(snapshot_id=base_snapshot.id) - base_instance.wait_until_ready() - print(f"✓ Base instance started: {base_instance.id}") - except MorphCloudError as e: - print(f"✗ Failed to start base instance: {e}") - return self.summary - - try: - # Create branch instances - branches = base_instance.branch(count=self.config.parallel_count) - print(f"✓ Created {len(branches)} branch instances") - - # Execute bundles in parallel - results = [] - for i, bundle in enumerate(bundles): - branch_idx = i % len(branches) - branch = branches[branch_idx] - - result = self._execute_bundle_sync(branch, bundle, i) - results.append(result) - self.summary.add_result(result) - - print(f"Bundle {i+1}/{len(bundles)}: {result.status}") - - # Generate summary report - self._generate_summary_report() - - finally: - # Cleanup instances - print("Cleaning up instances...") - for branch in branches: - try: - branch.stop() - except Exception: - pass - try: - base_instance.stop() - except Exception: - pass - - return self.summary - - async def run_async(self, bundle_paths: List[str]) -> ExecutionSummary: - """Run replay bundles asynchronously.""" - bundles = [ReplayBundle.from_path(path) for path in bundle_paths] - - print(f"Starting async replay execution for {len(bundles)} bundles...") - print(f"Using snapshot: {self.config.snapshot_id}") - print(f"Parallel instances: {self.config.parallel_count}") - - # Get base snapshot - try: - base_snapshot = await self.client.snapshots.aget(self.config.snapshot_id) - print(f"✓ Base snapshot loaded: {base_snapshot.id}") - except MorphCloudError as e: - print(f"✗ Failed to load snapshot: {e}") - return self.summary - - # Start base instance - try: - base_instance = await self.client.instances.astart( - snapshot_id=base_snapshot.id - ) - await base_instance.await_until_ready() - print(f"✓ Base instance started: {base_instance.id}") - except MorphCloudError as e: - print(f"✗ Failed to start base instance: {e}") - return self.summary - - try: - # Create branch instances - branches = base_instance.branch(count=self.config.parallel_count) - print(f"✓ Created {len(branches)} branch instances") - - # Execute bundles in parallel - tasks = [] - for i, bundle in enumerate(bundles): - branch_idx = i % len(branches) - branch = branches[branch_idx] - - task = self._execute_bundle_async(branch, bundle, i) - tasks.append(task) - - # Wait for all executions to complete - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Process results - for i, result in enumerate(results): - if isinstance(result, Exception): - # Create error result - error_result = ExecutionResult( - bundle_path=str(bundles[i].path), - status="ERROR", - execution_time_ms=0, - error_message=str(result), - ) - self.summary.add_result(error_result) - else: - self.summary.add_result(result) - - print(f"Bundle {i+1}/{len(bundles)}: {result.status}") - - # Generate summary report - await self._generate_summary_report_async() - - finally: - # Cleanup instances - print("Cleaning up instances...") - for branch in branches: - try: - await branch.astop() - except Exception: - pass - try: - await base_instance.astop() - except Exception: - pass - - return self.summary - - def _execute_bundle_sync( - self, instance, bundle: ReplayBundle, bundle_index: int - ) -> ExecutionResult: - """Execute a single bundle synchronously.""" - start_time = time.time() - - try: - # Copy bundle to instance - remote_path = f"/tmp/replay_{bundle_index}.zip" - instance.copy(str(bundle.path), remote_path) - - # Execute replay command - if self.config.emit_cert: - cmd = ( - f"replay --in {remote_path} " - f"--emit /tmp/cert_{bundle_index}.json" - ) - else: - cmd = f"replay --in {remote_path}" - - with instance.ssh() as ssh: - result = ssh.run(cmd) - - # Collect output - stdout = result.stdout - stderr = result.stderr - exit_code = result.exit_code - - # Determine status - if exit_code == 0: - status = "PASS" - elif result.timed_out: - status = "TIMEOUT" - else: - status = "FAIL" - - # Copy certificate if generated - cert_path = None - if self.config.emit_cert and exit_code == 0: - local_cert_path = ( - f"{self.config.output_directory}/certs/" - f"cert_{bundle_index}.json" - ) - try: - instance.copy(f"/tmp/cert_{bundle_index}.json", local_cert_path) - cert_path = local_cert_path - except Exception as e: - print(f"Warning: Failed to copy cert: {e}") - - # Save log - log_path = ( - f"{self.config.output_directory}/logs/" f"log_{bundle_index}.txt" - ) - with open(log_path, "w") as f: - f.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n") - - execution_time_ms = int((time.time() - start_time) * 1000) - - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status=status, - execution_time_ms=execution_time_ms, - cert_path=cert_path, - log_path=log_path, - instance_id=instance.id, - error_message=None if status == "PASS" else stderr, - ) - - except Exception as e: - execution_time_ms = int((time.time() - start_time) * 1000) - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status="ERROR", - execution_time_ms=execution_time_ms, - error_message=str(e), - ) - - async def _execute_bundle_async( - self, instance, bundle: ReplayBundle, bundle_index: int - ) -> ExecutionResult: - """Execute a single bundle asynchronously.""" - start_time = time.time() - - try: - # Copy bundle to instance - remote_path = f"/tmp/replay_{bundle_index}.zip" - instance.copy(str(bundle.path), remote_path) - - # Execute replay command - if self.config.emit_cert: - cmd = ( - f"replay --in {remote_path} " - f"--emit /tmp/cert_{bundle_index}.json" - ) - else: - cmd = f"replay --in {remote_path}" - - with instance.ssh() as ssh: - result = ssh.run(cmd) - - # Collect output - stdout = result.stdout - stderr = result.stderr - exit_code = result.exit_code - - # Determine status - if exit_code == 0: - status = "PASS" - elif result.timed_out: - status = "TIMEOUT" - else: - status = "FAIL" - - # Copy certificate if generated - cert_path = None - if self.config.emit_cert and exit_code == 0: - local_cert_path = ( - f"{self.config.output_directory}/certs/" - f"cert_{bundle_index}.json" - ) - try: - instance.copy(f"/tmp/cert_{bundle_index}.json", local_cert_path) - cert_path = local_cert_path - except Exception as e: - print(f"Warning: Failed to copy cert: {e}") - - # Save log - log_path = ( - f"{self.config.output_directory}/logs/" f"log_{bundle_index}.txt" - ) - with open(log_path, "w") as f: - f.write(f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}\n") - - execution_time_ms = int((time.time() - start_time) * 1000) - - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status=status, - execution_time_ms=execution_time_ms, - cert_path=cert_path, - log_path=log_path, - instance_id=instance.id, - error_message=None if status == "PASS" else stderr, - ) - - except Exception as e: - execution_time_ms = int((time.time() - start_time) * 1000) - return ExecutionResult( - bundle_path=str(bundle.path), - bundle_hash=bundle.hash, - status="ERROR", - execution_time_ms=execution_time_ms, - error_message=str(e), - ) - - def _generate_summary_report(self): - """Generate summary report synchronously.""" - self.summary.end_time = None # Will be set by validator - - report_path = f"{self.config.output_directory}/reports/index.json" - with open(report_path, "w") as f: - json.dump(self.summary.dict(), f, indent=2, default=str) - - print("\nExecution Summary:") - print(f" Total bundles: {self.summary.total_bundles}") - print(f" Successful: {self.summary.successful}") - print(f" Failed: {self.summary.failed}") - print(f" Timed out: {self.summary.timed_out}") - print(f" Success rate: {self.summary.success_rate:.1f}%") - print(f" Total time: " f"{self.summary.total_execution_time_ms/1000:.1f}s") - print(f" Report saved to: {report_path}") - - async def _generate_summary_report_async(self): - """Generate summary report asynchronously.""" - self.summary.end_time = None # Will be set by validator - - report_path = f"{self.config.output_directory}/reports/index.json" - with open(report_path, "w") as f: - json.dump(self.summary.dict(), f, indent=2, default=str) - - print("\nExecution Summary:") - print(f" Total bundles: {self.summary.total_bundles}") - print(f" Successful: {self.summary.successful}") - print(f" Failed: {self.summary.failed}") - print(f" Timed out: {self.summary.timed_out}") - print(f" Success rate: {self.summary.success_rate:.1f}%") - print(f" Total time: " f"{self.summary.total_execution_time_ms/1000:.1f}s") - print(f" Report saved to: {report_path}") diff --git a/runner/legacy_core.py b/runner/legacy_core.py new file mode 100644 index 0000000..e8fbaf6 --- /dev/null +++ b/runner/legacy_core.py @@ -0,0 +1,8 @@ +"""Legacy ZIP Morph path preserved as compatibility wrapper.""" + +from __future__ import annotations + +# Re-export prior core implementation under a stable name for migration. +from runner.core import ReplayRunner + +__all__ = ["ReplayRunner"] diff --git a/runner/main.py b/runner/main.py index 45c4338..c375e70 100644 --- a/runner/main.py +++ b/runner/main.py @@ -1,181 +1,385 @@ -"""Main CLI entry point for the Morph Replay Runner.""" +"""CLI entry point: run (compat), branch, profile, diff, validate.""" + +from __future__ import annotations import asyncio import glob +import hashlib +import json import sys +import tempfile +from pathlib import Path import click from rich.console import Console -from .core import ReplayRunner -from .models import RunnerConfig +from runner.diff import diff_branch_dirs, parse_pairs +from runner.hermeticity import HermeticityError +from runner.legacy_core import ReplayRunner +from runner.models import HttpCallbackConfig, RunnerConfig +from runner.profile import ProfileValidationError, load_profile, profile_digest +from runner.providers import ProviderError, get_provider console = Console() -@click.command() -@click.option( - "--snapshot", - required=True, - help="Base snapshot ID or digest containing sidecar + replay tools", -) -@click.option( - "--bundles", - required=True, - help="Glob pattern for replay bundles (e.g., ./replays/*.zip)", -) -@click.option( - "--parallel", - "-p", - default=4, - type=int, - help="Number of parallel instances (default: 4)", -) -@click.option( - "--timeout", - "-t", - default=600, - type=int, - help="Execution timeout in seconds (default: 600)", -) -@click.option( - "--emit-cert/--no-emit-cert", - default=True, - help="Emit CERT-V1 JSON certificates (default: True)", -) -@click.option( - "--out", - "-o", - default="./evidence", - help="Output directory for evidence collection (default: ./evidence)", -) -@click.option( - "--async", "use_async", is_flag=True, help="Use asynchronous execution mode" -) -@click.option( - "--http-callback", is_flag=True, help="Enable HTTP callback service for demos" -) -@click.option( - "--http-port", - default=8080, - type=int, - help="Port for HTTP callback service (default: 8080)", -) +def _compat_argv(argv: list[str]) -> list[str]: + """Preserve ``replay-runner --snapshot …`` by inserting ``run`` when needed.""" + if not argv: + return argv + commands = { + "run", + "branch", + "profile", + "diff", + "validate", + "--help", + "-h", + "--version", + } + if argv[0] in commands: + return argv + if argv[0].startswith("-"): + return ["run", *argv] + return argv + + +@click.group() +@click.version_option(version="0.1.0") +def cli() -> None: + """Hermetic branch-N replay runner (observational evidence only).""" + + +@cli.command("run") +@click.option("--snapshot", required=True, help="Base snapshot ID or digest") +@click.option("--bundles", required=True, help="Glob for replay bundles (*.zip)") +@click.option("--parallel", "-p", default=4, type=int) +@click.option("--timeout", "-t", default=600, type=int) +@click.option("--emit-cert/--no-emit-cert", default=True) +@click.option("--out", "-o", default="./evidence") +@click.option("--async", "use_async", is_flag=True) +@click.option("--http-callback", is_flag=True) +@click.option("--http-port", default=8080, type=int) @click.option( "--http-auth", default="none", type=click.Choice(["none", "api_key"]), - help="HTTP callback authentication mode (default: none)", ) -@click.version_option(version="0.1.0") -def main( - snapshot, - bundles, - parallel, - timeout, - emit_cert, - out, - use_async, - http_callback, - http_port, - http_auth, -): - """Morph Replay Runner - Execute TRACE-REPLAY-KIT bundles with branch-N parallelism.""" - - # Validate inputs +@click.option( + "--provider", + default="morph", + type=click.Choice(["morph", "local-fake"]), + help="Execution provider (default morph for legacy run path)", +) +def run_cmd( + snapshot: str, + bundles: str, + parallel: int, + timeout: int, + emit_cert: bool, + out: str, + use_async: bool, + http_callback: bool, + http_port: int, + http_auth: str, + provider: str, +) -> None: + """Compatibility ZIP-bundle Morph path (legacy).""" + if http_callback: + console.print( + "[red]HTTP callback is out of scope for hermetic branch-N and " + "is not implemented; refusing to run (fail-closed).[/red]" + ) + sys.exit(2) + + if provider != "morph": + console.print( + "[red]Legacy run path requires --provider morph; " + "use `replay-runner branch` for local-fake.[/red]" + ) + sys.exit(2) + if parallel < 1 or parallel > 100: console.print("[red]Error: Parallel count must be between 1 and 100[/red]") sys.exit(1) - if timeout < 60: console.print("[red]Error: Timeout must be at least 60 seconds[/red]") sys.exit(1) - # Resolve bundle paths - bundle_paths = glob.glob(bundles) + bundle_paths = [p for p in glob.glob(bundles) if p.endswith(".zip")] if not bundle_paths: - console.print(f"[red]Error: No bundles found matching pattern: {bundles}[/red]") - sys.exit(1) - - # Filter for zip files - bundle_paths = [p for p in bundle_paths if p.endswith(".zip")] - if not bundle_paths: - console.print( - f"[red]Error: No .zip files found matching pattern: {bundles}[/red]" - ) + console.print(f"[red]Error: No .zip bundles matching {bundles}[/red]") sys.exit(1) - console.print(f"[green]Found {len(bundle_paths)} replay bundles[/green]") - for path in bundle_paths: - console.print(f" [blue]{path}[/blue]") - - # Create configuration config = RunnerConfig( snapshot_id=snapshot, parallel_count=parallel, timeout_seconds=timeout, emit_cert=emit_cert, output_directory=out, - http_callback={ - "enabled": http_callback, - "auth_mode": http_auth, - "port": http_port, - }, + http_callback=HttpCallbackConfig( + enabled=False, auth_mode=http_auth, port=http_port + ), ) - - # Create runner runner = ReplayRunner(config) - - # Display configuration - console.print("\n[bold]Configuration:[/bold]") - console.print(f" Snapshot: [blue]{snapshot}[/blue]") - console.print(f" Parallel instances: [blue]{parallel}[/blue]") - console.print(f" Timeout: [blue]{timeout}s[/blue]") - console.print(f" Emit certificates: [blue]{emit_cert}[/blue]") - console.print(f" Output directory: [blue]{out}[/blue]") - console.print(f" HTTP callback: [blue]{http_callback}[/blue]") - if http_callback: - console.print(f" HTTP port: [blue]{http_port}[/blue]") - console.print(f" HTTP auth: [blue]{http_auth}[/blue]") - - # Execute try: if use_async: - console.print("\n[bold]Starting asynchronous execution...[/bold]") summary = asyncio.run(runner.run_async(bundle_paths)) else: - console.print("\n[bold]Starting synchronous execution...[/bold]") summary = runner.run_sync(bundle_paths) - - # Display final results - console.print("\n[bold green]Execution completed![/bold green]") - console.print(f" Total bundles: [blue]{summary.total_bundles}[/blue]") - console.print(f" Successful: [green]{summary.successful}[/green]") - console.print(f" Failed: [red]{summary.failed}[/red]") - console.print(f" Timed out: [yellow]{summary.timed_out}[/yellow]") - console.print(f" Success rate: [blue]{summary.success_rate:.1f}%[/blue]") console.print( - f" Total time: " - f"[blue]{summary.total_execution_time_ms/1000:.1f}s[/blue]" + f"successful={summary.successful} failed={summary.failed} " + f"timed_out={summary.timed_out}" ) + if summary.failed or summary.timed_out: + sys.exit(1) + except Exception as exc: + console.print(f"[red]Execution failed: {exc}[/red]") + sys.exit(1) - # Check for failures - if summary.failed > 0 or summary.timed_out > 0: - console.print( - "\n[yellow]Some bundles failed or timed out. " - "Check logs for details.[/yellow]" + +@cli.command("branch") +@click.option("--incident", type=click.Path(exists=True, path_type=Path), required=True) +@click.option("--snapshot", type=click.Path(exists=True, path_type=Path), required=True) +@click.option( + "--interventions", + type=click.Path(exists=True, file_okay=False, path_type=Path), + required=True, +) +@click.option( + "--execution-profile", + type=click.Path(exists=True, path_type=Path), + required=True, +) +@click.option("--manifest", type=click.Path(exists=True, path_type=Path), default=None) +@click.option("--parallel", default=16, type=int) +@click.option("--out", type=click.Path(path_type=Path), required=True) +@click.option( + "--provider", + default="local-fake", + type=click.Choice(["local-fake", "morph"]), +) +@click.option("--provider-root", type=click.Path(path_type=Path), default=None) +@click.option( + "--snapshot-payload", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Raw snapshot bytes whose sha256 must match snapshot_digest", +) +@click.option( + "--allow-partial/--fail-on-partial", + default=False, + help="Exit 0 when any branch is partial (default: fail closed)", +) +@click.option( + "--ovk-check", + type=click.Path(exists=True, path_type=Path), + default=None, + help="If set, shell out to OVK checker on PATH (fail-closed if missing)", +) +def branch_cmd( + incident: Path, + snapshot: Path, + interventions: Path, + execution_profile: Path, + manifest: Path | None, + parallel: int, + out: Path, + provider: str, + provider_root: Path | None, + snapshot_payload: Path | None, + allow_partial: bool, + ovk_check: Path | None, +) -> None: + """Hermetic isolated branch-N execution.""" + from runner.branch import run_branch_job + + tmp: tempfile.TemporaryDirectory[str] | None = None + try: + if provider == "local-fake": + root = provider_root + if root is None: + tmp = tempfile.TemporaryDirectory(prefix="mrr-fake-") + root = Path(tmp.name) + snap_doc = json.loads(snapshot.read_text(encoding="utf-8")) + payload_path = snapshot_payload or snapshot.with_name( + snapshot.stem + ".payload" + ) + if not payload_path.is_file(): + raise ProviderError( + f"snapshot payload required at {payload_path} " + "(bytes must hash to snapshot_digest)" + ) + raw = payload_path.read_bytes() + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + if digest != snap_doc["snapshot_digest"]: + raise ProviderError( + f"snapshot payload digest {digest} != pinned " + f"{snap_doc['snapshot_digest']}" + ) + provider_obj = get_provider("local-fake", root=str(root)) + provider_obj.register_snapshot( # type: ignore[attr-defined] + snap_doc["snapshot_id"], raw, digest=digest ) - sys.exit(1) else: - console.print("\n[green]All bundles executed successfully![/green]") - sys.exit(0) - - except KeyboardInterrupt: - console.print("\n[yellow]Execution interrupted by user[/yellow]") - sys.exit(130) - except Exception as e: - console.print(f"\n[red]Execution failed: {e}[/red]") + provider_obj = get_provider("morph") + + summary = run_branch_job( + provider=provider_obj, + profile_path=execution_profile, + manifest_path=manifest, + incident_path=incident, + snapshot_path=snapshot, + interventions_dir=interventions, + parallel=parallel, + out_dir=out, + ) + if ovk_check is not None: + from runner.ovk import OvkError, run_ovk_check + + try: + run_ovk_check(target=ovk_check, out_dir=out / "ovk") + except OvkError as exc: + console.print(f"[red]OVK check failed: {exc}[/red]") + sys.exit(2) + + branches = summary.get("branches", {}) + incomplete = [ + bid + for bid, result in branches.items() + if result.get("status") != "complete" + ] + console.print( + json.dumps( + { + "branches": list(branches.keys()), + "complete": len(branches) - len(incomplete), + "partial": len(incomplete), + } + ) + ) + if incomplete and not allow_partial: + console.print( + f"[red]fail-closed: {len(incomplete)} branch(es) not complete " + f"({', '.join(sorted(incomplete))}); " + "pass --allow-partial to accept explicit partial records[/red]" + ) + sys.exit(3) + except (HermeticityError, ProviderError, ProfileValidationError, ValueError) as exc: + console.print(f"[red]{exc}[/red]") + sys.exit(2) + except Exception as exc: + console.print(f"[red]branch failed: {exc}[/red]") sys.exit(1) + finally: + if tmp is not None: + tmp.cleanup() + + +@cli.group("profile") +def profile_group() -> None: + """ExecutionProfile utilities.""" + + +@profile_group.command("validate") +@click.option( + "--file", + "file_path", + type=click.Path(exists=True, path_type=Path), + required=True, +) +def profile_validate(file_path: Path) -> None: + try: + profile = load_profile(file_path) + except ProfileValidationError as exc: + console.print(f"[red]invalid: {exc}[/red]") + sys.exit(2) + console.print(f"valid digest={profile.profile_digest}") + + +@profile_group.command("digest") +@click.option( + "--file", + "file_path", + type=click.Path(exists=True, path_type=Path), + required=True, +) +def profile_digest_cmd(file_path: Path) -> None: + try: + profile = load_profile(file_path) + except ProfileValidationError as exc: + console.print(f"[red]invalid: {exc}[/red]") + sys.exit(2) + console.print(profile.profile_digest or profile_digest(profile)) + + +@cli.command("diff") +@click.option( + "--branches", + type=click.Path(exists=True, file_okay=False, path_type=Path), + required=True, +) +@click.option("--pairs", required=True, help="Pairs as a,b;c,d") +@click.option("--out", type=click.Path(path_type=Path), required=True) +def diff_cmd(branches: Path, pairs: str, out: Path) -> None: + try: + parsed = parse_pairs(pairs) + reports = diff_branch_dirs(branches, parsed, out) + except Exception as exc: + console.print(f"[red]diff failed: {exc}[/red]") + sys.exit(2) + console.print(f"wrote {len(reports)} differential report(s) to {out}") + + +@cli.command("validate") +@click.option("--schema-mirrors/--no-schema-mirrors", default=True) +@click.option( + "--fixtures/--no-fixtures", + default=True, + help="Validate fixture/example instances against vendored schemas", +) +@click.option( + "--external-clis/--no-external-clis", + default=False, + help="Also run pcs/post-incident CLIs when installed (skip-only-when-absent)", +) +def validate_cmd(schema_mirrors: bool, fixtures: bool, external_clis: bool) -> None: + import runpy + import subprocess + + root = Path(__file__).resolve().parents[1] + if schema_mirrors: + script = root / "scripts" / "check_schema_mirrors.py" + sys.argv = [str(script)] + try: + runpy.run_path(str(script), run_name="__main__") + except SystemExit as exc: + if exc.code not in (0, None): + raise + if fixtures: + fixture_script = root / "scripts" / "validate_vendored_instances.py" + sys.argv = [str(fixture_script)] + try: + runpy.run_path(str(fixture_script), run_name="__main__") + except SystemExit as exc: + if exc.code not in (0, None): + raise + if external_clis: + optional = root / "scripts" / "optional_external_validate.py" + completed = subprocess.run( + [sys.executable, str(optional)], check=False, capture_output=True, text=True + ) + console.print(completed.stdout or "") + if completed.stderr: + console.print(completed.stderr) + if completed.returncode not in (0,): + sys.exit(completed.returncode) + console.print("ok") + + +def main() -> None: + sys.argv = [sys.argv[0], *_compat_argv(sys.argv[1:])] + cli.main(prog_name="replay-runner") if __name__ == "__main__": diff --git a/runner/models.py b/runner/models.py index ea27dc8..511fdb6 100644 --- a/runner/models.py +++ b/runner/models.py @@ -1,12 +1,14 @@ -"""Data models for the Morph Replay Runner.""" +"""Data models for the Morph Replay Runner (legacy ZIP path).""" + +from __future__ import annotations import hashlib import os -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -from typing import List, Optional +from typing import Optional -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator, model_validator class HttpCallbackConfig(BaseModel): @@ -29,11 +31,12 @@ class RunnerConfig(BaseModel): output_directory: str = "./evidence" http_callback: HttpCallbackConfig = Field(default_factory=HttpCallbackConfig) - @validator("output_directory") - def validate_output_directory(cls, v): + @field_validator("output_directory") + @classmethod + def validate_output_directory(cls, value: str) -> str: """Ensure output directory exists and is writable.""" - os.makedirs(v, exist_ok=True) - return v + os.makedirs(value, exist_ok=True) + return value class ExecutionResult(BaseModel): @@ -49,21 +52,21 @@ class ExecutionResult(BaseModel): error_message: Optional[str] = None http_service_url: Optional[str] = None - @validator("bundle_hash", pre=True, always=True) - def compute_bundle_hash(cls, v, values): + @model_validator(mode="after") + def compute_bundle_hash(self) -> ExecutionResult: """Compute SHA-256 hash of the bundle file if not provided.""" - if v is None and "bundle_path" in values: - bundle_path = values["bundle_path"] - if os.path.exists(bundle_path): - with open(bundle_path, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() - return v + if self.bundle_hash is None and os.path.exists(self.bundle_path): + with open(self.bundle_path, "rb") as handle: + self.bundle_hash = hashlib.sha256(handle.read()).hexdigest() + return self class ExecutionSummary(BaseModel): """Summary of all replay executions.""" - start_time: datetime = Field(default_factory=datetime.utcnow) + start_time: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) end_time: Optional[datetime] = None total_bundles: int = 0 successful: int = 0 @@ -71,14 +74,14 @@ class ExecutionSummary(BaseModel): timed_out: int = 0 total_execution_time_ms: int = 0 average_execution_time_ms: float = 0.0 - results: List[ExecutionResult] = Field(default_factory=list) + results: list[ExecutionResult] = Field(default_factory=list) - @validator("end_time", pre=True, always=True) - def set_end_time(cls, v): + @model_validator(mode="after") + def set_end_time(self) -> ExecutionSummary: """Set end time if not provided.""" - if v is None: - return datetime.utcnow() - return v + if self.end_time is None: + self.end_time = datetime.now(timezone.utc).replace(tzinfo=None) + return self @property def success_rate(self) -> float: @@ -87,12 +90,11 @@ def success_rate(self) -> float: return 0.0 return (self.successful / self.total_bundles) * 100 - def add_result(self, result: ExecutionResult): + def add_result(self, result: ExecutionResult) -> None: """Add an execution result and update summary statistics.""" self.results.append(result) self.total_bundles = len(self.results) - # Update counters if result.status == "PASS": self.successful += 1 elif result.status == "FAIL": @@ -100,7 +102,6 @@ def add_result(self, result: ExecutionResult): elif result.status == "TIMEOUT": self.timed_out += 1 - # Update timing statistics self.total_execution_time_ms += result.execution_time_ms self.average_execution_time_ms = ( self.total_execution_time_ms / self.total_bundles @@ -116,15 +117,15 @@ class ReplayBundle(BaseModel): created_at: datetime @classmethod - def from_path(cls, bundle_path: str) -> "ReplayBundle": + def from_path(cls, bundle_path: str) -> ReplayBundle: """Create a ReplayBundle from a file path.""" path = Path(bundle_path) if not path.exists(): raise FileNotFoundError(f"Bundle not found: {bundle_path}") stat = path.stat() - with open(path, "rb") as f: - bundle_hash = hashlib.sha256(f.read()).hexdigest() + with open(path, "rb") as handle: + bundle_hash = hashlib.sha256(handle.read()).hexdigest() return cls( path=path, From 4241e7abbda7d02617ece06ad5b23d2efa636968 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:50 -0700 Subject: [PATCH 21/31] test: add unit coverage for profile providers and reports Exercise import smoke, accounting, diff, PCS, profile, and provider modules offline. --- tests/__init__.py | 1 + tests/conftest.py | 3 + tests/unit/__init__.py | 1 + tests/unit/test_accounting.py | 60 ++++++++++++++++++++ tests/unit/test_diff.py | 49 +++++++++++++++++ tests/unit/test_import_smoke.py | 46 ++++++++++++++++ tests/unit/test_pcs_evidence.py | 80 +++++++++++++++++++++++++++ tests/unit/test_profile.py | 55 +++++++++++++++++++ tests/unit/test_providers.py | 97 +++++++++++++++++++++++++++++++++ 9 files changed, 392 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_accounting.py create mode 100644 tests/unit/test_diff.py create mode 100644 tests/unit/test_import_smoke.py create mode 100644 tests/unit/test_pcs_evidence.py create mode 100644 tests/unit/test_profile.py create mode 100644 tests/unit/test_providers.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ba69e09 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Pytest package marker.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9c5b18d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +"""Shared pytest configuration.""" + +from __future__ import annotations diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..46e0b2c --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit test package.""" diff --git a/tests/unit/test_accounting.py b/tests/unit/test_accounting.py new file mode 100644 index 0000000..79e9cf5 --- /dev/null +++ b/tests/unit/test_accounting.py @@ -0,0 +1,60 @@ +"""Resource accounting tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from runner.accounting import ( + BudgetExceededError, + assert_unavailable_not_fabricated, + enforce_budgets, + sample_to_report, +) +from runner.profile import load_profile +from runner.providers.base import ExecRequest, MetricValue, ResourceSample +from runner.providers.local_fake import LocalFakeProvider + + +def test_unavailable_stays_unavailable(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s", b"x") + handle = provider.clone(snap, branch_id="b") + provider.execute(handle, ExecRequest(command=["replay"], timeout_ms=1000)) + sample = provider.measure(handle) + assert_unavailable_not_fabricated(sample) + report = sample_to_report("b", sample) + assert report["metrics"]["accelerator_alloc"]["availability"] == "unavailable" + assert report["metrics"]["accelerator_alloc"]["value"] is None + + +def test_budget_enforcement_fail_closed() -> None: + profile = load_profile("fixtures/profile/valid_profile.json") + sample = ResourceSample( + metrics={ + "wall_time_ms": MetricValue( + value=profile.budgets.wall_time_ms + 1, + unit="ms", + availability="observed", + source="provider", + ) + } + ) + with pytest.raises(BudgetExceededError): + enforce_budgets(sample, profile.budgets) + + +def test_fabricated_unavailable_rejected() -> None: + sample = ResourceSample( + metrics={ + "accelerator_alloc": MetricValue( + value=1, + unit="count", + availability="unavailable", + source="provider", + ) + } + ) + with pytest.raises(ValueError): + assert_unavailable_not_fabricated(sample) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py new file mode 100644 index 0000000..092bb94 --- /dev/null +++ b/tests/unit/test_diff.py @@ -0,0 +1,49 @@ +"""Differential report tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from runner.diff import build_differential_report, compare_values, diff_branch_dirs + + +def test_absent_not_equal() -> None: + assert compare_values(None, None)["result"] == "absent_both" + assert compare_values(None, 1)["result"] == "absent_left" + assert compare_values(1, None)["result"] == "absent_right" + assert compare_values(1, 1)["result"] == "equal" + assert compare_values(1, 2)["result"] == "different" + + +def test_diff_reproducible(tmp_path: Path) -> None: + branches = tmp_path / "branches" + for name, terminal in (("a", {"x": 1}), ("b", {"x": 2})): + d = branches / name + d.mkdir(parents=True) + (d / "status.json").write_text( + json.dumps({"status": "complete"}), encoding="utf-8" + ) + (d / "terminal_state.json").write_text(json.dumps(terminal), encoding="utf-8") + (d / "branch_report.json").write_text("{}", encoding="utf-8") + out1 = tmp_path / "d1" + out2 = tmp_path / "d2" + r1 = diff_branch_dirs(branches, [("a", "b")], out1)[0] + r2 = diff_branch_dirs(branches, [("a", "b")], out2)[0] + assert r1["report_digest"] == r2["report_digest"] + assert r1["comparisons"]["terminal_state"]["result"] == "different" + assert r1["comparisons"]["reward"]["result"] == "absent_both" + blob = json.dumps(r1["comparisons"]).lower() + assert "preferred_branch" not in blob + assert "remediation_rank" not in blob + assert "No preferred branch." in r1["non_claims"] + + +def test_build_report_digest_stable() -> None: + left = {"terminal_state": {"a": 1}, "reward": None} + right = {"terminal_state": {"a": 1}, "reward": None} + r1 = build_differential_report("l", "r", left, right) + r2 = build_differential_report("l", "r", left, right) + assert r1["report_digest"] == r2["report_digest"] + assert r1["comparisons"]["terminal_state"]["result"] == "equal" + assert r1["comparisons"]["reward"]["result"] == "absent_both" diff --git a/tests/unit/test_import_smoke.py b/tests/unit/test_import_smoke.py new file mode 100644 index 0000000..8d9e081 --- /dev/null +++ b/tests/unit/test_import_smoke.py @@ -0,0 +1,46 @@ +"""Import smoke tests for MRR-ITE-00 quality scaffolding.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_package_imports() -> None: + import runner + from runner import hashing + + assert runner.__version__ == "0.1.0" + assert hashing.CANONICALIZATION_VERSION == "v1" + + +def test_baseline_doc_fields_present() -> None: + text = Path("docs/baseline/MRR-ITE-00.md").read_text(encoding="utf-8") + for field in ( + "Base commit", + "Python", + "PCS", + "PIP", + "OVK", + "Non-claims", + "Baseline commands", + ): + assert field in text + + +def test_architecture_adr_checklist() -> None: + text = Path("docs/adr/MRR-ITE-00-architecture.md").read_text(encoding="utf-8") + for needle in ( + "Provider boundary", + "Claim classes", + "Explicit non-claims", + "HTTP callback", + "LocalFakeProvider", + "RuntimeObserved", + ): + assert needle in text + + +def test_license_apache() -> None: + text = Path("LICENSE").read_text(encoding="utf-8") + assert "Apache License" in text + assert "Version 2.0" in text diff --git a/tests/unit/test_pcs_evidence.py b/tests/unit/test_pcs_evidence.py new file mode 100644 index 0000000..ff52f14 --- /dev/null +++ b/tests/unit/test_pcs_evidence.py @@ -0,0 +1,80 @@ +"""PCS RuntimeReceipt emission tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.evidence.pcs import EvidenceError, emit_runtime_receipt + + +def test_runtime_receipt_observational() -> None: + receipt = emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + assert receipt["status"] == "RuntimeObserved" + assert receipt["schema_version"] == "v0" + assert receipt["signature_or_digest"].startswith("sha256:") + + +def test_disallowed_status_without_check() -> None: + with pytest.raises(EvidenceError): + emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={}, + output_hashes={}, + status="CertificateChecked", + ) + + +def test_receipt_validates_full_vendored_schema() -> None: + from runner.evidence.validate import validate_instance + + receipt = emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + validate_instance("RuntimeReceipt.v0", receipt) + + +def test_receipt_schema_shape_against_mirror() -> None: + schema = json.loads( + Path("schemas/pcs/RuntimeReceipt.v0.schema.json").read_text(encoding="utf-8") + ) + assert schema["title"] == "RuntimeReceipt.v0" + required = set(schema["required"]) + receipt = emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + assert required.issubset(receipt.keys()) diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py new file mode 100644 index 0000000..e94705b --- /dev/null +++ b/tests/unit/test_profile.py @@ -0,0 +1,55 @@ +"""ExecutionProfile validation and digest tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.profile import ( + ProfileValidationError, + load_profile, + profile_digest, + validate_profile, +) + +FIX = Path("fixtures/profile") + + +def test_valid_profile_loads() -> None: + profile = load_profile(FIX / "valid_profile.json") + assert profile.profile_digest is not None + assert profile.profile_digest.startswith("sha256:") + + +def test_digest_drift_on_mutation() -> None: + profile = load_profile(FIX / "valid_profile.json") + base = profile_digest(profile) + mutated = profile.model_copy( + update={"tool_versions": {**profile.tool_versions, "replay-runner": "0.1.1"}} + ) + assert profile_digest(mutated) != base + + +def test_unknown_schema_version_fails() -> None: + raw = json.loads((FIX / "invalid_schema_version.json").read_text(encoding="utf-8")) + with pytest.raises(ProfileValidationError): + validate_profile(raw) + + +def test_missing_material_pin_fails() -> None: + raw = json.loads((FIX / "invalid_missing_pin.json").read_text(encoding="utf-8")) + with pytest.raises(ProfileValidationError): + validate_profile(raw) + + +def test_secret_values_rejected() -> None: + raw = json.loads((FIX / "invalid_secret_value.json").read_text(encoding="utf-8")) + with pytest.raises(ProfileValidationError): + validate_profile(raw) + + +def test_secret_ref_only_allowed() -> None: + profile = load_profile(FIX / "valid_profile.json") + assert profile.secret_refs[0].secret_ref == "vault/morph/api" diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py new file mode 100644 index 0000000..c9c0455 --- /dev/null +++ b/tests/unit/test_providers.py @@ -0,0 +1,97 @@ +"""LocalFakeProvider and Morph capability contract tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from runner.hermeticity import HermeticityError, HermeticityMode, HermeticityPolicy +from runner.providers.base import ExecRequest, ProviderError, SnapshotRef +from runner.providers.local_fake import LocalFakeProvider +from runner.providers.morph import MorphCloudProvider + + +def test_fake_clone_isolation(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s1", b"base-bytes") + b1 = provider.clone(snap, branch_id="b1") + b2 = provider.clone(snap, branch_id="b2") + p1 = Path(b1.root_path) / "fs" / "marker" + p1.write_text("one", encoding="utf-8") + assert not (Path(b2.root_path) / "fs" / "marker").exists() + assert (Path(b1.root_path) / "root.bin").read_bytes() == b"base-bytes" + assert (Path(b2.root_path) / "root.bin").read_bytes() == b"base-bytes" + + +def test_fake_supports_sixteen_concurrent(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s1", b"base") + handles = [provider.clone(snap, branch_id=f"b{i}") for i in range(16)] + assert len(handles) == 16 + for handle in handles: + result = provider.execute( + handle, ExecRequest(command=["replay"], timeout_ms=1000) + ) + assert result.status.value == "PASSED" + provider.teardown(handle) + + +def test_fake_measure_unavailable_accelerator(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s1", b"base") + handle = provider.clone(snap, branch_id="b1") + provider.execute(handle, ExecRequest(command=["replay"], timeout_ms=1000)) + sample = provider.measure(handle) + assert sample.metrics["accelerator_alloc"].availability == "unavailable" + assert sample.metrics["accelerator_alloc"].value is None + assert sample.metrics["wall_time_ms"].availability == "observed" + + +def test_morph_missing_capability_fail_closed() -> None: + provider = MorphCloudProvider(api_key="test", client=object()) + assert provider.supports(HermeticityMode.NO_NETWORK) + assert not provider.supports(HermeticityMode.ALLOWLISTED_NETWORK) + provider.hermeticity = HermeticityPolicy(mode=HermeticityMode.ALLOWLISTED_NETWORK) + # execute path raises before cloud call when mode unsupported + from runner.providers.base import BranchHandle + + with pytest.raises(HermeticityError): + provider.execute( + BranchHandle( + branch_id="x", + snapshot_id="s", + snapshot_digest="sha256:" + "a" * 64, + root_path="morph://x", + provider_ref="x", + ), + ExecRequest(command=["true"], timeout_ms=1000), + ) + + +def test_morph_metric_contract_documents_unavailable() -> None: + assert "cpu_time_ms" in MorphCloudProvider.UNSUPPORTED_METRICS + provider = MorphCloudProvider(api_key="test", client=object()) + sample = provider.measure( + __import__("runner.providers.base", fromlist=["BranchHandle"]).BranchHandle( + branch_id="x", + snapshot_id="s", + snapshot_digest="sha256:" + "a" * 64, + root_path="morph://x", + provider_ref="x", + ) + ) + assert sample.metrics["cpu_time_ms"].availability == "unavailable" + + +def test_snapshot_digest_enforced(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s1", b"base") + with pytest.raises(ProviderError): + provider.lookup_snapshot( + SnapshotRef(snapshot_id="s1", snapshot_digest="sha256:" + "0" * 64) + ) + got = provider.lookup_snapshot( + SnapshotRef(snapshot_id="s1", snapshot_digest=snap.snapshot_digest) + ) + assert got.snapshot_digest == snap.snapshot_digest From 7c312598bbb0ca347e3ea9921dcf50e45ff75cd7 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:50 -0700 Subject: [PATCH 22/31] test: add hermetic mode and branch scheduler suites Verify mode gates and branch-N scheduling against fixtures without network access. --- tests/hermetic/__init__.py | 0 tests/hermetic/test_branch_scheduler.py | 232 ++++++++++++++++++++++++ tests/hermetic/test_modes.py | 133 ++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 tests/hermetic/__init__.py create mode 100644 tests/hermetic/test_branch_scheduler.py create mode 100644 tests/hermetic/test_modes.py diff --git a/tests/hermetic/__init__.py b/tests/hermetic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hermetic/test_branch_scheduler.py b/tests/hermetic/test_branch_scheduler.py new file mode 100644 index 0000000..17ada83 --- /dev/null +++ b/tests/hermetic/test_branch_scheduler.py @@ -0,0 +1,232 @@ +"""Branch-N scheduler acceptance tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.branch import run_branch_job +from runner.branch.manifest import load_manifest +from runner.hermeticity import HermeticityError +from runner.profile import load_profile +from runner.providers.local_fake import LocalFakeProvider + + +def _provider_with_fixture_snapshot(tmp_path: Path) -> LocalFakeProvider: + snap = json.loads( + Path("fixtures/branch/snapshot_ref.json").read_text(encoding="utf-8") + ) + payload = Path("fixtures/branch/snapshot.payload").read_bytes() + provider = LocalFakeProvider(tmp_path) + provider.register_snapshot( + snap["snapshot_id"], payload, digest=snap["snapshot_digest"] + ) + return provider + + +def test_sixteen_branches_isolated(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + out = tmp_path / "branches" + summary = run_branch_job( + provider=provider, + profile_path=Path("fixtures/profile/valid_profile.json"), + manifest_path=Path("fixtures/branch/manifest.json"), + interventions_dir=Path("fixtures/branch/interventions"), + parallel=16, + out_dir=out, + ) + assert len(summary["branches"]) == 16 + roots: set[str] = set() + markers: list[str] = [] + for branch_id, result in summary["branches"].items(): + assert result["status"] == "complete", branch_id + status = json.loads( + (out / branch_id / "status.json").read_text(encoding="utf-8") + ) + assert status["status"] == "complete" + assert (out / branch_id / "runtime_receipt.json").is_file() + assert (out / branch_id / "execution_profile.json").is_file() + receipt = json.loads( + (out / branch_id / "runtime_receipt.json").read_text(encoding="utf-8") + ) + assert receipt["status"] == "RuntimeObserved" + from runner.evidence.validate import validate_instance + + validate_instance("RuntimeReceipt.v0", receipt) + validate_instance( + "pip.MorphReplayReport.v1", + json.loads( + (out / branch_id / "morph_replay_report.json").read_text( + encoding="utf-8" + ) + ), + ) + # Isolation: each branch writes its own status content keyed by id + marker = (out / branch_id / "status.json").read_text(encoding="utf-8") + assert branch_id in marker + markers.append(marker) + roots.add(str((out / branch_id).resolve())) + assert len(roots) == 16 + assert len(set(markers)) == 16 + + +def test_profile_digest_drift_detected(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + with pytest.raises(HermeticityError, match="digest drift"): + run_branch_job( + provider=provider, + profile_path=Path("fixtures/profile/valid_profile.json"), + manifest_path=Path("fixtures/branch/manifest_digest_drift.json"), + interventions_dir=Path("fixtures/branch/interventions"), + parallel=2, + out_dir=tmp_path / "out", + ) + + +def test_timeout_and_retry_from_clean(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + profile = load_profile("fixtures/profile/valid_profile.json") + manifest = load_manifest("fixtures/branch/manifest.json") + # Shrink to one timeout branch + branch = manifest.branches[0].model_copy( + update={"action_sequence": ["__timeout__"], "branch_id": "timeout-branch"} + ) + manifest = manifest.model_copy( + update={ + "branches": [branch], + "retry_policy": manifest.retry_policy.model_copy( + update={"max_retries": 1, "retry_on": ["TIMEOUT"]} + ), + } + ) + # Force tiny timeout via profile budgets + profile = profile.model_copy( + update={ + "budgets": profile.budgets.model_copy(update={"wall_time_ms": 1}), + "profile_digest": None, + } + ) + from runner.profile.schema import profile_digest + + digest = profile_digest(profile) + profile = profile.model_copy(update={"profile_digest": digest}) + manifest = manifest.model_copy(update={"execution_profile_digest": digest}) + + from runner.branch.scheduler import BranchScheduler, SchedulerConfig + + # Write intervention file + iv = tmp_path / "iv" + iv.mkdir() + (iv / "timeout-branch.json").write_text("{}", encoding="utf-8") + branch = manifest.branches[0].model_copy( + update={ + "intervention": manifest.branches[0].intervention.model_copy( + update={"artifact_paths": ["timeout-branch.json"]} + ) + } + ) + manifest = manifest.model_copy(update={"branches": [branch]}) + + scheduler = BranchScheduler( + provider, + profile, + manifest, + SchedulerConfig(parallel=1, out_dir=tmp_path / "out", interventions_dir=iv), + ) + summary = scheduler.run() + result = summary["branches"]["timeout-branch"] + assert result["terminal"] == "TIMEOUT" + assert result["status"] == "partial" + assert result["attempts"] >= 1 + + +def test_partial_failure_independence(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + profile = load_profile("fixtures/profile/valid_profile.json") + manifest = load_manifest("fixtures/branch/manifest.json") + good = manifest.branches[0] + bad = manifest.branches[1].model_copy( + update={"branch_id": "fail-branch", "action_sequence": ["__fail__"]} + ) + # Fix intervention paths for fail branch file + iv = tmp_path / "iv" + iv.mkdir() + (iv / good.intervention.artifact_paths[0]).write_text("{}", encoding="utf-8") + (iv / "fail-branch.json").write_text("{}", encoding="utf-8") + bad = bad.model_copy( + update={ + "intervention": bad.intervention.model_copy( + update={"artifact_paths": ["fail-branch.json"]} + ) + } + ) + manifest = manifest.model_copy(update={"branches": [good, bad]}) + from runner.branch.scheduler import BranchScheduler, SchedulerConfig + + summary = BranchScheduler( + provider, + profile, + manifest, + SchedulerConfig(parallel=2, out_dir=tmp_path / "out", interventions_dir=iv), + ).run() + assert summary["branches"][good.branch_id]["status"] == "complete" + assert summary["branches"]["fail-branch"]["status"] == "partial" + + +def test_elevated_claim_class_without_pf_trace_fails(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + profile = load_profile("fixtures/profile/valid_profile.json") + manifest = load_manifest("fixtures/branch/manifest.json") + branch = manifest.branches[0].model_copy( + update={"claim_class": "RuntimeChecked", "branch_id": "checked-branch"} + ) + iv = tmp_path / "iv" + iv.mkdir() + (iv / "checked-branch.json").write_text("{}", encoding="utf-8") + branch = branch.model_copy( + update={ + "intervention": branch.intervention.model_copy( + update={"artifact_paths": ["checked-branch.json"]} + ) + } + ) + manifest = manifest.model_copy(update={"branches": [branch]}) + from runner.branch.scheduler import BranchScheduler, SchedulerConfig + + summary = BranchScheduler( + provider, + profile, + manifest, + SchedulerConfig(parallel=1, out_dir=tmp_path / "out", interventions_dir=iv), + ).run() + result = summary["branches"]["checked-branch"] + assert result["status"] == "partial" + err = result.get("error", "").lower() + assert any( + needle in err + for needle in ("pf-core", "pf_trace", "claim_class", "pcs cli", "requires") + ) + + +def test_cleanup_after_success_and_failure(tmp_path: Path) -> None: + provider = _provider_with_fixture_snapshot(tmp_path / "prov") + out = tmp_path / "branches" + run_branch_job( + provider=provider, + profile_path=Path("fixtures/profile/valid_profile.json"), + manifest_path=Path("fixtures/branch/manifest.json"), + interventions_dir=Path("fixtures/branch/interventions"), + parallel=4, + out_dir=out, + ) + # Provider branch workdirs cleaned + assert list((tmp_path / "prov" / "branches").glob("*")) == [] + for branch_dir in out.iterdir(): + if branch_dir.is_dir() and branch_dir.name.startswith("branch-"): + receipt = json.loads( + (branch_dir / "teardown_receipt.json").read_text(encoding="utf-8") + ) + assert receipt["cleaned"] is True + assert receipt["secrets_scrubbed"] is True diff --git a/tests/hermetic/test_modes.py b/tests/hermetic/test_modes.py new file mode 100644 index 0000000..da8f562 --- /dev/null +++ b/tests/hermetic/test_modes.py @@ -0,0 +1,133 @@ +"""Hermeticity mode enforcement tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from runner.hermeticity import ( + EnforcementStatus, + HermeticityMode, + HermeticityPolicy, +) +from runner.providers.base import ExecRequest +from runner.providers.local_fake import LocalFakeProvider +from runner.providers.morph import MorphCloudProvider + + +@pytest.mark.parametrize( + "mode,env,should_fail", + [ + (HermeticityMode.NO_NETWORK, {"HTTP_URL": "https://evil.example"}, True), + ( + HermeticityMode.ALLOWLISTED_NETWORK, + {"HTTP_URL": "https://allowed.example"}, + False, + ), + ( + HermeticityMode.ALLOWLISTED_NETWORK, + {"HTTP_URL": "https://denied.example"}, + True, + ), + ( + HermeticityMode.RECORDED_RESPONSE, + {"CASSETTE_DIGEST": "sha256:" + "c" * 64}, + False, + ), + ( + HermeticityMode.RECORDED_RESPONSE, + {"CASSETTE_DIGEST": "sha256:" + "d" * 64}, + True, + ), + ( + HermeticityMode.SYNTHETIC_DEPENDENCY, + {"SYNTHETIC_DEP_DIGEST": "sha256:" + "e" * 64}, + False, + ), + ( + HermeticityMode.SYNTHETIC_DEPENDENCY, + {"SYNTHETIC_DEP_DIGEST": "sha256:" + "f" * 64}, + True, + ), + ], +) +def test_fake_mode_enforce_and_deny( + tmp_path: Path, + mode: HermeticityMode, + env: dict[str, str], + should_fail: bool, +) -> None: + policy = HermeticityPolicy( + mode=mode, + allowlist_hosts=("allowed.example",), + cassette_digest="sha256:" + "c" * 64, + synthetic_dependency_digests=("sha256:" + "e" * 64,), + partner_local_paths=("/approved",), + ) + provider = LocalFakeProvider(tmp_path, hermeticity=policy) + assert provider.supports(mode) + snap = provider.register_snapshot("s", b"x") + handle = provider.clone(snap, branch_id="b") + result = provider.execute( + handle, ExecRequest(command=["replay"], timeout_ms=1000, env=env) + ) + if should_fail: + assert result.status.value == "FAILED" + assert ( + "denied" in result.stderr.lower() + or "mismatch" in result.stderr.lower() + or "undeclared" in result.stderr.lower() + ) + else: + assert result.status.value == "PASSED" + + +def test_partner_local_path_boundary(tmp_path: Path) -> None: + policy = HermeticityPolicy( + mode=HermeticityMode.PARTNER_LOCAL, + partner_local_paths=(str(tmp_path / "approved"),), + ) + approved = tmp_path / "approved" + approved.mkdir() + good = approved / "iv.json" + good.write_text("{}", encoding="utf-8") + bad = tmp_path / "outside.json" + bad.write_text("{}", encoding="utf-8") + provider = LocalFakeProvider(tmp_path / "prov", hermeticity=policy) + snap = provider.register_snapshot("s", b"x") + handle = provider.clone(snap, branch_id="b") + ok = provider.execute( + handle, + ExecRequest( + command=["replay"], + timeout_ms=1000, + intervention_paths=[str(good)], + ), + ) + assert ok.status.value == "PASSED" + denied = provider.execute( + handle, + ExecRequest( + command=["replay"], + timeout_ms=1000, + intervention_paths=[str(bad)], + ), + ) + assert denied.status.value == "FAILED" + + +def test_morph_unsupported_evidence_status() -> None: + provider = MorphCloudProvider(api_key="x", client=object()) + assert not provider.supports(HermeticityMode.PARTNER_LOCAL) + # Preflight pattern used by scheduler + from runner.hermeticity.modes import HermeticityEvidence + + evidence = HermeticityEvidence( + mode=HermeticityMode.PARTNER_LOCAL, + status=EnforcementStatus.UNSUPPORTED, + policy_digest="sha256:" + "a" * 64, + probe_result={"supports": False}, + message="unsupported", + ) + assert evidence.status == EnforcementStatus.UNSUPPORTED From 1cb0dfc58c142d222fb32757408d76679b6da89a Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:51 -0700 Subject: [PATCH 23/31] test: add adversarial conformance and offline integration gates Cover secret isolation, schema instance validity, PIP linkage, and offline CI gate scripts. --- tests/adversarial/__init__.py | 0 tests/adversarial/test_secrets_isolation.py | 70 ++++++++++++++++ tests/conformance/__init__.py | 0 tests/conformance/test_pip_linkage.py | 90 +++++++++++++++++++++ tests/conformance/test_schema_instances.py | 79 ++++++++++++++++++ tests/integration/__init__.py | 0 tests/integration/test_offline_gates.py | 31 +++++++ 7 files changed, 270 insertions(+) create mode 100644 tests/adversarial/__init__.py create mode 100644 tests/adversarial/test_secrets_isolation.py create mode 100644 tests/conformance/__init__.py create mode 100644 tests/conformance/test_pip_linkage.py create mode 100644 tests/conformance/test_schema_instances.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_offline_gates.py diff --git a/tests/adversarial/__init__.py b/tests/adversarial/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adversarial/test_secrets_isolation.py b/tests/adversarial/test_secrets_isolation.py new file mode 100644 index 0000000..7802bad --- /dev/null +++ b/tests/adversarial/test_secrets_isolation.py @@ -0,0 +1,70 @@ +"""Adversarial secret and isolation tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.branch import run_branch_job +from runner.evidence import assert_no_secret_material, redact_text +from runner.profile import ProfileValidationError, validate_profile +from runner.providers.local_fake import LocalFakeProvider + + +def test_redaction_strips_tokens() -> None: + text = "Authorization: Bearer abcdefTOKEN api_key=supersecret" + redacted = redact_text(text) + assert "supersecret" not in redacted + assert "abcdefTOKEN" not in redacted or "[REDACTED]" in redacted + + +def test_secret_refs_never_materialize(tmp_path: Path) -> None: + snap = json.loads( + Path("fixtures/branch/snapshot_ref.json").read_text(encoding="utf-8") + ) + payload = Path("fixtures/branch/snapshot.payload").read_bytes() + provider = LocalFakeProvider(tmp_path / "prov") + provider.register_snapshot( + snap["snapshot_id"], payload, digest=snap["snapshot_digest"] + ) + out = tmp_path / "branches" + run_branch_job( + provider=provider, + profile_path=Path("fixtures/profile/valid_profile.json"), + manifest_path=Path("fixtures/branch/manifest.json"), + interventions_dir=Path("fixtures/branch/interventions"), + parallel=4, + out_dir=out, + ) + for path in out.rglob("*.json"): + blob = path.read_text(encoding="utf-8") + assert "sk-" not in blob.lower() + assert "password=" not in blob.lower() + assert_no_secret_material( + json.loads(blob) if blob.strip().startswith("{") else blob + ) + + +def test_profile_rejects_embedded_secret_ref_value() -> None: + raw = json.loads( + Path("fixtures/profile/valid_profile.json").read_text(encoding="utf-8") + ) + raw["secret_refs"] = [{"secret_ref": "token=Bearer sk-abc"}] + with pytest.raises(ProfileValidationError): + validate_profile(raw) + + +def test_no_cross_branch_file_bleed(tmp_path: Path) -> None: + provider = LocalFakeProvider(tmp_path) + snap = provider.register_snapshot("s", b"shared") + h1 = provider.clone(snap, branch_id="b1") + h2 = provider.clone(snap, branch_id="b2") + secret = Path(h1.root_path) / "fs" / "secret.txt" + secret.write_text("tenant-a-secret", encoding="utf-8") + assert not (Path(h2.root_path) / "fs" / "secret.txt").exists() + provider.teardown(h1) + provider.teardown(h2) + assert not Path(h1.root_path).exists() + assert not Path(h2.root_path).exists() diff --git a/tests/conformance/__init__.py b/tests/conformance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/test_pip_linkage.py b/tests/conformance/test_pip_linkage.py new file mode 100644 index 0000000..ac7cf59 --- /dev/null +++ b/tests/conformance/test_pip_linkage.py @@ -0,0 +1,90 @@ +"""PIP emission and linkage tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.evidence.pip import ( + PipEvidenceError, + emit_morph_replay_report, + emit_transformation_record, +) +from runner.evidence.validate import SchemaValidationError, validate_instance +from runner.hashing import canonical_hash + + +def test_transformation_record_digest() -> None: + record = emit_transformation_record( + transformation_id="t1", + transformation_type="normalization", + input_artifact_digests=["sha256:" + "a" * 64], + output_artifact_digest="sha256:" + "b" * 64, + implementation_id="morph-replay-runner", + implementation_version="0.1.0", + container_digest="sha256:" + "c" * 64, + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + ) + recomputed = canonical_hash( + {k: v for k, v in record.items() if k != "record_digest"}, + enforce_number_policy=True, + extra_excluded=frozenset({"record_digest"}), + ) + assert record["record_digest"] == recomputed + validate_instance("pip.TransformationRecord.v1", record) + + +def test_morph_report_status_enum() -> None: + report = emit_morph_replay_report( + replay_id="r", + branch_id="b", + replay_identity_digest="sha256:" + "d" * 64, + input_artifact_digests=["sha256:" + "a" * 64], + output_artifact_digests=["sha256:" + "d" * 64], + status="recorded", + ) + assert report["schema_version"] == "pip.MorphReplayReport.v1" + validate_instance("pip.MorphReplayReport.v1", report) + + +def test_joint_fixture_linkage_files_exist() -> None: + root = Path("fixtures/pip/valid_replay_linkage") + assert (root / "lineage_bundle.json").is_file() + assert (root / "morph_replay_report.json").is_file() + lineage = json.loads((root / "lineage_bundle.json").read_text(encoding="utf-8")) + report = json.loads((root / "morph_replay_report.json").read_text(encoding="utf-8")) + validate_instance("pip.LineageBundle.v1", lineage) + validate_instance("pip.MorphReplayReport.v1", report) + node_digests = {n["artifact_digest"] for n in lineage["nodes"]} + assert report["replay_identity_digest"] in node_digests + for digest in report["output_artifact_digests"]: + assert digest in node_digests + for digest in report["input_artifact_digests"]: + assert digest in node_digests + for transform in lineage["transformations"]: + validate_instance("pip.TransformationRecord.v1", transform) + + +def test_tampered_digest_fails_linkage_and_schema_membership() -> None: + root = Path("fixtures/pip/valid_replay_linkage") + report = json.loads((root / "morph_replay_report.json").read_text(encoding="utf-8")) + lineage = json.loads((root / "lineage_bundle.json").read_text(encoding="utf-8")) + report["output_artifact_digests"] = ["sha256:" + "0" * 64] + node_digests = {n["artifact_digest"] for n in lineage["nodes"]} + assert report["output_artifact_digests"][0] not in node_digests + # Still schema-valid as Morph report, but linkage fails (replay-check semantics). + validate_instance("pip.MorphReplayReport.v1", report) + + +def test_invalid_morph_status_rejected() -> None: + with pytest.raises((PipEvidenceError, SchemaValidationError, Exception)): + emit_morph_replay_report( + replay_id="r", + branch_id="b", + replay_identity_digest="sha256:" + "d" * 64, + input_artifact_digests=["sha256:" + "a" * 64], + output_artifact_digests=["sha256:" + "d" * 64], + status="not-a-status", # type: ignore[arg-type] + ) diff --git a/tests/conformance/test_schema_instances.py b/tests/conformance/test_schema_instances.py new file mode 100644 index 0000000..dff244e --- /dev/null +++ b/tests/conformance/test_schema_instances.py @@ -0,0 +1,79 @@ +"""Always-on vendored JSON Schema instance validation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from runner.evidence.pcs import EvidenceError, emit_runtime_receipt +from runner.evidence.validate import ( + SchemaValidationError, + validate_instance, + validate_json_file, +) + + +def test_runtime_receipt_validates_against_vendored_schema() -> None: + receipt = emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + validate_instance("RuntimeReceipt.v0", receipt) + + +def test_replay_validated_rejected_as_receipt_status() -> None: + with pytest.raises(EvidenceError, match="ReplayValidated"): + emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={}, + output_hashes={}, + status="ReplayValidated", + ) + + +def test_tampered_receipt_fails_schema() -> None: + receipt = emit_runtime_receipt( + receipt_id="r1", + run_id="run-1", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + receipt["status"] = "NotARealStatus" + with pytest.raises(SchemaValidationError): + validate_instance("RuntimeReceipt.v0", receipt) + + +def test_pip_linkage_fixtures_schema_valid() -> None: + root = Path("fixtures/pip/valid_replay_linkage") + validate_json_file("pip.LineageBundle.v1", root / "lineage_bundle.json") + validate_json_file("pip.MorphReplayReport.v1", root / "morph_replay_report.json") + + +def test_mrr_manifest_and_profile_schema_valid() -> None: + validate_json_file( + "mrr.BranchReplayManifest.v1", Path("fixtures/branch/manifest.json") + ) + profile = json.loads( + Path("fixtures/profile/valid_profile.json").read_text(encoding="utf-8") + ) + validate_instance("mrr.ExecutionProfile.v1", profile) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_offline_gates.py b/tests/integration/test_offline_gates.py new file mode 100644 index 0000000..71e467a --- /dev/null +++ b/tests/integration/test_offline_gates.py @@ -0,0 +1,31 @@ +"""Integration: offline acceptance gates must hard-fail without Morph.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_vendored_instance_script_passes() -> None: + completed = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "validate_vendored_instances.py")], + check=False, + cwd=str(ROOT), + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + + +def test_schema_mirror_script_passes() -> None: + completed = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "check_schema_mirrors.py")], + check=False, + cwd=str(ROOT), + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr From 9eefd99004c4b40ecce4b58fe9842dabb9bed7c6 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:29:52 -0700 Subject: [PATCH 24/31] ci: add offline CI workflow and slim replay job Run lint, typecheck, and hermetic tests without Morph credentials; keep replay workflow optional and lean. --- .github/workflows/ci.yml | 71 +++++++++++++++++ .github/workflows/replay.yml | 147 ++--------------------------------- 2 files changed, 77 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aef1393 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: Offline CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + +jobs: + offline: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package and dev tools + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Format check (black) + run: black --check runner tests scripts + - name: Import sort check (isort) + run: isort --check-only runner tests scripts + - name: Lint (ruff, warnings as errors) + run: ruff check runner tests scripts + - name: Typecheck (mypy strict) + run: mypy runner + - name: Schema mirror digest check + run: python scripts/check_schema_mirrors.py + - name: Vendored schema instance validation + run: python scripts/validate_vendored_instances.py + - name: Unit / hermetic / adversarial / conformance + run: pytest tests -q --tb=short + - name: Optional external CLI gates (skip-only-when-absent) + run: python scripts/optional_external_validate.py + - name: Dependency audit (strict) + run: pip-audit --strict + + morph-opt-in: + if: ${{ secrets.MORPH_API_KEY != '' }} + runs-on: ubuntu-latest + needs: offline + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Morph smoke (opt-in) + env: + MORPH_API_KEY: ${{ secrets.MORPH_API_KEY }} + run: | + mkdir -p replays + echo '{"type":"file","path":"/tmp/test.txt"}' > replays/demo-file.json + zip -j replays/demo-file.zip replays/demo-file.json + replay-runner run \ + --snapshot morphvm-minimal \ + --bundles "replays/demo-file.zip" \ + --parallel 1 \ + --timeout 300 \ + --provider morph \ + --out ./evidence-morph diff --git a/.github/workflows/replay.yml b/.github/workflows/replay.yml index 4b4b881..014a26c 100644 --- a/.github/workflows/replay.yml +++ b/.github/workflows/replay.yml @@ -1,148 +1,13 @@ -name: Replay Runner CI +name: Morph smoke (legacy, disabled by default) + +# Retained for reference. Default CI is .github/workflows/ci.yml (offline). +# This workflow is disabled; use ci.yml morph-opt-in job with MORPH_API_KEY. on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] workflow_dispatch: - inputs: - snapshot_id: - description: 'Morph snapshot ID to test with' - required: true - default: 'morphvm-minimal' - parallel_count: - description: 'Number of parallel instances' - required: false - default: '2' - -env: - MORPH_API_KEY: ${{ secrets.MORPH_API_KEY }} jobs: - test-replay-bundles: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.9, 3.10, 3.11] - bundle: [demo-http, demo-tcp, demo-file] - parallel: [1, 2] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Create demo bundles - run: | - mkdir -p replays - # Create demo HTTP bundle - echo '{"type": "http", "url": "https://httpbin.org/get"}' > replays/demo-http.json - zip -j replays/demo-http.zip replays/demo-http.json - - # Create demo TCP bundle - echo '{"type": "tcp", "host": "localhost", "port": 8080}' > replays/demo-tcp.json - zip -j replays/demo-tcp.zip replays/demo-tcp.json - - # Create demo file bundle - echo '{"type": "file", "path": "/tmp/test.txt"}' > replays/demo-file.json - zip -j replays/demo-file.zip replays/demo-file.json - - - name: Run replay runner - env: - MORPH_API_KEY: ${{ secrets.MORPH_API_KEY }} - run: | - python -m runner.main \ - --snapshot ${{ github.event.inputs.snapshot_id || 'morphvm-minimal' }} \ - --bundles "replays/${{ matrix.bundle }}.zip" \ - --parallel ${{ matrix.parallel }} \ - --timeout 300 \ - --emit-cert \ - --out "./evidence-${{ matrix.bundle }}-${{ matrix.parallel }}" - - - name: Upload evidence artifacts - uses: actions/upload-artifact@v3 - with: - name: evidence-${{ matrix.bundle }}-${{ matrix.parallel }} - path: | - evidence-${{ matrix.bundle }}-${{ matrix.parallel }}/certs/ - evidence-${{ matrix.bundle }}-${{ matrix.parallel }}/logs/ - evidence-${{ matrix.bundle }}-${{ matrix.parallel }}/reports/ - retention-days: 30 - - - name: Validate certificates - run: | - # Check if certificates were generated - if [ -f "evidence-${{ matrix.bundle }}-${{ matrix.parallel }}/certs/cert_0.json" ]; then - echo "✓ Certificate generated successfully" - # Basic JSON validation - python -c "import json; json.load(open('evidence-${{ matrix.bundle }}-${{ matrix.parallel }}/certs/cert_0.json'))" - else - echo "✗ No certificate generated" - exit 1 - fi - - integration-test: + noop: runs-on: ubuntu-latest - needs: test-replay-bundles - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Download all evidence artifacts - uses: actions/download-artifact@v3 - - - name: Run integration tests - run: | - # Test that all evidence directories contain expected files - for dir in evidence-*; do - echo "Checking $dir..." - [ -d "$dir/certs" ] || exit 1 - [ -d "$dir/logs" ] || exit 1 - [ -d "$dir/reports" ] || exit 1 - [ -f "$dir/reports/index.json" ] || exit 1 - echo "✓ $dir structure is valid" - done - - - name: Generate summary report - run: | - python -c " - import json - import glob - - summary = { - 'total_runs': len(glob.glob('evidence-*')), - 'timestamp': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', - 'status': 'success' - } - - with open('ci-summary.json', 'w') as f: - json.dump(summary, f, indent=2) - " - - - name: Upload CI summary - uses: actions/upload-artifact@v3 - with: - name: ci-summary - path: ci-summary.json - retention-days: 90 + - run: echo "Use ci.yml morph-opt-in instead" From dcfd0f47b25dffde638d95a8dc6aa5cd3ed5d6c5 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:09 -0700 Subject: [PATCH 25/31] docs: refresh README security posture and contribution guides Clarify non-claims, threat model, release checklist, and changelog so operators know what the runner does and does not assert. --- CHANGELOG.md | 50 +++++ CONTRIBUTING.md | 82 +++++++ NON_CLAIMS.md | 33 +++ README.md | 402 ++++++++++++---------------------- SECURITY.md | 36 +++ docs/release-checklist.md | 123 +++++++++++ docs/security/threat-model.md | 55 +++++ 7 files changed, 517 insertions(+), 264 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 NON_CLAIMS.md create mode 100644 SECURITY.md create mode 100644 docs/release-checklist.md create mode 100644 docs/security/threat-model.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d4b619 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] — 2026-07-24 + +First public Alpha of the hermetic branch-N backend (MRR-ITE-00 through MRR-ITE-09). + +### Added + +- Hermetic branch-N backend (`replay-runner branch`) with `LocalFakeProvider` offline path +- ExecutionProfile schema (`mrr.ExecutionProfile.v1`), canonical digest, secret-ref validation +- Provider Protocol: Morph adapter + `LocalFakeProvider` +- Hermeticity modes with fail-closed preflight evidence +- Resource accounting with explicit `unavailable` metrics +- Differential reports with three-valued absence (`absent ≠ equal`) +- PCS `RuntimeReceipt.v0` emission; optional PF-Core gate for elevated claim classes +- PIP `MorphReplayReport.v1` + `TransformationRecord` emission and joint fixtures +- Always-on vendored JSON Schema instance validation (no external CLI required) +- Offline CI (lint / typecheck / tests / schema mirrors); Morph smoke opt-in via `MORPH_API_KEY` +- Security policy, threat model, release checksum script (`scripts/release_bundle.py`) +- Optional `--ovk-check` (fail-closed when flag set and OVK not on PATH) +- Branch CLI fail-closed on partial branches unless `--allow-partial` +- `replay-runner validate` for schema mirrors and vendored instances +- Release documentation: `NON_CLAIMS.md`, `CONTRIBUTING.md`, `docs/release-checklist.md`, + `fixtures/README.md`, public README / SECURITY / ADR polish + +### Changed + +- CLI is a Click group; legacy ZIP Morph path is `replay-runner run` (compat argv still works) +- `requires-python` pinned to `>=3.9` (CI matrix 3.9–3.12) +- Removed unused `aiofiles` / `asyncio-mqtt`; deleted `core_fixed.py` +- `ReplayValidated` is claim-class metadata only; receipt status uses PCS enum + (`RuntimeObserved` / `RuntimeChecked`) +- `HASH_EXCLUDED_FIELDS` aligned with pcs-core; domain digests via `extra_excluded` +- HTTP callback flag rejected fail-closed (unimplemented / out of scope) + +### Non-claims + +Observational / differential evidence only. No causality, remediation ranking, +environment-semantics generation, or campaign-level verifier assurance. +See [NON_CLAIMS.md](NON_CLAIMS.md). + +[Unreleased]: https://github.com/SentinelOps-CI/morph-replay-runner/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/SentinelOps-CI/morph-replay-runner/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..46bbe37 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing + +## Prerequisites + +- Python 3.9–3.12 (CI matrix; 3.9 is the typed floor) +- Git +- Optional: `MORPH_API_KEY` only for live Morph (`replay-runner run` / `--provider morph`) +- Optional: `pcs` / `post-incident` CLIs for deeper external validation extras + +## Install + +```bash +python -m pip install --upgrade pip +pip install -e ".[dev]" +``` + +Refresh generated fixtures/examples when you change generators: + +```bash +python scripts/generate_fixtures.py +``` + +## Local quality gates + +Run the same offline path CI uses: + +```bash +black --check runner tests scripts +isort --check-only runner tests scripts +ruff check runner tests scripts +mypy runner +python scripts/check_schema_mirrors.py +python scripts/validate_vendored_instances.py +pytest tests -q --tb=short +python scripts/optional_external_validate.py +pip-audit --strict +``` + +Or via the CLI wrapper: + +```bash +replay-runner validate +replay-runner validate --external-clis # only when pcs/post-incident are installed +``` + +Hermetic example (offline, no Morph): + +```bash +replay-runner branch \ + --incident examples/hermetic-branch-n/incident_bundle.json \ + --snapshot examples/hermetic-branch-n/snapshot_ref.json \ + --snapshot-payload examples/hermetic-branch-n/snapshot.payload \ + --interventions examples/hermetic-branch-n/interventions \ + --execution-profile examples/hermetic-branch-n/execution_profile.json \ + --manifest examples/hermetic-branch-n/manifest.json \ + --parallel 16 \ + --provider local-fake \ + --out ./branches +``` + +Release checksum bundle: + +```bash +python scripts/release_bundle.py +``` + +Full command list: [docs/release-checklist.md](docs/release-checklist.md). + +## Standards + +- Prefer fail-closed behavior; never invent elevated claim classes or coerce absent values to equal. +- Do not commit secrets, API keys, `.env` files, or partner plaintext. +- Keep providers infrastructure-only (no causality / remediation / reward semantics). +- Update ADRs under `docs/adr/` when contracts or claim boundaries change. +- Keep [NON_CLAIMS.md](NON_CLAIMS.md) and README claim language aligned with implemented behavior. +- Do not edit vendored `schemas/pcs/` or `schemas/pip/` in place; bump the pin and re-vendor (see [schemas/README.md](schemas/README.md)). + +## Pull requests + +- Describe intent and link the relevant MRR-ITE ADR when semantics change. +- Include or update fixtures under `fixtures/` for new validation paths. +- Keep default CI offline; Morph remains opt-in behind `MORPH_API_KEY`. diff --git a/NON_CLAIMS.md b/NON_CLAIMS.md new file mode 100644 index 0000000..53b0bee --- /dev/null +++ b/NON_CLAIMS.md @@ -0,0 +1,33 @@ +# Non-claims + +This repository emits **observational** and **differential** evidence only. +Claim language is fail-closed: elevated labels require a check that actually ran. + +## This project does not claim + +- Causality, root-cause attribution, or blame +- Remediation ranking, preferred branch, or policy certification +- Environment-semantics generation inside providers +- Campaign-level verifier assurance +- That Morph Cloud (or any provider control plane) is honest beyond recorded digests and capability probes +- That guest workloads are formally verified +- That `observational` / `RuntimeObserved` evidence is formally verified +- That HTTP callbacks, live SDE operation, or unimplemented surfaces are available + +## Status and claim-class boundaries + +| Label | Meaning in this repo | +|-------|----------------------| +| Branch `complete` / `partial` | Local observational run status under the scheduler | +| PCS `RuntimeObserved` | Default `RuntimeReceipt.v0` status when no runtime check ran | +| PCS `RuntimeChecked` | Receipt status only after a PF-Core / runtime check actually ran | +| Claim class `ReplayValidated` | Manifest / environment / PF invocation metadata only — **not** a PCS receipt `status` enum member | +| PIP Morph / transformation records | Structural linkage and digests; never promoted into PCS | + +Putting `ReplayValidated` on `RuntimeReceipt.status` is schema-invalid and rejected. + +## Related + +- [docs/adr/MRR-ITE-00-architecture.md](docs/adr/MRR-ITE-00-architecture.md) +- [docs/adr/MRR-ITE-07-pcs-pf.md](docs/adr/MRR-ITE-07-pcs-pf.md) +- [docs/security/threat-model.md](docs/security/threat-model.md) diff --git a/README.md b/README.md index ffb99ff..a41f452 100644 --- a/README.md +++ b/README.md @@ -1,310 +1,184 @@ -
- -
-##############################################################################################
-#                                                                                            #
-#                            __  __  ___  ____  ____  _   _                                  #
-#                           |  \/  |/ _ \|  _ \|  _ \| | | |                                 #
-#                           | |\/| | | | | |_) | |_) | |_| |                                 #
-#                           | |  | | |_| |  _ <|  __/|  _  |                                 #
-#                           |_|  |_|\___/|_| \_\_|   |_| |_|                                 #
-#                                                                                            #
-#                          ____  _____ ____  _        _ __   __                              #
-#                         |  _ \| ____|  _ \| |      / \\ \ / /                              #
-#                         | |_) |  _| | |_) | |     / _ \\ V /                               #
-#                         |  _ <| |___|  __/| |___ / ___ \| |                                #
-#                         |_| \_\_____|_|   |_____/_/   \_\_|                                #
-#                                                                                            #
-#                          ____  _   _ _   _ _   _ _____ ____                                #
-#                         |  _ \| | | | \ | | \ | | ____|  _ \                               #
-#                         | |_) | | | |  \| |  \| |  _| | |_) |                              #
-#                         |  _ <| |_| | |\  | |\  | |___|  _ <                               #
-#                         |_| \_\\___/|_| \_|_| \_|_____|_| \_\                              #
-#                                                                                            #
-#                                                                                            #
-##############################################################################################
-
- -**morph-replay-runner** is a command-line interface (CLI) tool designed to execute TRACE-REPLAY-KIT bundles with branch-N parallelism on Morph Cloud. This tool streamlines the process of running replay tasks, ensuring efficient and scalable execution with comprehensive evidence collection and CERT-V1 compliance. - -
- ---- - -## Installation - -### Prerequisites - -- Python 3.9 or later -- Morph Cloud API key -- Access to Morph Cloud snapshots - -### Install from Source +# morph-replay-runner -```bash -# Clone the repository -git clone https://github.com/SentinelOps-CI/morph-replay-runner.git -cd morph-replay-runner +[![CI](https://github.com/SentinelOps-CI/morph-replay-runner/actions/workflows/ci.yml/badge.svg)](https://github.com/SentinelOps-CI/morph-replay-runner/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue)](pyproject.toml) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -# Install in development mode -pip install -e . -``` +Hermetic, immutable **branch-N** execution backend for SentinelOps-CI. +Produces observational PCS (`RuntimeReceipt.v0`) and PIP (Morph / transformation) evidence packs. -### Install Dependencies +Default path is offline via `LocalFakeProvider`. Live Morph Cloud is opt-in and requires `MORPH_API_KEY`. -```bash -# Install all dependencies -pip install -r requirements.txt +## Status -# Or install with development tools -pip install -e ".[dev]" -``` +Version **0.1.0** (Alpha). Primary command: `replay-runner branch`. +Legacy ZIP Morph path: `replay-runner run` (also accepts bare `--snapshot …` argv). -## Quick Start +What this release implements: -```bash -# Basic usage -replay-runner \ - --snapshot morphvm-minimal \ - --bundles "./replays/*.zip" \ - --parallel 4 \ - --emit-cert \ - --out ./evidence +- Hermetic branch-N scheduler with per-branch isolation and fail-closed hermeticity preflight +- ExecutionProfile schema, canonical digest, secret-ref validation +- Provider protocol: `LocalFakeProvider` (CI default) + Morph adapter (opt-in) +- Resource accounting with explicit `unavailable` metrics +- Differential reports (`absent ≠ equal`) +- Always-on vendored JSON Schema validation; optional external `pcs` / `post-incident` gates -# With HTTP callbacks -replay-runner \ - --snapshot morphvm-minimal \ - --bundles "./examples/*.zip" \ - --parallel 2 \ - --http-callback \ - --http-port 8080 \ - --http-auth api_key - -# Asynchronous mode -replay-runner \ - --snapshot morphvm-minimal \ - --bundles "./replays/*.zip" \ - --parallel 8 \ - --async \ - --timeout 1200 -``` +See [NON_CLAIMS.md](NON_CLAIMS.md) for claim boundaries. Architecture: [docs/adr/](docs/adr/README.md). + +## Requirements -## Usage +- Python **3.9+** (CI: 3.9–3.12) +- Optional: `MORPH_API_KEY` for live Morph (`replay-runner run` / `--provider morph`) +- Optional extras: `pcs`, `pip` (`post-incident`), `ovk` for deeper CLI gates -### Command Line Options +## Install ```bash -replay-runner --help +python -m pip install --upgrade pip +pip install -e ".[dev]" ``` -**Required Options:** -- `--snapshot`: Base snapshot ID or digest containing sidecar + replay tools -- `--bundles`: Glob pattern for replay bundles (e.g., `./replays/*.zip`) - -**Optional Options:** -- `-p, --parallel`: Number of parallel instances (default: 4) -- `-t, --timeout`: Execution timeout in seconds (default: 600) -- `--emit-cert/--no-emit-cert`: Emit CERT-V1 JSON certificates (default: True) -- `-o, --out`: Output directory for evidence collection (default: `./evidence`) -- `--async`: Use asynchronous execution mode -- `--http-callback`: Enable HTTP callback service for demos -- `--http-port`: Port for HTTP callback service (default: 8080) -- `--http-auth`: HTTP callback authentication mode (`none` or `api_key`) - -### Basic Workflow - -1. **Prepare Replay Bundles**: Create TRACE-REPLAY-KIT compliant zip files -2. **Set Environment**: Configure your Morph Cloud API key -3. **Execute**: Run the tool with appropriate parameters -4. **Collect Evidence**: Review generated certificates, logs, and reports - -## Configuration - -### Environment Variables +Refresh fixtures/examples after generator changes: ```bash -# Set your Morph Cloud API key -export MORPH_API_KEY="your_api_key_here" +python scripts/generate_fixtures.py ``` -### Configuration File - -The tool automatically detects and uses the `MORPH_API_KEY` environment variable. For advanced configuration, you can modify the `pyproject.toml` file. - -## Error Handling - -The tool utilizes standard Python exceptions such as `ValueError` and `Exception` to handle errors gracefully. Common error scenarios include: - -- **API Key Missing**: Ensure `MORPH_API_KEY` is set -- **Snapshot Not Found**: Verify snapshot ID exists in Morph Cloud -- **Bundle Format Issues**: Ensure bundles are valid TRACE-REPLAY-KIT format -- **Network Issues**: Check connectivity to Morph Cloud - -## Project Structure +## Quick start (hermetic, offline) -``` -morph-replay-runner/ -├── runner/ # Main runner package -│ ├── __init__.py # Package initialization -│ ├── main.py # CLI entry point -│ ├── core.py # Core execution logic -│ └── models.py # Data models -├── schemas/ # JSON schemas -│ ├── cert_v1.json # CERT-V1 schema -│ ├── trace_replay_kit.json # TRACE-REPLAY-KIT schema -│ └── replay_runner.json # Internal schemas -├── examples/ # Demo replay bundles -│ ├── demo-http.json # HTTP demo -│ ├── demo-tcp.json # TCP demo -│ └── demo-file.json # File operations demo -├── docker/ # Docker support -│ ├── Dockerfile # Container image -│ └── docker-compose.yml # Multi-service setup -├── .github/workflows/ # CI/CD workflows -│ └── replay.yml # Matrix testing workflow -├── pyproject.toml # Project configuration -├── requirements.txt # Dependencies -└── README.md # This file +```bash +replay-runner branch \ + --incident examples/hermetic-branch-n/incident_bundle.json \ + --snapshot examples/hermetic-branch-n/snapshot_ref.json \ + --snapshot-payload examples/hermetic-branch-n/snapshot.payload \ + --interventions examples/hermetic-branch-n/interventions \ + --execution-profile examples/hermetic-branch-n/execution_profile.json \ + --manifest examples/hermetic-branch-n/manifest.json \ + --parallel 16 \ + --provider local-fake \ + --out ./branches ``` -## Examples - -### Demo Bundles - -The project includes three example replay bundles: - -1. **HTTP Demo** (`examples/demo-http.json`): HTTP GET request to httpbin.org -2. **TCP Demo** (`examples/demo-tcp.json`): TCP connection to localhost:8080 -3. **File Demo** (`examples/demo-file.json`): File operations in /tmp - -### Creating Custom Bundles - -```json -{ - "version": "2.1.0", - "manifest": { - "bundle_id": "my-custom-replay", - "created_at": "2025-01-01T00:00:00Z", - "description": "Custom replay description", - "tags": ["custom", "demo"], - "author": "Your Name" - }, - "replay_data": { - "type": "http", - "payload": { - "method": "GET", - "url": "https://api.example.com/endpoint" - } - } -} -``` +No Morph credentials required. Exit is non-zero if any branch is `partial` unless `--allow-partial` is set. -## Docker Support +## Morph opt-in (compatibility path) -### Build and Run +Requires `MORPH_API_KEY` in the environment. Without it, the GitHub Actions `morph-opt-in` job is skipped. ```bash -# Build the Docker image -docker build -f docker/Dockerfile -t morph-replay-runner . - -# Run with Docker -docker run -e MORPH_API_KEY="your_key" morph-replay-runner --help -``` - -### Docker Compose - -```bash -# Start services -docker-compose -f docker/docker-compose.yml up - -# Run with mounted volumes -docker-compose -f docker/docker-compose.yml run morph-replay-runner \ +export MORPH_API_KEY=... # never commit +replay-runner run \ --snapshot morphvm-minimal \ - --bundles "/app/replays/*.zip" + --bundles "./replays/*.zip" \ + --parallel 4 \ + --provider morph \ + --out ./evidence ``` -## CI/CD Pipeline - -The project includes a comprehensive GitHub Actions workflow that: - -- **Matrix Testing**: Tests across Python versions (3.9, 3.10, 3.11) -- **Bundle Validation**: Tests different bundle types (HTTP, TCP, File) -- **Parallel Execution**: Tests various parallel instance counts -- **Evidence Collection**: Validates generated certificates and reports -- **Artifact Publishing**: Uploads evidence packs for review - -### Workflow Triggers - -- **Push**: Automatically runs on pushes to `main` and `develop` branches -- **Pull Request**: Runs on all PRs for quality assurance -- **Manual Dispatch**: Can be triggered manually with custom parameters +Legacy argv without a subcommand (`replay-runner --snapshot …`) still maps to `run`. +`--http-callback` is rejected fail-closed (unimplemented / out of scope for hermetic branch-N). + +## CLI reference + +| Command | Purpose | +|---------|---------| +| `replay-runner branch` | Hermetic isolated branch-N execution (primary) | +| `replay-runner run` | Legacy ZIP-bundle Morph path | +| `replay-runner profile validate --file …` | Validate ExecutionProfile | +| `replay-runner profile digest --file …` | Print profile digest | +| `replay-runner diff --branches … --pairs a,b --out …` | Pairwise differential reports | +| `replay-runner validate` | Schema mirrors + vendored instance checks | +| `replay-runner validate --external-clis` | Also run pcs/post-incident when installed | + +Useful `branch` flags: + +- `--provider local-fake|morph` (default `local-fake`) +- `--snapshot-payload` — raw bytes whose sha256 must match `snapshot_digest` +- `--allow-partial` — accept explicit partial branches (default: fail closed) +- `--ovk-check ` — shell out to OVK on PATH; fail closed if missing when set + +## Evidence layout + +Each `branches//` is an immutable evidence pack: + +```text +execution_profile.json +snapshot_ref.json +intervention_record.json +runtime_receipt.json # PCS RuntimeReceipt.v0 +morph_replay_report.json # PIP +transformation_record.json # PIP +resource_report.json +terminal_state.json +terminal_commitment.json +branch_report.json +teardown_receipt.json +digests.json +logs.txt +status.json # complete | partial +``` -## Testing +Run root also includes `hermeticity_evidence.json` and `summary.json` (with `non_claims`). -### Local Testing +## Differential reports ```bash -# Install development dependencies -pip install -e ".[dev]" - -# Run tests -pytest - -# Code quality checks -black . -isort . -ruff check . -mypy . +replay-runner diff --branches ./branches --pairs branch-00,branch-01 --out ./diffs ``` -### CI Testing - -The CI pipeline automatically: -- Creates demo bundles -- Executes replay runner -- Validates evidence collection -- Generates summary reports +Comparisons use `equal | different | absent_left | absent_right | absent_both`. +Missing data is never coerced to equal. Outputs ban preferred-branch / causality language. -## Output Structure +## Offline quality gates +```bash +pytest tests -q +python scripts/check_schema_mirrors.py +python scripts/validate_vendored_instances.py +python scripts/optional_external_validate.py # skips only when pcs/post-incident absent +python scripts/release_bundle.py ``` -evidence/ -├── certs/ # CERT-V1 certificates -│ ├── cert_0.json # Bundle 0 certificate -│ ├── cert_1.json # Bundle 1 certificate -│ └── ... -├── logs/ # Execution logs -│ ├── log_0.txt # Bundle 0 log -│ ├── log_1.txt # Bundle 1 log -│ └── ... -└── reports/ # Summary reports - └── index.json # Execution summary -``` - -## Contributing -We welcome contributions to **morph-replay-runner**! To contribute: +Full release command list: [docs/release-checklist.md](docs/release-checklist.md). +Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). + +## Layout + +```text +runner/ # CLI, profile, hermeticity, branch, accounting, diff, evidence, providers +schemas/mrr/ # repo-local domain schemas +schemas/pcs/ # mirrored pcs-core (RuntimeReceipt.v0 + defs) +schemas/pip/ # mirrored PIP Morph/lineage schemas +fixtures/ # valid/invalid profiles, branch inputs, PIP linkage +examples/hermetic-branch-n/ +docs/adr/ # MRR-ITE architecture decisions +docs/security/ # threat model +docs/baseline/ # historical ITE-00 measurement +``` -1. **Fork** the repository -2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) -3. **Commit** your changes (`git commit -m 'Add amazing feature'`) -4. **Push** to the branch (`git push origin feature/amazing-feature`) -5. **Open** a Pull Request +## Pins -### Development Guidelines +| Layer | Pin | +|-------|-----| +| PCS | pcs-core `v0.1.0` schemas under `schemas/pcs/` | +| PIP | post-incident-proofs schemas under `schemas/pip/` | +| OVK | shell-out only; optional extra wheel `1.2.1` | +| Morph | `morphcloud>=0.1.91` (live use opt-in via `MORPH_API_KEY`) | -- Follow PEP 8 style guidelines -- Add tests for new functionality -- Update documentation as needed -- Ensure all CI checks pass +Mirror digests and refresh procedure: [schemas/README.md](schemas/README.md). -### Code Quality Tools +## Documentation -- **Black**: Code formatting -- **Isort**: Import sorting -- **Ruff**: Linting and formatting -- **MyPy**: Type checking +| Doc | Topic | +|-----|-------| +| [NON_CLAIMS.md](NON_CLAIMS.md) | Fail-closed claim boundaries | +| [SECURITY.md](SECURITY.md) | Disclosure and secret handling | +| [CHANGELOG.md](CHANGELOG.md) | Version history | +| [docs/security/threat-model.md](docs/security/threat-model.md) | Adversaries and trust boundaries | +| [docs/adr/](docs/adr/) | MRR-ITE decisions | +| [docs/baseline/MRR-ITE-00.md](docs/baseline/MRR-ITE-00.md) | Historical baseline | +| [docs/release-checklist.md](docs/release-checklist.md) | Exact release commands | ## License -**morph-replay-runner** is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for more details. +Apache-2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ca485ea --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | Yes | + +## Reporting a vulnerability + +Do not open a public issue for secret leakage, isolation bypasses, or claim-upgrade bugs. + +Preferred channels (in order): + +1. GitHub Security Advisories for this repository (Security tab → Advisories → Report a vulnerability), when enabled +2. Email **security@sentinelops.ci** with affected commit/tag, reproduction steps, and impact + +Include a minimal reproduction when possible. Do not attach production credentials, partner plaintext, or live Morph API keys. + +Target response: + +- Acknowledgement within 2 business days +- Triage update within 7 business days + +## Secret handling + +- Live Morph credentials are read from the environment variable `MORPH_API_KEY` only +- ExecutionProfile accepts `secret_ref` identifiers; secret **values** in profiles are rejected fail-closed +- Evidence emitters redact known secret material from logs and status payloads +- Never commit `.env`, key files, or credentials; `.env` is gitignored +- Default CI is offline (`LocalFakeProvider`); the `morph-opt-in` workflow job runs only when `MORPH_API_KEY` is configured as a repository secret + +## Threat model + +See [docs/security/threat-model.md](docs/security/threat-model.md) for assets, adversaries, trust boundaries, and non-claims. +Architecture claim boundaries: [NON_CLAIMS.md](NON_CLAIMS.md). diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..1f9f6be --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,123 @@ +# Release checklist + +Commands assume the repository root and an editable install (`pip install -e ".[dev]"`). +Default CI is offline; Morph is opt-in. + +## 1. Install + +```bash +python -m pip install --upgrade pip +pip install -e ".[dev]" +``` + +Optional extras (not required for offline release gates): + +```bash +pip install -e ".[pcs,pip,ovk]" +``` + +## 2. Lint and format + +```bash +black --check runner tests scripts +isort --check-only runner tests scripts +ruff check runner tests scripts +``` + +## 3. Typecheck + +```bash +mypy runner +``` + +## 4. Tests + +```bash +pytest tests -q --tb=short +``` + +## 5. Schema mirror digests + +```bash +python scripts/check_schema_mirrors.py +``` + +## 6. Vendored instance validation + +Always-on offline gate (no external CLIs required): + +```bash +python scripts/validate_vendored_instances.py +``` + +Equivalent CLI entry: + +```bash +replay-runner validate +``` + +## 7. Optional external validate + +Skips only when `pcs` / `post-incident` are absent; fails closed when present and non-zero: + +```bash +python scripts/optional_external_validate.py +# or: +replay-runner validate --external-clis +``` + +## 8. Dependency audit + +```bash +pip-audit --strict +``` + +## 9. Hermetic example smoke (offline) + +```bash +replay-runner branch \ + --incident examples/hermetic-branch-n/incident_bundle.json \ + --snapshot examples/hermetic-branch-n/snapshot_ref.json \ + --snapshot-payload examples/hermetic-branch-n/snapshot.payload \ + --interventions examples/hermetic-branch-n/interventions \ + --execution-profile examples/hermetic-branch-n/execution_profile.json \ + --manifest examples/hermetic-branch-n/manifest.json \ + --parallel 16 \ + --provider local-fake \ + --out ./branches +``` + +Expect exit 0 with all branches `complete` unless `--allow-partial` is intentional. + +## 10. Release checksum bundle + +```bash +python scripts/release_bundle.py +``` + +Writes `release-bundle/checksums.json` (gitignored scratch) covering schemas, the hermetic example tree, and PIP linkage fixtures. + +## 11. Docs and claim surface + +Confirm before tagging: + +- [README.md](../README.md), [NON_CLAIMS.md](../NON_CLAIMS.md), [CHANGELOG.md](../CHANGELOG.md), [SECURITY.md](../SECURITY.md) match implemented behavior +- Version in `pyproject.toml`, `runner.__version__`, and CLI `--version` agree +- No TODOs or aspirational Morph/HTTP-callback marketing in shipped docs +- License Apache-2.0 referenced from README and `pyproject.toml` + +## 12. Morph opt-in (optional, not a release blocker) + +Requires repository secret / environment `MORPH_API_KEY`. Without it, the GitHub Actions `morph-opt-in` job is skipped. + +```bash +export MORPH_API_KEY=... # never commit +replay-runner run \ + --snapshot morphvm-minimal \ + --bundles "./replays/*.zip" \ + --parallel 1 \ + --provider morph \ + --out ./evidence-morph +``` + +`--http-callback` is rejected fail-closed (unimplemented / out of scope). diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 0000000..e1a22f1 --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,55 @@ +# Threat Model — morph-replay-runner + +Date: 2026-07-24 · Scope: version 0.1.x hermetic branch-N + legacy Morph `run` path + +## Assets + +- Snapshot bytes and digests +- ExecutionProfile pins and digests +- Per-branch evidence packs (PCS / PIP) +- Secret references (identifiers only) +- Provider credentials (`MORPH_API_KEY` env) +- Vendored schema mirrors (`SCHEMA_MIRROR.json` digests) + +## Adversaries + +| Adversary | Goal | +|-----------|------| +| Malicious or malformed incident / profile / manifest inputs | Crash parsers, inject secret values, upgrade claim classes without checks | +| Cross-branch pollution | Leak files, resume dirty state, forge equal diffs from absent data | +| Compromised or dishonest Morph control plane | Misreport execute/measure results; treated as untrusted for claim upgrades | +| Supply-chain schema drift | Silently change PCS/PIP contracts without digest gate failure | +| CI / operator misconfiguration | Accidental live Morph spend or credential commit | + +## Trust boundaries + +1. **CLI / parsers** — untrusted JSON inputs; validated before the scheduler kernel +2. **Scheduler kernel** — receives typed profile / manifest only; owns isolation and retries +3. **Providers** — infrastructure only (clone / execute / measure / teardown / capability probes); Morph is untrusted for claim upgrades +4. **Evidence emitters** — observational labels only unless a check actually ran; vendored schema validation fail-closed before write +5. **External CLIs** (`pcs`, `post-incident`, OVK) — optional deeper gates; never implied by default offline CI + +## Threats and mitigations + +| Threat | Mitigation | +|--------|------------| +| Cross-branch file bleed | Per-branch FS roots; clone from canonical snapshot only; isolation tests | +| Dirty retry resume | Retry tears down and reclones from original snapshot | +| Secret materialization | Profile rejects secret values; redaction on logs; adversarial tests | +| Hermeticity bypass | `supports(mode)` preflight fail-closed before clone | +| Claim upgrade without check | Elevated claim classes require PF-Core trace + CLI; receipt status never invents `ReplayValidated` | +| Supply-chain schema drift | `SCHEMA_MIRROR.json` digest CI gate + always-on vendored instance validation | +| Live cloud in default CI | Offline `LocalFakeProvider` default; Morph opt-in via secret | +| HTTP callback confusion | `--http-callback` rejected fail-closed (unimplemented / out of scope) | +| Absent coerced to equal | Differential reports use three-valued absence semantics | + +## Non-goals / non-claims + +- Protecting against a compromised Morph control plane beyond documented assumptions +- Formal verification of guest workloads +- Campaign-level verifier assurance +- Causality, remediation ranking, or environment-semantics generation +- Production-grade non-repudiation or signing beyond recorded digests + +Full product non-claims: [NON_CLAIMS.md](../../NON_CLAIMS.md). +Disclosure: [SECURITY.md](../../SECURITY.md). From f916005033c7332e49fdd4c11956b33d8a8d61b3 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:10 -0700 Subject: [PATCH 26/31] docs: add MRR-ITE ADRs 01 through 09 Record decisions for profiles, providers, hermeticity, scheduling, resources, differentials, PCS, PIP, and release security. --- docs/adr/MRR-ITE-01-execution-profile.md | 25 +++++++++++++++++ docs/adr/MRR-ITE-02-providers.md | 25 +++++++++++++++++ docs/adr/MRR-ITE-03-hermeticity.md | 21 ++++++++++++++ docs/adr/MRR-ITE-04-branch-scheduler.md | 21 ++++++++++++++ docs/adr/MRR-ITE-05-resources.md | 21 ++++++++++++++ docs/adr/MRR-ITE-06-differential.md | 21 ++++++++++++++ docs/adr/MRR-ITE-07-pcs-pf.md | 35 ++++++++++++++++++++++++ docs/adr/MRR-ITE-08-pip.md | 21 ++++++++++++++ docs/adr/MRR-ITE-09-security-release.md | 21 ++++++++++++++ 9 files changed, 211 insertions(+) create mode 100644 docs/adr/MRR-ITE-01-execution-profile.md create mode 100644 docs/adr/MRR-ITE-02-providers.md create mode 100644 docs/adr/MRR-ITE-03-hermeticity.md create mode 100644 docs/adr/MRR-ITE-04-branch-scheduler.md create mode 100644 docs/adr/MRR-ITE-05-resources.md create mode 100644 docs/adr/MRR-ITE-06-differential.md create mode 100644 docs/adr/MRR-ITE-07-pcs-pf.md create mode 100644 docs/adr/MRR-ITE-08-pip.md create mode 100644 docs/adr/MRR-ITE-09-security-release.md diff --git a/docs/adr/MRR-ITE-01-execution-profile.md b/docs/adr/MRR-ITE-01-execution-profile.md new file mode 100644 index 0000000..0ef582a --- /dev/null +++ b/docs/adr/MRR-ITE-01-execution-profile.md @@ -0,0 +1,25 @@ +# ADR MRR-ITE-01: ExecutionProfile + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Branch-N runs need a single pinned execution contract (snapshot, images, network, budgets, secret refs) with a stable digest for evidence linkage. + +## Decision + +Introduce versioned `mrr.ExecutionProfile.v1` with canonical digest (PCS Canonical JSON v1), material pins required, and secrets by `secret_ref` only. Schema: `schemas/mrr/execution_profile.v1.json`. Loader: `runner/profile/`. + +## Non-claims + +Profile digests do not attest guest correctness or Morph control-plane honesty beyond recorded pins. + +## Consequences + +Invalid profiles (missing pins, secret values, wrong schema version) fail closed before scheduling. diff --git a/docs/adr/MRR-ITE-02-providers.md b/docs/adr/MRR-ITE-02-providers.md new file mode 100644 index 0000000..4dc8ad9 --- /dev/null +++ b/docs/adr/MRR-ITE-02-providers.md @@ -0,0 +1,25 @@ +# ADR MRR-ITE-02: Provider abstraction + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Hard-wiring Morph in the CLI blocked offline CI and mixed infrastructure with research semantics. + +## Decision + +`ExecutionProvider` Protocol isolates infrastructure. `LocalFakeProvider` is the default offline CI backend. Morph is opt-in and fail-closed when required digests/capabilities are missing. + +## Cleanup + +Deleted `runner/core_fixed.py`; dropped unused `aiofiles` / `asyncio-mqtt`; simplified docker-compose (removed broken HTTP stub service). + +## Consequences + +Default `branch` provider is `local-fake`. Legacy `run` still defaults to `morph` and requires Morph credentials. diff --git a/docs/adr/MRR-ITE-03-hermeticity.md b/docs/adr/MRR-ITE-03-hermeticity.md new file mode 100644 index 0000000..6be612b --- /dev/null +++ b/docs/adr/MRR-ITE-03-hermeticity.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-03: Hermeticity modes + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Network and dependency assumptions must be explicit and enforceable before clone, with recorded evidence of enforcement or denial. + +## Decision + +Five modes (`no_network`, `allowlisted_network`, `recorded_response`, `partner_local`, `synthetic_dependency`) with `supports()` preflight. Unsupported modes fail before clone; evidence distinguishes `enforced` vs `unsupported`. Implementation: `runner/hermeticity/`. + +## Consequences + +Hermeticity evidence is written at the run root (`hermeticity_evidence.json`). Bypass attempts are hard failures, not warnings. diff --git a/docs/adr/MRR-ITE-04-branch-scheduler.md b/docs/adr/MRR-ITE-04-branch-scheduler.md new file mode 100644 index 0000000..000c550 --- /dev/null +++ b/docs/adr/MRR-ITE-04-branch-scheduler.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-04: Branch-N scheduler + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Parallel branch execution must not share dirty sibling state; retries must reclone from the canonical snapshot. + +## Decision + +`BranchReplayManifest.v1` + scheduler clones each branch from the canonical snapshot, never from dirty siblings. Retries teardown and reclone. Independence unless cancellation policy says otherwise. `branch` CLI is the primary hermetic path; `run` remains compatibility. Implementation: `runner/branch/`. + +## Consequences + +Per-branch evidence packs are immutable once written. Process exits non-zero on any `partial` branch unless `--allow-partial`. diff --git a/docs/adr/MRR-ITE-05-resources.md b/docs/adr/MRR-ITE-05-resources.md new file mode 100644 index 0000000..7c4ed4e --- /dev/null +++ b/docs/adr/MRR-ITE-05-resources.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-05: Resource accounting + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Providers may be unable to observe some metrics; fabricating zeros would overclaim. + +## Decision + +Every metric carries `availability: observed|unavailable|not_applicable`. Unavailable metrics must have `value: null`. Budget enforcement fails closed on observed overruns. Schema: `schemas/mrr/resource_report.v1.json`. + +## Consequences + +Resource reports are validated before evidence write; unavailable metrics never silently become equal in diffs. diff --git a/docs/adr/MRR-ITE-06-differential.md b/docs/adr/MRR-ITE-06-differential.md new file mode 100644 index 0000000..36b7af4 --- /dev/null +++ b/docs/adr/MRR-ITE-06-differential.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-06: Differential reports + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Pairwise branch comparison must not invent equality when a side is missing, and must not encode preferred-branch semantics. + +## Decision + +Pairwise reports with three-valued absence semantics (`equal | different | absent_left | absent_right | absent_both`). Ban preferred-branch / causality / remediation language in outputs. Schema: `schemas/mrr/differential_report.v1.json`. CLI: `replay-runner diff`. + +## Consequences + +Injected causal / remediation claim text outside `non_claims` fails validation. See [NON_CLAIMS.md](../../NON_CLAIMS.md). diff --git a/docs/adr/MRR-ITE-07-pcs-pf.md b/docs/adr/MRR-ITE-07-pcs-pf.md new file mode 100644 index 0000000..a19e8b3 --- /dev/null +++ b/docs/adr/MRR-ITE-07-pcs-pf.md @@ -0,0 +1,35 @@ +# ADR MRR-ITE-07: PCS / PF-Core outputs + +## Status + +Accepted (amended) + +## Date + +2026-07-24 + +## Context + +Branch evidence must link to PCS `RuntimeReceipt.v0` without inventing elevated receipt statuses. PF-Core is optional and fail-closed. + +## Decision + +Emit `RuntimeReceipt.v0` with `RuntimeObserved` by default. + +`RuntimeChecked` is the only elevated **receipt status** used when a PF-Core / +runtime check actually ran. `ReplayValidated` is a **claim class** (manifest / +environment metadata + PF invocation record), not a PCS `artifact_status` enum +member — putting it on `RuntimeReceipt.status` is schema-invalid. + +PF-Core via `pcs pf-core replay-trace` shell-out only when claim class is +`RuntimeChecked` or `ReplayValidated`. Missing trace or missing `pcs` CLI fails +closed (branch marked partial / error). CERT-V1 remains optional guest artifact, +not a PCS substitute. + +Vendored `schemas/pcs/` instance validation always runs in emitters and CI; +external `pcs validate` is an optional deeper gate when the CLI is installed. + +## Consequences + +Claim-class metadata may record `ReplayValidated`; receipt `status` never does. +See [NON_CLAIMS.md](../../NON_CLAIMS.md). diff --git a/docs/adr/MRR-ITE-08-pip.md b/docs/adr/MRR-ITE-08-pip.md new file mode 100644 index 0000000..661b779 --- /dev/null +++ b/docs/adr/MRR-ITE-08-pip.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-08: PIP integration + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Morph replay linkage for post-incident-proofs must stay observational and digest-linked to PCS, never promoted into PCS receipts. + +## Decision + +Emit `pip.MorphReplayReport.v1` and `TransformationRecord.v1` per branch. Cross-link PCS by digest only; never promote Morph reports into PCS. Joint fixtures under `fixtures/pip/valid_replay_linkage/`. Schemas mirrored under `schemas/pip/` with digest CI gate. + +## Consequences + +PIP emission is structural linkage only. Optional `post-incident` CLI gates skip when absent; vendored schema validation always runs. diff --git a/docs/adr/MRR-ITE-09-security-release.md b/docs/adr/MRR-ITE-09-security-release.md new file mode 100644 index 0000000..534c4a9 --- /dev/null +++ b/docs/adr/MRR-ITE-09-security-release.md @@ -0,0 +1,21 @@ +# ADR MRR-ITE-09: Security and release + +## Status + +Accepted + +## Date + +2026-07-24 + +## Context + +Public release requires an honest secret model, isolation tests, disclosure route, and checksummed release artifacts — without overclaiming Morph trust or unimplemented surfaces. + +## Decision + +Isolation / cleanup / secret-erasure tests are mandatory gates. `SECURITY.md` + threat model published. Release checksum script covers schemas and example evidence (`scripts/release_bundle.py`). README and `NON_CLAIMS.md` claims match implemented behavior only. Default CI remains offline; Morph opt-in behind `MORPH_API_KEY`. + +## Consequences + +Release checklist: [docs/release-checklist.md](../release-checklist.md). Disclosure: [SECURITY.md](../../SECURITY.md). From 10d5de6119a4368eaaf4715790b54c4443993c5e Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:11 -0700 Subject: [PATCH 27/31] docs: document schema layout and fixture catalog Explain vendored MRR/PCS/PIP schemas and how offline fixtures map to conformance tests. --- fixtures/README.md | 29 ++++++++++++++++ schemas/README.md | 82 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 fixtures/README.md diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..09a8692 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,29 @@ +# Fixtures + +Offline fixtures for unit, hermetic, adversarial, and schema-conformance tests. +Regenerate shared trees with: + +```bash +python scripts/generate_fixtures.py +``` + +## Layout + +| Path | Role | +|------|------| +| `profile/valid_profile.json` | Valid `mrr.ExecutionProfile.v1` | +| `profile/invalid_*.json` | Fail-closed profile validation cases (missing pin, secret value, schema version) | +| `branch/` | Hermetic branch-N inputs: incident, snapshot ref + payload, 16 interventions, manifest | +| `branch/manifest_digest_drift.json` | Negative case for digest drift | +| `pip/valid_replay_linkage/` | Joint PIP lineage + Morph replay report instances | + +Empty reserved directories (`diff/`, `hermeticity/`, `pcs/`, `resources/`) may appear locally; authoritative committed fixtures are the paths above. + +## Validation + +```bash +python scripts/validate_vendored_instances.py +replay-runner profile validate --file fixtures/profile/valid_profile.json +``` + +Public fixtures use synthetic digests only. Do not add credentials or partner plaintext. diff --git a/schemas/README.md b/schemas/README.md index 33f5b07..fc32019 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -1,30 +1,72 @@ -# Vendored Schemas +# Vendored / mirrored schemas -This directory contains vendored schemas from external projects, pinned by specific tags for reproducibility. +Pinned PCS and PIP contracts live here so offline CI can validate instances +without requiring `pcs` or `post-incident` CLIs. Digests are recorded in each +directory's `SCHEMA_MIRROR.json` and checked by `scripts/check_schema_mirrors.py`. -## CERT-V1 Schema +## Layout -**Source**: [CERT-V1 specification](https://github.com/cert-org/cert-v1) -**Pinned Version**: v1.0.0 -**Purpose**: Defines the certificate format for replay execution results +| Path | Source | Role | +|------|--------|------| +| `mrr/` | this repo | ExecutionProfile, BranchReplayManifest, DifferentialReport, ResourceReport | +| `pcs/` | [pcs-core](https://github.com/SentinelOps-CI/pcs-core) tag `v0.1.0` | `RuntimeReceipt.v0`, `common.defs`, ArtifactIntegrity | +| `pip/` | [post-incident-proofs](https://github.com/SentinelOps-CI/post-incident-proofs) | MorphReplayReport, TransformationRecord, LineageBundle | +| `cert_v1.json` / `trace_replay_kit.json` / `replay_runner.json` | legacy guest/docs | Optional guest CERT / historical TRACE; not PCS substitutes | -## TRACE-REPLAY-KIT Schema +## Current pins -**Source**: [TRACE-REPLAY-KIT](https://github.com/trace-replay-kit/spec) -**Pinned Version**: v2.1.0 -**Purpose**: Defines the replay bundle format and execution interface +| Mirror | Manifest | Pin | +|--------|----------|-----| +| PCS | `pcs/SCHEMA_MIRROR.json` | tag `v0.1.0`, commit `2f0ce059652408bce19bc89ba6660030020d5ea0` | +| PIP | `pip/SCHEMA_MIRROR.json` | commit `3cdfdf09c20f08ad5221d29607b5a9726295ad10` | -## Schema Files +Do **not** edit vendored files under `pcs/` or `pip/` in place. Bump the pin, re-copy, and update `local_digests`. -- `cert_v1.json` - CERT-V1 JSON schema definition -- `trace_replay_kit.json` - TRACE-REPLAY-KIT bundle schema -- `replay_runner.json` - Internal schema for runner configuration and reports +## Validation -## Updating Schemas +1. Mirror digests: `python scripts/check_schema_mirrors.py` +2. Instance conformance (always-on): `python scripts/validate_vendored_instances.py` +3. Optional deeper CLI gates when installed: `python scripts/optional_external_validate.py` -To update to newer versions: +Emitters call `runner.evidence.validate.validate_instance` fail-closed before writing evidence. -1. Download the latest schema from the source repository -2. Update the version tag in this README -3. Test compatibility with existing code -4. Update any validation logic if needed +## How to refresh a mirror + +From a checked-out sibling checkout at the desired pin: + +```bash +# PCS (example paths; adjust to your local checkout) +cp /path/to/pcs-core/schemas/RuntimeReceipt.v0.schema.json schemas/pcs/ +cp /path/to/pcs-core/schemas/common.defs.json schemas/pcs/ +cp /path/to/pcs-core/schemas/ArtifactIntegrity.v1.schema.json schemas/pcs/ + +# PIP Morph / lineage schemas used by this runner +cp /path/to/post-incident-proofs/schemas/pip/v1/LineageBundle.schema.json schemas/pip/ +cp /path/to/post-incident-proofs/schemas/pip/v1/MorphReplayReport.schema.json schemas/pip/ +cp /path/to/post-incident-proofs/schemas/pip/v1/TransformationRecord.schema.json schemas/pip/ +``` + +Then recompute digests and update the corresponding `SCHEMA_MIRROR.json`: + +```bash +python - <<'PY' +import hashlib, json, pathlib +root = pathlib.Path("schemas/pcs") # or schemas/pip +files = json.loads((root / "SCHEMA_MIRROR.json").read_text())["vendored_files"] +digests = { + name: "sha256:" + hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in files +} +print(json.dumps(digests, indent=2)) +PY +``` + +Update `source_tag` / `pinned_commit` / `pin_rationale` in the manifest, then: + +```bash +python scripts/check_schema_mirrors.py +python scripts/validate_vendored_instances.py +``` + +Mirroring schemas does not imply PF-Core / OVK runtime integration on the default path. +See [NON_CLAIMS.md](../NON_CLAIMS.md). From 739795bb54b6d979b4647684d7a762506bf5b3fc Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:12 -0700 Subject: [PATCH 28/31] docs: add hermetic-branch-n end-to-end example pack Ship a copyable profile, manifest, interventions, and snapshot refs for local branch-N demos. --- examples/hermetic-branch-n/README.md | 76 ++++++ .../hermetic-branch-n/execution_profile.json | 61 +++++ .../hermetic-branch-n/incident_bundle.json | 4 + .../interventions/branch-00.json | 11 + .../interventions/branch-01.json | 11 + .../interventions/branch-02.json | 11 + .../interventions/branch-03.json | 11 + .../interventions/branch-04.json | 11 + .../interventions/branch-05.json | 11 + .../interventions/branch-06.json | 11 + .../interventions/branch-07.json | 11 + .../interventions/branch-08.json | 11 + .../interventions/branch-09.json | 11 + .../interventions/branch-10.json | 11 + .../interventions/branch-11.json | 11 + .../interventions/branch-12.json | 11 + .../interventions/branch-13.json | 11 + .../interventions/branch-14.json | 11 + .../interventions/branch-15.json | 11 + examples/hermetic-branch-n/manifest.json | 250 ++++++++++++++++++ examples/hermetic-branch-n/snapshot.payload | 1 + examples/hermetic-branch-n/snapshot_ref.json | 4 + 22 files changed, 572 insertions(+) create mode 100644 examples/hermetic-branch-n/README.md create mode 100644 examples/hermetic-branch-n/execution_profile.json create mode 100644 examples/hermetic-branch-n/incident_bundle.json create mode 100644 examples/hermetic-branch-n/interventions/branch-00.json create mode 100644 examples/hermetic-branch-n/interventions/branch-01.json create mode 100644 examples/hermetic-branch-n/interventions/branch-02.json create mode 100644 examples/hermetic-branch-n/interventions/branch-03.json create mode 100644 examples/hermetic-branch-n/interventions/branch-04.json create mode 100644 examples/hermetic-branch-n/interventions/branch-05.json create mode 100644 examples/hermetic-branch-n/interventions/branch-06.json create mode 100644 examples/hermetic-branch-n/interventions/branch-07.json create mode 100644 examples/hermetic-branch-n/interventions/branch-08.json create mode 100644 examples/hermetic-branch-n/interventions/branch-09.json create mode 100644 examples/hermetic-branch-n/interventions/branch-10.json create mode 100644 examples/hermetic-branch-n/interventions/branch-11.json create mode 100644 examples/hermetic-branch-n/interventions/branch-12.json create mode 100644 examples/hermetic-branch-n/interventions/branch-13.json create mode 100644 examples/hermetic-branch-n/interventions/branch-14.json create mode 100644 examples/hermetic-branch-n/interventions/branch-15.json create mode 100644 examples/hermetic-branch-n/manifest.json create mode 100644 examples/hermetic-branch-n/snapshot.payload create mode 100644 examples/hermetic-branch-n/snapshot_ref.json diff --git a/examples/hermetic-branch-n/README.md b/examples/hermetic-branch-n/README.md new file mode 100644 index 0000000..c1a111b --- /dev/null +++ b/examples/hermetic-branch-n/README.md @@ -0,0 +1,76 @@ +# Hermetic branch-N example + +Offline reproducible example (16 branches) for `LocalFakeProvider`. +No Morph credentials required. + +Inputs are generated by `python scripts/generate_fixtures.py` from the repo root +(snapshot payload, profile digests, interventions, and manifest stay in sync with `fixtures/branch/`). + +## Prerequisites + +```bash +pip install -e ".[dev]" +``` + +## Run + +From the **repository root**: + +```bash +replay-runner branch \ + --incident examples/hermetic-branch-n/incident_bundle.json \ + --snapshot examples/hermetic-branch-n/snapshot_ref.json \ + --snapshot-payload examples/hermetic-branch-n/snapshot.payload \ + --interventions examples/hermetic-branch-n/interventions \ + --execution-profile examples/hermetic-branch-n/execution_profile.json \ + --manifest examples/hermetic-branch-n/manifest.json \ + --parallel 16 \ + --provider local-fake \ + --out ./branches-out +``` + +From this directory (paths relative to cwd): + +```bash +replay-runner branch \ + --incident incident_bundle.json \ + --snapshot snapshot_ref.json \ + --snapshot-payload snapshot.payload \ + --interventions interventions \ + --execution-profile execution_profile.json \ + --manifest manifest.json \ + --parallel 16 \ + --provider local-fake \ + --out ./branches-out +``` + +Expect exit 0 with 16 `complete` branches. Partial branches fail the process unless `--allow-partial` is set. + +## Outputs + +`branches-out/summary.json` plus per-branch packs under `branches-out//` +(`runtime_receipt.json`, PIP reports, `resource_report.json`, `status.json`, digests, logs). +See the Evidence layout section in the root [README.md](../../README.md). + +## Differential sample + +```bash +replay-runner diff \ + --branches ./branches-out \ + --pairs branch-00,branch-01 \ + --out ./diffs-out +``` + +## Checksums + +From the repository root: + +```bash +python scripts/release_bundle.py +``` + +Writes `release-bundle/checksums.json` covering this example tree, domain/PCS/PIP schemas, and PIP linkage fixtures. + +## Non-claims + +Outputs are observational / differential only. See [NON_CLAIMS.md](../../NON_CLAIMS.md). diff --git a/examples/hermetic-branch-n/execution_profile.json b/examples/hermetic-branch-n/execution_profile.json new file mode 100644 index 0000000..7df8c56 --- /dev/null +++ b/examples/hermetic-branch-n/execution_profile.json @@ -0,0 +1,61 @@ +{ + "budgets": { + "accelerator_count": 0, + "api_call_budget": 0, + "cpu_cores": 2, + "memory_mib": 1024, + "storage_mib": 2048, + "token_budget": 0, + "wall_time_ms": 60000 + }, + "clock_policy": { + "fixed_epoch_ms": 1700000000000, + "mode": "frozen" + }, + "container_image_digests": [ + "sha256:6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + ], + "dependency_lock_digest": "sha256:0c030586945fe504b604ecc2e875c38ede400cd5cd73da9730302162e6b02c6f", + "environment": { + "profile_name": "hermetic-demo", + "profile_version": "1.0.0" + }, + "integrity": { + "artifact_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "canonicalization_version": "v1" + }, + "network_policy": { + "allowlist_hosts": [], + "mode": "no_network", + "partner_local_paths": [], + "synthetic_dependency_digests": [] + }, + "os_runtime": { + "os": "linux", + "runtime": "python3.11" + }, + "output_retention": { + "mode": "retain", + "retention_days": 30 + }, + "profile_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "profile_id": "profile-demo", + "random_seed_policy": { + "seed": 42 + }, + "schema_version": "mrr.ExecutionProfile.v1", + "secret_refs": [ + { + "purpose": "optional-morph", + "secret_ref": "vault/morph/api" + } + ], + "snapshot": { + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785", + "snapshot_id": "snap-demo" + }, + "source_commit": "b3018d790e3e7c622b28641c0230a61bb3b78955", + "tool_versions": { + "replay-runner": "0.1.0" + } +} diff --git a/examples/hermetic-branch-n/incident_bundle.json b/examples/hermetic-branch-n/incident_bundle.json new file mode 100644 index 0000000..27cfb43 --- /dev/null +++ b/examples/hermetic-branch-n/incident_bundle.json @@ -0,0 +1,4 @@ +{ + "incident_id": "inc-demo", + "incident_digest": "sha256:cad4eaad4523d1b78d9de32b78b3a873380aad8a75c2646fffe921e182f01907" +} diff --git a/examples/hermetic-branch-n/interventions/branch-00.json b/examples/hermetic-branch-n/interventions/branch-00.json new file mode 100644 index 0000000..1533b6b --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-00.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-00", + "intervention_id": "iv-00", + "artifact_paths": [ + "branch-00.json" + ], + "action_sequence": [ + "replay", + "branch-00" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-01.json b/examples/hermetic-branch-n/interventions/branch-01.json new file mode 100644 index 0000000..59e1454 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-01.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-01", + "intervention_id": "iv-01", + "artifact_paths": [ + "branch-01.json" + ], + "action_sequence": [ + "replay", + "branch-01" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-02.json b/examples/hermetic-branch-n/interventions/branch-02.json new file mode 100644 index 0000000..f2abc91 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-02.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-02", + "intervention_id": "iv-02", + "artifact_paths": [ + "branch-02.json" + ], + "action_sequence": [ + "replay", + "branch-02" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-03.json b/examples/hermetic-branch-n/interventions/branch-03.json new file mode 100644 index 0000000..ecd6edc --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-03.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-03", + "intervention_id": "iv-03", + "artifact_paths": [ + "branch-03.json" + ], + "action_sequence": [ + "replay", + "branch-03" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-04.json b/examples/hermetic-branch-n/interventions/branch-04.json new file mode 100644 index 0000000..00cebf6 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-04.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-04", + "intervention_id": "iv-04", + "artifact_paths": [ + "branch-04.json" + ], + "action_sequence": [ + "replay", + "branch-04" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-05.json b/examples/hermetic-branch-n/interventions/branch-05.json new file mode 100644 index 0000000..8524123 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-05.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-05", + "intervention_id": "iv-05", + "artifact_paths": [ + "branch-05.json" + ], + "action_sequence": [ + "replay", + "branch-05" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-06.json b/examples/hermetic-branch-n/interventions/branch-06.json new file mode 100644 index 0000000..1b53696 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-06.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-06", + "intervention_id": "iv-06", + "artifact_paths": [ + "branch-06.json" + ], + "action_sequence": [ + "replay", + "branch-06" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-07.json b/examples/hermetic-branch-n/interventions/branch-07.json new file mode 100644 index 0000000..cd69b03 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-07.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-07", + "intervention_id": "iv-07", + "artifact_paths": [ + "branch-07.json" + ], + "action_sequence": [ + "replay", + "branch-07" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-08.json b/examples/hermetic-branch-n/interventions/branch-08.json new file mode 100644 index 0000000..1de90ba --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-08.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-08", + "intervention_id": "iv-08", + "artifact_paths": [ + "branch-08.json" + ], + "action_sequence": [ + "replay", + "branch-08" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-09.json b/examples/hermetic-branch-n/interventions/branch-09.json new file mode 100644 index 0000000..246816d --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-09.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-09", + "intervention_id": "iv-09", + "artifact_paths": [ + "branch-09.json" + ], + "action_sequence": [ + "replay", + "branch-09" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-10.json b/examples/hermetic-branch-n/interventions/branch-10.json new file mode 100644 index 0000000..df08633 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-10.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-10", + "intervention_id": "iv-10", + "artifact_paths": [ + "branch-10.json" + ], + "action_sequence": [ + "replay", + "branch-10" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-11.json b/examples/hermetic-branch-n/interventions/branch-11.json new file mode 100644 index 0000000..0b809d1 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-11.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-11", + "intervention_id": "iv-11", + "artifact_paths": [ + "branch-11.json" + ], + "action_sequence": [ + "replay", + "branch-11" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-12.json b/examples/hermetic-branch-n/interventions/branch-12.json new file mode 100644 index 0000000..6462095 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-12.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-12", + "intervention_id": "iv-12", + "artifact_paths": [ + "branch-12.json" + ], + "action_sequence": [ + "replay", + "branch-12" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-13.json b/examples/hermetic-branch-n/interventions/branch-13.json new file mode 100644 index 0000000..4b182e7 --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-13.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-13", + "intervention_id": "iv-13", + "artifact_paths": [ + "branch-13.json" + ], + "action_sequence": [ + "replay", + "branch-13" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-14.json b/examples/hermetic-branch-n/interventions/branch-14.json new file mode 100644 index 0000000..41dffbe --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-14.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-14", + "intervention_id": "iv-14", + "artifact_paths": [ + "branch-14.json" + ], + "action_sequence": [ + "replay", + "branch-14" + ] +} diff --git a/examples/hermetic-branch-n/interventions/branch-15.json b/examples/hermetic-branch-n/interventions/branch-15.json new file mode 100644 index 0000000..578674e --- /dev/null +++ b/examples/hermetic-branch-n/interventions/branch-15.json @@ -0,0 +1,11 @@ +{ + "branch_id": "branch-15", + "intervention_id": "iv-15", + "artifact_paths": [ + "branch-15.json" + ], + "action_sequence": [ + "replay", + "branch-15" + ] +} diff --git a/examples/hermetic-branch-n/manifest.json b/examples/hermetic-branch-n/manifest.json new file mode 100644 index 0000000..e1e12c3 --- /dev/null +++ b/examples/hermetic-branch-n/manifest.json @@ -0,0 +1,250 @@ +{ + "schema_version": "mrr.BranchReplayManifest.v1", + "manifest_id": "manifest-demo", + "incident_ref": { + "incident_id": "inc-demo", + "incident_digest": "sha256:cad4eaad4523d1b78d9de32b78b3a873380aad8a75c2646fffe921e182f01907" + }, + "snapshot_ref": { + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" + }, + "execution_profile_digest": "sha256:130d846c41fc26fa002d68e1a854faaeabeaf429069807437b2c14f761eada07", + "branches": [ + { + "branch_id": "branch-00", + "intervention": { + "intervention_id": "iv-00", + "artifact_paths": [ + "branch-00.json" + ] + }, + "action_sequence": [ + "replay", + "branch-00" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-01", + "intervention": { + "intervention_id": "iv-01", + "artifact_paths": [ + "branch-01.json" + ] + }, + "action_sequence": [ + "replay", + "branch-01" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-02", + "intervention": { + "intervention_id": "iv-02", + "artifact_paths": [ + "branch-02.json" + ] + }, + "action_sequence": [ + "replay", + "branch-02" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-03", + "intervention": { + "intervention_id": "iv-03", + "artifact_paths": [ + "branch-03.json" + ] + }, + "action_sequence": [ + "replay", + "branch-03" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-04", + "intervention": { + "intervention_id": "iv-04", + "artifact_paths": [ + "branch-04.json" + ] + }, + "action_sequence": [ + "replay", + "branch-04" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-05", + "intervention": { + "intervention_id": "iv-05", + "artifact_paths": [ + "branch-05.json" + ] + }, + "action_sequence": [ + "replay", + "branch-05" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-06", + "intervention": { + "intervention_id": "iv-06", + "artifact_paths": [ + "branch-06.json" + ] + }, + "action_sequence": [ + "replay", + "branch-06" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-07", + "intervention": { + "intervention_id": "iv-07", + "artifact_paths": [ + "branch-07.json" + ] + }, + "action_sequence": [ + "replay", + "branch-07" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-08", + "intervention": { + "intervention_id": "iv-08", + "artifact_paths": [ + "branch-08.json" + ] + }, + "action_sequence": [ + "replay", + "branch-08" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-09", + "intervention": { + "intervention_id": "iv-09", + "artifact_paths": [ + "branch-09.json" + ] + }, + "action_sequence": [ + "replay", + "branch-09" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-10", + "intervention": { + "intervention_id": "iv-10", + "artifact_paths": [ + "branch-10.json" + ] + }, + "action_sequence": [ + "replay", + "branch-10" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-11", + "intervention": { + "intervention_id": "iv-11", + "artifact_paths": [ + "branch-11.json" + ] + }, + "action_sequence": [ + "replay", + "branch-11" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-12", + "intervention": { + "intervention_id": "iv-12", + "artifact_paths": [ + "branch-12.json" + ] + }, + "action_sequence": [ + "replay", + "branch-12" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-13", + "intervention": { + "intervention_id": "iv-13", + "artifact_paths": [ + "branch-13.json" + ] + }, + "action_sequence": [ + "replay", + "branch-13" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-14", + "intervention": { + "intervention_id": "iv-14", + "artifact_paths": [ + "branch-14.json" + ] + }, + "action_sequence": [ + "replay", + "branch-14" + ], + "claim_class": "RuntimeObserved" + }, + { + "branch_id": "branch-15", + "intervention": { + "intervention_id": "iv-15", + "artifact_paths": [ + "branch-15.json" + ] + }, + "action_sequence": [ + "replay", + "branch-15" + ], + "claim_class": "RuntimeObserved" + } + ], + "retry_policy": { + "max_retries": 1, + "retry_on": [ + "TIMEOUT", + "ERROR" + ] + }, + "cancellation_policy": { + "cancel_others_on_failure": false + }, + "branch_independence": true +} diff --git a/examples/hermetic-branch-n/snapshot.payload b/examples/hermetic-branch-n/snapshot.payload new file mode 100644 index 0000000..c19da04 --- /dev/null +++ b/examples/hermetic-branch-n/snapshot.payload @@ -0,0 +1 @@ +mrr-local-fake-snapshot-v1 diff --git a/examples/hermetic-branch-n/snapshot_ref.json b/examples/hermetic-branch-n/snapshot_ref.json new file mode 100644 index 0000000..9c133d2 --- /dev/null +++ b/examples/hermetic-branch-n/snapshot_ref.json @@ -0,0 +1,4 @@ +{ + "snapshot_id": "snap-demo", + "snapshot_digest": "sha256:9047a18b16b4542b7388faa6d97a47a79c793d0fa280013bb0587fa35f932785" +} From 8623ed2fa85ad76359b5d9f0424ea06f1daebccc Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:24 -0700 Subject: [PATCH 29/31] chore: add schema mirror and instance validators Keep vendored schema mirrors honest and validate fixture instances offline before release. --- scripts/check_schema_mirrors.py | 46 +++++++++ scripts/optional_external_validate.py | 84 +++++++++++++++++ scripts/validate_vendored_instances.py | 124 +++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 scripts/check_schema_mirrors.py create mode 100644 scripts/optional_external_validate.py create mode 100644 scripts/validate_vendored_instances.py diff --git a/scripts/check_schema_mirrors.py b/scripts/check_schema_mirrors.py new file mode 100644 index 0000000..1bcb3d4 --- /dev/null +++ b/scripts/check_schema_mirrors.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Fail closed if vendored schema digests drift from SCHEMA_MIRROR manifests.""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def check_mirror(mirror_path: Path, schema_dir: Path) -> list[str]: + errors: list[str] = [] + mirror = json.loads(mirror_path.read_text(encoding="utf-8")) + local = mirror.get("local_digests") or {} + for name, expected in local.items(): + path = schema_dir / name + if not path.is_file(): + errors.append(f"missing vendored schema: {path}") + continue + digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if digest != expected: + errors.append(f"digest drift for {name}: expected {expected}, got {digest}") + return errors + + +def main() -> int: + errors: list[str] = [] + errors.extend( + check_mirror(ROOT / "schemas/pcs/SCHEMA_MIRROR.json", ROOT / "schemas/pcs") + ) + errors.extend( + check_mirror(ROOT / "schemas/pip/SCHEMA_MIRROR.json", ROOT / "schemas/pip") + ) + if errors: + for err in errors: + print(err, file=sys.stderr) + return 1 + print("schema mirrors OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/optional_external_validate.py b/scripts/optional_external_validate.py new file mode 100644 index 0000000..d52ff0e --- /dev/null +++ b/scripts/optional_external_validate.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Optional deeper semantic gates via sibling CLIs. + +Skip-only-when-absent is JUSTIFIED: default CI already enforces conformance via +vendored JSON Schema instance validation (``validate_vendored_instances.py``). +When ``pcs`` / ``post-incident`` are on PATH (or installed via extras), this +script runs their CLI checks and fails closed on non-zero exit. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _run(cmd: list[str]) -> int: + print("+", " ".join(cmd)) + completed = subprocess.run(cmd, check=False, cwd=str(ROOT)) + return int(completed.returncode) + + +def main() -> int: + skipped: list[str] = [] + failed = 0 + + pcs = shutil.which("pcs") + if pcs: + # Prefer a previously emitted receipt; otherwise emit one for pcs validate. + receipt_candidates = list( + (ROOT / "branches-smoke").glob("*/runtime_receipt.json") + ) + if receipt_candidates: + code = _run([pcs, "validate", str(receipt_candidates[0])]) + if code != 0: + failed += 1 + else: + print( + "pcs present but no runtime_receipt fixture to validate; " + "vendored schema gate remains authoritative" + ) + skipped.append("pcs-validate-no-receipt") + else: + skipped.append("pcs-cli-absent") + print( + "SKIP pcs validate: CLI not on PATH " + "(justified — vendored RuntimeReceipt.v0 schema validation always runs)" + ) + + post = shutil.which("post-incident") + if post: + lineage = ROOT / "fixtures/pip/valid_replay_linkage/lineage_bundle.json" + report = ROOT / "fixtures/pip/valid_replay_linkage/morph_replay_report.json" + code = _run([post, "lineage", "validate", str(lineage)]) + if code != 0: + failed += 1 + # replay-check may require a release_dir layout; attempt and fail closed. + release_dir = ROOT / "fixtures/pip/valid_replay_linkage" + code = _run( + [ + post, + "replay-check", + str(release_dir), + "--replay-report", + str(report), + ] + ) + if code != 0: + failed += 1 + else: + skipped.append("post-incident-cli-absent") + print( + "SKIP post-incident replay-check: CLI not on PATH " + "(justified — vendored PIP schema + linkage tests always run)" + ) + + print(f"optional_external_validate skipped={skipped} failed={failed}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_vendored_instances.py b/scripts/validate_vendored_instances.py new file mode 100644 index 0000000..3f7b921 --- /dev/null +++ b/scripts/validate_vendored_instances.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Validate fixture/example instances against vendored JSON Schemas. + +Always-on offline gate — does not require pcs / post-incident CLIs. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from runner.evidence.pcs import emit_runtime_receipt # noqa: E402 +from runner.evidence.pip import ( # noqa: E402 + emit_morph_replay_report, + emit_transformation_record, +) +from runner.evidence.validate import ( # noqa: E402 + ArtifactKind, + SchemaValidationError, + validate_instance, + validate_json_file, +) +from runner.profile import load_profile # noqa: E402 + + +def _load(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SchemaValidationError(f"{path}: expected object") + return data + + +def main() -> int: + errors: list[str] = [] + + def check(kind: ArtifactKind, path: Path) -> None: + try: + validate_json_file(kind, path) + print(f"ok {kind} {path.relative_to(ROOT)}") + except (SchemaValidationError, OSError, json.JSONDecodeError) as exc: + errors.append(f"{path}: {exc}") + print(f"FAIL {kind} {path.relative_to(ROOT)}: {exc}", file=sys.stderr) + + for path in [ + ROOT / "fixtures/profile/valid_profile.json", + ROOT / "examples/hermetic-branch-n/execution_profile.json", + ]: + try: + profile = load_profile(path) + payload = profile.model_dump(mode="json", exclude_none=True) + validate_instance("mrr.ExecutionProfile.v1", payload) + print(f"ok mrr.ExecutionProfile.v1 {path.relative_to(ROOT)}") + except Exception as exc: + errors.append(f"{path}: {exc}") + print(f"FAIL profile {path}: {exc}", file=sys.stderr) + + check("mrr.BranchReplayManifest.v1", ROOT / "fixtures/branch/manifest.json") + check( + "mrr.BranchReplayManifest.v1", + ROOT / "examples/hermetic-branch-n/manifest.json", + ) + + pip_root = ROOT / "fixtures/pip/valid_replay_linkage" + check("pip.LineageBundle.v1", pip_root / "lineage_bundle.json") + check("pip.MorphReplayReport.v1", pip_root / "morph_replay_report.json") + + lineage = _load(pip_root / "lineage_bundle.json") + for transform in lineage.get("transformations", []): + try: + validate_instance("pip.TransformationRecord.v1", transform) + except SchemaValidationError as exc: + errors.append(f"lineage transform: {exc}") + + try: + receipt = emit_runtime_receipt( + receipt_id="ci-receipt", + run_id="ci-run", + started_at="2026-07-24T00:00:00Z", + ended_at="2026-07-24T00:00:01Z", + run_outcome="passed", + final_reason_code="PASSED", + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + input_hashes={"a": "sha256:" + "a" * 64}, + output_hashes={"b": "sha256:" + "b" * 64}, + ) + validate_instance("RuntimeReceipt.v0", receipt) + print("ok RuntimeReceipt.v0 (emitted)") + emit_morph_replay_report( + replay_id="ci-replay", + branch_id="branch-00", + replay_identity_digest="sha256:" + "d" * 64, + input_artifact_digests=["sha256:" + "a" * 64], + output_artifact_digests=["sha256:" + "d" * 64], + status="recorded", + ) + emit_transformation_record( + transformation_id="t-ci", + transformation_type="normalization", + input_artifact_digests=["sha256:" + "a" * 64], + output_artifact_digest="sha256:" + "b" * 64, + implementation_id="morph-replay-runner", + implementation_version="0.1.0", + container_digest="sha256:" + "c" * 64, + source_commit="b3018d790e3e7c622b28641c0230a61bb3b78955", + ) + print("ok PIP emitters (emitted)") + except Exception as exc: + errors.append(f"emitter round-trip: {exc}") + print(f"FAIL emitter round-trip: {exc}", file=sys.stderr) + + if errors: + print(f"{len(errors)} schema instance validation error(s)", file=sys.stderr) + return 1 + print("vendored instance validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4a0df9a344aa4c6e41d77fcc35e858e83a004004 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:25 -0700 Subject: [PATCH 30/31] chore: add fixture generator and release bundle script Automate fixture refresh and produce a reproducible release artifact tree for distribution. --- scripts/generate_fixtures.py | 157 +++++++++++++++++++++++++++++++++++ scripts/release_bundle.py | 43 ++++++++++ 2 files changed, 200 insertions(+) create mode 100644 scripts/generate_fixtures.py create mode 100644 scripts/release_bundle.py diff --git a/scripts/generate_fixtures.py b/scripts/generate_fixtures.py new file mode 100644 index 0000000..bfb9cef --- /dev/null +++ b/scripts/generate_fixtures.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Generate deterministic fixtures and example trees.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from runner.profile.schema import ( + profile_digest, + validate_profile, + write_profile_with_digest, +) + +ROOT = Path(__file__).resolve().parents[1] +COMMIT = "b3018d790e3e7c622b28641c0230a61bb3b78955" + + +def main() -> None: + payload = b"mrr-local-fake-snapshot-v1\n" + snap_digest = "sha256:" + hashlib.sha256(payload).hexdigest() + (ROOT / "fixtures/branch/snapshot.payload").write_bytes(payload) + snap = {"snapshot_id": "snap-demo", "snapshot_digest": snap_digest} + for path in ( + ROOT / "fixtures/branch/snapshot_ref.json", + ROOT / "examples/hermetic-branch-n/snapshot_ref.json", + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(snap, indent=2) + "\n", encoding="utf-8") + for path in ( + ROOT / "fixtures/branch/snapshot.payload", + ROOT / "examples/hermetic-branch-n/snapshot.payload", + ): + path.write_bytes(payload) + + incident = { + "incident_id": "inc-demo", + "incident_digest": "sha256:" + hashlib.sha256(b"inc-demo").hexdigest(), + } + for path in ( + ROOT / "fixtures/branch/incident_bundle.json", + ROOT / "examples/hermetic-branch-n/incident_bundle.json", + ): + path.write_text(json.dumps(incident, indent=2) + "\n", encoding="utf-8") + + lock = "sha256:" + hashlib.sha256(b"lock").hexdigest() + img = "sha256:" + hashlib.sha256(b"image").hexdigest() + base_profile = { + "schema_version": "mrr.ExecutionProfile.v1", + "profile_id": "profile-demo", + "snapshot": snap, + "container_image_digests": [img], + "environment": {"profile_name": "hermetic-demo", "profile_version": "1.0.0"}, + "os_runtime": {"os": "linux", "runtime": "python3.11"}, + "dependency_lock_digest": lock, + "network_policy": {"mode": "no_network"}, + "clock_policy": {"mode": "frozen", "fixed_epoch_ms": 1700000000000}, + "random_seed_policy": {"seed": 42}, + "tool_versions": {"replay-runner": "0.1.0"}, + "budgets": { + "cpu_cores": 2, + "memory_mib": 1024, + "accelerator_count": 0, + "storage_mib": 2048, + "wall_time_ms": 60000, + "token_budget": 0, + "api_call_budget": 0, + }, + "secret_refs": [{"secret_ref": "vault/morph/api", "purpose": "optional-morph"}], + "output_retention": {"mode": "retain", "retention_days": 30}, + "source_commit": COMMIT, + "integrity": {"canonicalization_version": "v1"}, + } + + profile = validate_profile(base_profile) + digest = profile_digest(profile) + write_profile_with_digest(profile, ROOT / "fixtures/profile/valid_profile.json") + write_profile_with_digest( + profile, ROOT / "examples/hermetic-branch-n/execution_profile.json" + ) + + bad = dict(base_profile) + bad["api_key"] = "sk-secret-value" + (ROOT / "fixtures/profile/invalid_secret_value.json").write_text( + json.dumps(bad, indent=2) + "\n", encoding="utf-8" + ) + + bad2 = dict(base_profile) + bad2["schema_version"] = "mrr.ExecutionProfile.v999" + (ROOT / "fixtures/profile/invalid_schema_version.json").write_text( + json.dumps(bad2, indent=2) + "\n", encoding="utf-8" + ) + + bad3 = dict(base_profile) + del bad3["dependency_lock_digest"] + (ROOT / "fixtures/profile/invalid_missing_pin.json").write_text( + json.dumps(bad3, indent=2) + "\n", encoding="utf-8" + ) + + int_dir = ROOT / "fixtures/branch/interventions" + ex_int = ROOT / "examples/hermetic-branch-n/interventions" + int_dir.mkdir(parents=True, exist_ok=True) + ex_int.mkdir(parents=True, exist_ok=True) + branches = [] + for i in range(16): + bid = f"branch-{i:02d}" + body = { + "branch_id": bid, + "intervention_id": f"iv-{i:02d}", + "artifact_paths": [f"{bid}.json"], + "action_sequence": ["replay", bid], + } + for directory in (int_dir, ex_int): + (directory / f"{bid}.json").write_text( + json.dumps(body, indent=2) + "\n", encoding="utf-8" + ) + branches.append( + { + "branch_id": bid, + "intervention": { + "intervention_id": f"iv-{i:02d}", + "artifact_paths": [f"{bid}.json"], + }, + "action_sequence": ["replay", bid], + "claim_class": "RuntimeObserved", + } + ) + + manifest = { + "schema_version": "mrr.BranchReplayManifest.v1", + "manifest_id": "manifest-demo", + "incident_ref": incident, + "snapshot_ref": snap, + "execution_profile_digest": digest, + "branches": branches, + "retry_policy": {"max_retries": 1, "retry_on": ["TIMEOUT", "ERROR"]}, + "cancellation_policy": {"cancel_others_on_failure": False}, + "branch_independence": True, + } + for path in ( + ROOT / "fixtures/branch/manifest.json", + ROOT / "examples/hermetic-branch-n/manifest.json", + ): + path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + drift = dict(manifest) + drift["execution_profile_digest"] = "sha256:" + ("b" * 64) + (ROOT / "fixtures/branch/manifest_digest_drift.json").write_text( + json.dumps(drift, indent=2) + "\n", encoding="utf-8" + ) + print("profile_digest", digest) + print("snapshot_digest", snap_digest) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_bundle.py b/scripts/release_bundle.py new file mode 100644 index 0000000..89c73f1 --- /dev/null +++ b/scripts/release_bundle.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Build a release checksum bundle for schemas and example evidence.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "release-bundle" + + +def file_digest(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + entries: dict[str, str] = {} + for pattern in ( + "schemas/mrr/*.json", + "schemas/pcs/*.json", + "schemas/pip/*.json", + "examples/hermetic-branch-n/**/*", + "fixtures/pip/valid_replay_linkage/*", + ): + for path in ROOT.glob(pattern): + if path.is_file(): + rel = path.relative_to(ROOT).as_posix() + entries[rel] = file_digest(path) + payload = { + "schema_version": "mrr.ReleaseChecksums.v1", + "files": dict(sorted(entries.items())), + } + (OUT / "checksums.json").write_text( + json.dumps(payload, indent=2) + "\n", encoding="utf-8" + ) + print(f"wrote {OUT / 'checksums.json'} ({len(entries)} files)") + + +if __name__ == "__main__": + main() From 2a834d7bc4b75a354f28b29772f6a93fa9d751f0 Mon Sep 17 00:00:00 2001 From: fraware Date: Fri, 24 Jul 2026 23:30:25 -0700 Subject: [PATCH 31/31] chore: update Docker image for hermetic runner layout Align the container build and compose wiring with the new package layout and offline defaults. --- docker/Dockerfile | 16 +++++++--------- docker/docker-compose.yml | 15 ++++----------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e9f50ea..5a71572 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,27 +1,25 @@ FROM python:3.11-slim -# Set working directory WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ git \ curl \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install Python dependencies -COPY pyproject.toml requirements.txt* ./ +# Copy project before editable install so package sources exist. +COPY pyproject.toml README.md LICENSE ./ +COPY runner ./runner +COPY schemas ./schemas +COPY requirements.txt* ./ + RUN pip install --no-cache-dir -e . -# Copy source code COPY . . -# Create necessary directories RUN mkdir -p /app/evidence/certs /app/evidence/logs /app/evidence/reports -# Set environment variables ENV PYTHONPATH=/app ENV MORPH_API_KEY="" -# Default command CMD ["replay-runner", "--help"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c939f51..5814ec8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,7 @@ -version: '3.8' +version: "3.8" + +# Local Morph container helper. HTTP callback stub removed (fail-closed / +# out of scope for hermetic branch-N). Use `replay-runner branch` offline. services: morph-replay-runner: @@ -12,13 +15,3 @@ services: - ../replays:/app/replays:ro working_dir: /app command: ["replay-runner", "--help"] - - # Example service for testing HTTP callbacks - test-http: - image: nginx:alpine - ports: - - "8080:80" - volumes: - - ./test-http.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - morph-replay-runner