diff --git a/README.md b/README.md index 9ba0877..2501901 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,43 @@ Agentplane treats execution as evidence-producing work. The current public evide - `NetworkDoorPlanEvidence` - `ExternalModelProviderRouteEvidence` - `NativeAssistantBridgeEvidence` +- `SupplyChainValidationArtifact` The Network Door / BYOM / Native Assistant evidence types are non-mutating by default. They record policy posture, references, route decisions, hash-only prompt/destination evidence, and side-effect flags without directly mutating firewall state, installing mesh components, contacting model providers, invoking native assistant APIs, or storing credentials. --- +## Prophet Trust Chain supply-chain validation + +Agentplane owns the validation, replay, and receipt evidence slice of Prophet Trust Chain. The platform standard and admission contract live in `SocioProphet/prophet-platform`: + +- `docs/standards/PROPHET_TRUST_CHAIN_V0.md` +- `docs/TRUST_CHAIN_ADMISSION_CONTRACT.md` +- `docs/standards/PROPHET_TRUST_CHAIN_IMPLEMENTATION_MAP.md` + +The first Agentplane Trust Chain slice defines `SupplyChainValidationArtifact`, which binds runtime supply-chain evidence to Agentplane validation/replay/receipt evidence. + +Relevant files: + +- `schemas/trust-chain/supply-chain-validation-artifact.v0.1.schema.json` +- `tests/fixtures/trust-chain/supply-chain-validation.valid.json` +- `tests/fixtures/trust-chain/supply-chain-validation.blocked.json` +- `tools/validate_trust_chain_supply_chain_validation.py` +- `tools/tests/test_trust_chain_supply_chain_validation.py` + +Validation: + +```bash +python3 tools/validate_trust_chain_supply_chain_validation.py +python3 -m pytest -q tools/tests/test_trust_chain_supply_chain_validation.py +``` + +The valid fixture requires SBOM, VEX, lockfile, signature, scan, promotion, rollback, policy, guardrail, validation, replay, and runtime receipt references before production-scope execution and promotion are allowed. The blocked fixture denies execution and promotion, requires repair and human review, and preserves remediation authority. + +Boundary: Agentplane records validation, replay, and runtime receipt evidence. It does not perform live package scanning, certify runtime production readiness by itself, replace Lattice Forge runtime evidence, replace Policy Fabric policy profiles, or replace Guardrail Fabric action admission. + +--- + ## Prerequisites | Tool | Purpose | diff --git a/schemas/trust-chain/supply-chain-validation-artifact.v0.1.schema.json b/schemas/trust-chain/supply-chain-validation-artifact.v0.1.schema.json new file mode 100644 index 0000000..76ef1e6 --- /dev/null +++ b/schemas/trust-chain/supply-chain-validation-artifact.v0.1.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.socioprophet.dev/agentplane/trust-chain/supply-chain-validation-artifact.v0.1.schema.json", + "title": "SupplyChainValidationArtifact v0.1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "artifact_id", "artifact_type", "runtime_asset_ref", "scope", "evidence_refs", "policy_decision_ref", "guardrail_decision_ref", "validation_artifact_ref", "replay_artifact_ref", "runtime_receipt_ref", "decision", "effects", "non_claims"], + "properties": { + "schema_version": {"const": "0.1"}, + "artifact_id": {"type": "string", "pattern": "^agentplane:supply-chain-validation-artifact:"}, + "artifact_type": {"const": "SupplyChainValidationArtifact"}, + "runtime_asset_ref": {"type": "string", "pattern": "^runtime-asset://"}, + "scope": {"type": "object", "additionalProperties": false, "required": ["environment", "risk_tier"], "properties": {"environment": {"enum": ["preview", "staging", "production"]}, "risk_tier": {"enum": ["internal", "enterprise", "regulated_enterprise"]}}}, + "evidence_refs": {"type": "object", "additionalProperties": false, "required": ["sbom_ref", "vex_ref", "lockfile_ref", "signature_ref", "scan_record_ref"], "properties": {"sbom_ref": {"type": ["string", "null"]}, "vex_ref": {"type": ["string", "null"]}, "lockfile_ref": {"type": ["string", "null"]}, "signature_ref": {"type": ["string", "null"]}, "scan_record_ref": {"type": ["string", "null"]}, "promotion_evidence_ref": {"type": ["string", "null"]}, "rollback_evidence_ref": {"type": ["string", "null"]}}}, + "policy_decision_ref": {"type": ["string", "null"]}, + "guardrail_decision_ref": {"type": ["string", "null"]}, + "validation_artifact_ref": {"type": ["string", "null"]}, + "replay_artifact_ref": {"type": ["string", "null"]}, + "runtime_receipt_ref": {"type": ["string", "null"]}, + "decision": {"enum": ["validated", "blocked", "review_required"]}, + "effects": {"type": "object", "additionalProperties": false, "required": ["execution_allowed", "promotion_allowed", "repair_required", "human_review_required"], "properties": {"execution_allowed": {"type": "boolean"}, "promotion_allowed": {"type": "boolean"}, "repair_required": {"type": "boolean"}, "human_review_required": {"type": "boolean"}}}, + "remediation": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["step_id", "authority", "required_before_execution", "instruction"], "properties": {"step_id": {"type": "string"}, "authority": {"type": "string"}, "required_before_execution": {"type": "boolean"}, "instruction": {"type": "string"}}}}, + "non_claims": {"type": "array", "items": {"type": "string"}, "minItems": 1} + } +} diff --git a/tests/fixtures/trust-chain/supply-chain-validation.blocked.json b/tests/fixtures/trust-chain/supply-chain-validation.blocked.json new file mode 100644 index 0000000..8a84ed4 --- /dev/null +++ b/tests/fixtures/trust-chain/supply-chain-validation.blocked.json @@ -0,0 +1,49 @@ +{ + "schema_version": "0.1", + "artifact_id": "agentplane:supply-chain-validation-artifact:runtime-asset-demo-002", + "artifact_type": "SupplyChainValidationArtifact", + "runtime_asset_ref": "runtime-asset://lattice-forge/legacy-python-ml@0.0.3", + "scope": { + "environment": "production", + "risk_tier": "regulated_enterprise" + }, + "evidence_refs": { + "sbom_ref": "sbom://lattice-forge/legacy-python-ml/0.0.3/spdx.json", + "vex_ref": null, + "lockfile_ref": "lockfile://lattice-forge/legacy-python-ml/0.0.3/flake.lock", + "signature_ref": "sigstore://lattice-forge/legacy-python-ml/0.0.3/bundle.sigstore", + "scan_record_ref": "scan://lattice-forge/legacy-python-ml/0.0.3/osv-scan.json", + "promotion_evidence_ref": null, + "rollback_evidence_ref": null + }, + "policy_decision_ref": "policy://trust-chain/dependency-admission/regulated-enterprise-production", + "guardrail_decision_ref": "guardrail:decision:trust-chain-runtime-deny-001", + "validation_artifact_ref": null, + "replay_artifact_ref": null, + "runtime_receipt_ref": null, + "decision": "blocked", + "effects": { + "execution_allowed": false, + "promotion_allowed": false, + "repair_required": true, + "human_review_required": true + }, + "remediation": [ + { + "step_id": "patch-runtime-asset", + "authority": "SocioProphet/lattice-forge", + "required_before_execution": true, + "instruction": "Replace or patch the vulnerable RuntimeAsset and emit fresh SBOM, VEX, scan, signature, promotion, and rollback evidence." + }, + { + "step_id": "rerun-agentplane-validation", + "authority": "SocioProphet/agentplane", + "required_before_execution": true, + "instruction": "Run validation and replay after the patched RuntimeAsset is available." + } + ], + "non_claims": [ + "This fixture is expected to remain blocked until required evidence exists.", + "This fixture does not perform live package scanning." + ] +} diff --git a/tests/fixtures/trust-chain/supply-chain-validation.valid.json b/tests/fixtures/trust-chain/supply-chain-validation.valid.json new file mode 100644 index 0000000..fba68da --- /dev/null +++ b/tests/fixtures/trust-chain/supply-chain-validation.valid.json @@ -0,0 +1,37 @@ +{ + "schema_version": "0.1", + "artifact_id": "agentplane:supply-chain-validation-artifact:runtime-asset-demo-001", + "artifact_type": "SupplyChainValidationArtifact", + "runtime_asset_ref": "runtime-asset://lattice-forge/prophet-python-ml@0.1.0", + "scope": { + "environment": "production", + "risk_tier": "regulated_enterprise" + }, + "evidence_refs": { + "sbom_ref": "sbom://lattice-forge/prophet-python-ml/0.1.0/spdx.json", + "vex_ref": "vex://lattice-forge/prophet-python-ml/0.1.0/vex.json", + "lockfile_ref": "lockfile://lattice-forge/prophet-python-ml/0.1.0/flake.lock", + "signature_ref": "sigstore://lattice-forge/prophet-python-ml/0.1.0/bundle.sigstore", + "scan_record_ref": "scan://lattice-forge/prophet-python-ml/0.1.0/osv-scan.json", + "promotion_evidence_ref": "promotion://lattice-forge/prophet-python-ml/0.1.0/stable", + "rollback_evidence_ref": "rollback://lattice-forge/prophet-python-ml/0.0.1" + }, + "policy_decision_ref": "policy://trust-chain/dependency-admission/regulated-enterprise-production", + "guardrail_decision_ref": "guardrail:decision:trust-chain-runtime-allow-001", + "validation_artifact_ref": "agentplane:validation:runtime-asset-demo-001", + "replay_artifact_ref": "agentplane:replay:runtime-asset-demo-001", + "runtime_receipt_ref": "runtime-receipt://trust-chain/demo-runtime-asset-001", + "decision": "validated", + "effects": { + "execution_allowed": true, + "promotion_allowed": true, + "repair_required": false, + "human_review_required": false + }, + "remediation": [], + "non_claims": [ + "This fixture does not perform live package scanning.", + "This fixture does not claim IBM/Red Hat Lightwell integration.", + "This fixture is a contract example and not a production certification by itself." + ] +} diff --git a/tools/tests/test_trust_chain_supply_chain_validation.py b/tools/tests/test_trust_chain_supply_chain_validation.py new file mode 100644 index 0000000..a932e7c --- /dev/null +++ b/tools/tests/test_trust_chain_supply_chain_validation.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tools.validate_trust_chain_supply_chain_validation import main as validate_trust_chain_supply_chain_validation + + +ROOT = Path(__file__).resolve().parents[2] +VALID_FIXTURE = ROOT / "tests" / "fixtures" / "trust-chain" / "supply-chain-validation.valid.json" +BLOCKED_FIXTURE = ROOT / "tests" / "fixtures" / "trust-chain" / "supply-chain-validation.blocked.json" + + +def test_trust_chain_supply_chain_validation_artifacts_validate() -> None: + assert validate_trust_chain_supply_chain_validation() == 0 + + +def test_valid_supply_chain_artifact_allows_execution_and_promotion() -> None: + fixture = json.loads(VALID_FIXTURE.read_text(encoding="utf-8")) + assert fixture["decision"] == "validated" + assert fixture["effects"]["execution_allowed"] is True + assert fixture["effects"]["promotion_allowed"] is True + assert fixture["effects"]["repair_required"] is False + assert fixture["effects"]["human_review_required"] is False + + +def test_blocked_supply_chain_artifact_blocks_execution_and_requires_repair() -> None: + fixture = json.loads(BLOCKED_FIXTURE.read_text(encoding="utf-8")) + assert fixture["decision"] == "blocked" + assert fixture["effects"]["execution_allowed"] is False + assert fixture["effects"]["promotion_allowed"] is False + assert fixture["effects"]["repair_required"] is True + assert fixture["effects"]["human_review_required"] is True + assert fixture["remediation"] diff --git a/tools/validate_trust_chain_supply_chain_validation.py b/tools/validate_trust_chain_supply_chain_validation.py new file mode 100644 index 0000000..da28bfb --- /dev/null +++ b/tools/validate_trust_chain_supply_chain_validation.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = ROOT / "schemas" / "trust-chain" / "supply-chain-validation-artifact.v0.1.schema.json" +VALID_FIXTURE = ROOT / "tests" / "fixtures" / "trust-chain" / "supply-chain-validation.valid.json" +BLOCKED_FIXTURE = ROOT / "tests" / "fixtures" / "trust-chain" / "supply-chain-validation.blocked.json" + +REQUIRED_PRODUCTION_REFS = { + "sbom_ref", + "vex_ref", + "lockfile_ref", + "signature_ref", + "scan_record_ref", + "promotion_evidence_ref", + "rollback_evidence_ref", +} + + +class ValidationError(Exception): + pass + + +def fail(message: str) -> None: + raise ValidationError(message) + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ValidationError(f"missing file: {path.relative_to(ROOT)}") from exc + except json.JSONDecodeError as exc: + raise ValidationError(f"invalid JSON in {path.relative_to(ROOT)}: {exc}") from exc + + +def json_type_name(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int) and not isinstance(value, bool): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return type(value).__name__ + + +def type_matches(value: Any, expected: str) -> bool: + actual = json_type_name(value) + if expected == "number": + return actual in {"integer", "number"} + return actual == expected + + +def validate_schema(schema: dict[str, Any], value: Any, path: str = "$") -> None: + if "const" in schema and value != schema["const"]: + fail(f"{path}: expected const {schema['const']!r}, got {value!r}") + if "enum" in schema and value not in schema["enum"]: + fail(f"{path}: {value!r} not in enum {schema['enum']!r}") + expected_type = schema.get("type") + if expected_type is not None: + expected_types = expected_type if isinstance(expected_type, list) else [expected_type] + if not any(type_matches(value, item) for item in expected_types): + fail(f"{path}: expected type {expected_types!r}, got {json_type_name(value)!r}") + if isinstance(value, dict): + required = schema.get("required", []) + for key in required: + if key not in value: + fail(f"{path}: missing required property {key!r}") + properties = schema.get("properties", {}) + if schema.get("additionalProperties") is False: + extra = sorted(set(value) - set(properties)) + if extra: + fail(f"{path}: unexpected properties {extra!r}") + additional = schema.get("additionalProperties") + for key, item in value.items(): + child_schema = properties.get(key) + if child_schema is None and isinstance(additional, dict): + child_schema = additional + if child_schema is not None: + validate_schema(child_schema, item, f"{path}.{key}") + if isinstance(value, list): + item_schema = schema.get("items") + if item_schema is not None: + for index, item in enumerate(value): + validate_schema(item_schema, item, f"{path}[{index}]") + + +def validate_common(record: dict[str, Any], path: Path) -> None: + if not str(record.get("runtime_asset_ref", "")).startswith("runtime-asset://"): + fail(f"{path}: runtime_asset_ref must be runtime-asset://") + if record.get("scope", {}).get("environment") == "production" and record.get("scope", {}).get("risk_tier") == "regulated_enterprise": + if record.get("decision") == "validated": + refs = record.get("evidence_refs", {}) + missing = sorted(key for key in REQUIRED_PRODUCTION_REFS if not refs.get(key)) + if missing: + fail(f"{path}: validated production record missing refs: {missing}") + for ref_key in ("policy_decision_ref", "guardrail_decision_ref", "validation_artifact_ref", "replay_artifact_ref", "runtime_receipt_ref"): + if not record.get(ref_key): + fail(f"{path}: validated production record missing {ref_key}") + + +def validate_valid(record: dict[str, Any], path: Path) -> None: + validate_common(record, path) + if record.get("decision") != "validated": + fail(f"{path}: valid fixture must have decision=validated") + effects = record.get("effects", {}) + if effects.get("execution_allowed") is not True or effects.get("promotion_allowed") is not True: + fail(f"{path}: valid fixture must allow execution and promotion") + if effects.get("repair_required") is not False or effects.get("human_review_required") is not False: + fail(f"{path}: valid fixture must not require repair or review") + + +def validate_blocked(record: dict[str, Any], path: Path) -> None: + validate_common(record, path) + if record.get("decision") != "blocked": + fail(f"{path}: blocked fixture must have decision=blocked") + effects = record.get("effects", {}) + if effects.get("execution_allowed") is not False or effects.get("promotion_allowed") is not False: + fail(f"{path}: blocked fixture must deny execution and promotion") + if effects.get("repair_required") is not True or effects.get("human_review_required") is not True: + fail(f"{path}: blocked fixture must require repair and human review") + remediation = record.get("remediation", []) + if not isinstance(remediation, list) or len(remediation) < 1: + fail(f"{path}: blocked fixture requires remediation") + for item in remediation: + if item.get("required_before_execution") is not True: + fail(f"{path}: remediation must be required before execution") + if not item.get("authority"): + fail(f"{path}: remediation requires authority") + + +def main() -> int: + try: + schema = load_json(SCHEMA) + valid = load_json(VALID_FIXTURE) + blocked = load_json(BLOCKED_FIXTURE) + validate_schema(schema, valid) + validate_schema(schema, blocked) + validate_valid(valid, VALID_FIXTURE) + validate_blocked(blocked, BLOCKED_FIXTURE) + except ValidationError as exc: + print(f"ERR: {exc}", file=sys.stderr) + return 2 + print("OK: Trust Chain supply-chain validation artifacts passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())