From 72599b436637dcf8324e2dc59f66ee1c9fdaaef8 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:48:16 -0700 Subject: [PATCH 01/26] feat(contracts): expand vendor shared authorization schemas to 2.1 Add grant and request contract modules so AKTA can pin a single shared vocabulary for institutional authorization. --- .../README.md | 32 +++++++- .../pyproject.toml | 46 +++++++++++ .../__init__.py | 78 ++++++++++++++++++- .../grant.py | 73 +++++++++++++++++ .../request.py | 63 +++++++++++++++ 5 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 vendor/scientific-authorization-contracts/pyproject.toml create mode 100644 vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/grant.py create mode 100644 vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/request.py diff --git a/vendor/scientific-authorization-contracts/README.md b/vendor/scientific-authorization-contracts/README.md index 949f9b3..9192e96 100644 --- a/vendor/scientific-authorization-contracts/README.md +++ b/vendor/scientific-authorization-contracts/README.md @@ -6,6 +6,7 @@ This package is the authoritative source for capability families, operations, responsibility levels, expiration events, and JSON Schemas. Neither SCOPE nor AKTA should redefine these contracts independently. +**Package:** `scientific-authorization-contracts==2.1.0` Status: beta / reference. Not an institutional certification package. ## Layout @@ -14,7 +15,24 @@ Status: beta / reference. Not an institutional certification package. schemas/ JSON Schema artifacts (hashed for CI drift detection) vocabularies/ Frozen enums and operation partial orders conformance/ Positive, negative, narrowing, and conflict fixtures -python/ Optional loader helpers for Python consumers +python/ Installable Python package (loader, hashing, envelope, grant) +pyproject.toml Packaging metadata for pip install from this tree +``` + +## Install (AKTA / CI) + +No public PyPI index yet. Install from the vendor tree before AKTA: + +```bash +pip install ./vendor/scientific-authorization-contracts +pip install -e ".[dev]" # or install the AKTA wheel after contracts +``` + +Wheel smoke order (clean venv): + +```bash +pip install ./vendor/scientific-authorization-contracts +pip install dist/akta_protocol-*.whl ``` ## Envelope version @@ -23,8 +41,14 @@ python/ Optional loader helpers for Python consumers ## Consumption -SCOPE loads schemas and vocabularies from this directory at runtime and embeds -`authorization_schema_version` plus `authorization_schema_hash` on decisions -and grants. AKTA should pin the same directory (or published artifact hash). +Import the installed distribution only: + +```python +import scientific_authorization_contracts as contracts + +contracts.load_schema("authorization_envelope.v2.schema.json") +contracts.validate_grant(grant) +contracts.envelope_covers(granted, requested) +``` Schema drift between repositories must fail CI. diff --git a/vendor/scientific-authorization-contracts/pyproject.toml b/vendor/scientific-authorization-contracts/pyproject.toml new file mode 100644 index 0000000..5424251 --- /dev/null +++ b/vendor/scientific-authorization-contracts/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "scientific-authorization-contracts" +version = "2.1.0" +description = "Shared scientific authorization envelope, grant, and status contracts for SCOPE and AKTA" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "SCOPE/AKTA Contributors" }] +keywords = ["authorization", "scientific-action", "capability-envelope"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "jsonschema>=4.20", +] + +[project.urls] +Homepage = "https://github.com/scientific-authorization/contracts" + +[tool.hatch.build.targets.wheel] +packages = ["python/scientific_authorization_contracts"] + +[tool.hatch.build.targets.wheel.force-include] +"schemas" = "scientific_authorization_contracts/schemas" +"vocabularies" = "scientific_authorization_contracts/vocabularies" +"conformance" = "scientific_authorization_contracts/conformance" + +[tool.hatch.build.targets.sdist] +include = [ + "python/scientific_authorization_contracts", + "schemas", + "vocabularies", + "conformance", + "README.md", + "pyproject.toml", +] diff --git a/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/__init__.py b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/__init__.py index b727024..1afed5c 100644 --- a/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/__init__.py +++ b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/__init__.py @@ -8,8 +8,30 @@ from pathlib import Path from typing import Any +__version__ = "2.1.0" ENVELOPE_VERSION = "scientific-authorization-envelope-v2" -PACKAGE_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_VERSION = __version__ + + +def _data_root() -> Path: + """Resolve schemas/vocabularies/conformance for source tree or installed wheel. + + Source layout keeps artifacts at the vendor root (sibling of ``python/``). + Installed wheels place them next to this package via hatch force-include. + """ + here = Path(__file__).resolve().parent + if (here / "schemas").is_dir(): + return here + vendor_root = here.parents[1] + if (vendor_root / "schemas").is_dir(): + return vendor_root + raise FileNotFoundError( + "scientific-authorization-contracts data root not found " + "(expected schemas/ next to package or at vendor root)" + ) + + +PACKAGE_ROOT = _data_root() SCHEMAS_DIR = PACKAGE_ROOT / "schemas" VOCAB_DIR = PACKAGE_ROOT / "vocabularies" CONFORMANCE_DIR = PACKAGE_ROOT / "conformance" @@ -97,6 +119,10 @@ def grant_schema_hash() -> str: return schema_file_hash("scope_grant.v2.schema.json") +def grant_status_schema_hash() -> str: + return schema_file_hash("scope_grant_status.v1.schema.json") + + def load_conformance_cases(kind: str) -> list[dict[str, Any]]: root = CONFORMANCE_DIR / kind if not root.is_dir(): @@ -107,6 +133,40 @@ def load_conformance_cases(kind: str) -> list[dict[str, Any]]: return cases +def release_artifact_hash() -> str: + """Stable hash over schemas, vocabularies, conformance, and python modules. + + Uses logical paths (``schemas/...``, ``python/scientific_authorization_contracts/...``) + so source-tree and installed-wheel layouts produce the same digest. + """ + digest = hashlib.sha256() + pkg_dir = Path(__file__).resolve().parent + + def _feed(logical: str, path: Path) -> None: + digest.update(logical.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + + for label, directory in ( + ("schemas", SCHEMAS_DIR), + ("vocabularies", VOCAB_DIR), + ("conformance", CONFORMANCE_DIR), + ): + if not directory.is_dir(): + continue + for path in sorted(p for p in directory.rglob("*") if p.is_file()): + if path.suffix != ".json" or "__pycache__" in path.parts: + continue + rel = path.relative_to(directory).as_posix() + _feed(f"{label}/{rel}", path) + + for path in sorted(p for p in pkg_dir.glob("*.py") if p.is_file()): + _feed(f"python/scientific_authorization_contracts/{path.name}", path) + + return "sha256:" + digest.hexdigest() + + # Re-export envelope API for consumers. from scientific_authorization_contracts.envelope import ( # noqa: E402 AuthorizationEnvelope, @@ -121,17 +181,29 @@ def load_conformance_cases(kind: str) -> list[dict[str, Any]]: new_capability_id, validate_envelope_dict, ) +from scientific_authorization_contracts.grant import ( # noqa: E402 + GrantValidationError, + validate_grant, + validate_grant_status, +) from scientific_authorization_contracts.hashing import ( # noqa: E402 attach_hash, compute_hash, verify_hash, ) +from scientific_authorization_contracts.request import ( # noqa: E402 + RequestedAuthorizationContext, +) __all__ = [ "ENVELOPE_VERSION", + "PACKAGE_VERSION", "AuthorizationEnvelope", "EnvelopeConflictError", "EnvelopeValidationError", + "GrantValidationError", + "RequestedAuthorizationContext", + "__version__", "attach_hash", "authorization_envelope_schema_hash", "authorization_schema_provenance", @@ -147,14 +219,18 @@ def load_conformance_cases(kind: str) -> list[dict[str, Any]]: "family_operations", "grant_schema_hash", "grant_schema_version", + "grant_status_schema_hash", "legacy_scope_migration", "load_conformance_cases", "load_envelope_schema", "load_schema", "new_capability_id", + "release_artifact_hash", "responsibility_rank", "schema_file_hash", "target_types", "validate_envelope_dict", + "validate_grant", + "validate_grant_status", "verify_hash", ] diff --git a/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/grant.py b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/grant.py new file mode 100644 index 0000000..fc4c395 --- /dev/null +++ b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/grant.py @@ -0,0 +1,73 @@ +"""Grant and grant-status validation against shared JSON Schemas.""" + +from __future__ import annotations + +from typing import Any + +from scientific_authorization_contracts.hashing import verify_hash + +GRANT_V2_SCHEMA_NAME = "scope_grant.v2.schema.json" +GRANT_STATUS_SCHEMA_NAME = "scope_grant_status.v1.schema.json" + + +class GrantValidationError(ValueError): + """Grant or grant-status payload failed shared-schema validation.""" + + def __init__(self, message: str, *, errors: list[str] | None = None) -> None: + self.errors = list(errors or [message]) + super().__init__(message) + + +def _jsonschema_validate(instance: dict[str, Any], schema_name: str) -> list[str]: + from scientific_authorization_contracts import load_schema + + try: + import jsonschema + except ImportError as exc: # pragma: no cover - declared dependency + raise GrantValidationError( + "jsonschema is required for grant validation" + ) from exc + + schema = load_schema(schema_name) + validator_cls = jsonschema.Draft202012Validator + validator = validator_cls(schema) + return [ + f"{'/'.join(str(p) for p in err.path) or ''}: {err.message}" + for err in validator.iter_errors(instance) + ] + + +def validate_grant( + grant: dict[str, Any], + *, + require_hash: bool = True, + raise_on_error: bool = True, +) -> list[str]: + """Validate a SCOPE grant against ``scope-grant-v2`` (+ optional grant_hash).""" + errors = _jsonschema_validate(grant, GRANT_V2_SCHEMA_NAME) + if require_hash and "grant_hash" in grant and not verify_hash(grant, "grant_hash"): + errors.append("grant_hash mismatch (invalid or tampered grant payload)") + if raise_on_error and errors: + raise GrantValidationError("; ".join(errors), errors=errors) + return errors + + +def validate_grant_status( + status: dict[str, Any], + *, + raise_on_error: bool = True, +) -> list[str]: + """Validate a grant-status attestation against ``scope-grant-status-v1``.""" + errors = _jsonschema_validate(status, GRANT_STATUS_SCHEMA_NAME) + if raise_on_error and errors: + raise GrantValidationError("; ".join(errors), errors=errors) + return errors + + +__all__ = [ + "GRANT_STATUS_SCHEMA_NAME", + "GRANT_V2_SCHEMA_NAME", + "GrantValidationError", + "validate_grant", + "validate_grant_status", +] diff --git a/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/request.py b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/request.py new file mode 100644 index 0000000..7e211f5 --- /dev/null +++ b/vendor/scientific-authorization-contracts/python/scientific_authorization_contracts/request.py @@ -0,0 +1,63 @@ +"""Runtime requested-authorization model shared by SCOPE issuers and AKTA consumers. + +AKTA 1.1 Phase C (WS8) builds and compares this shape against granted envelopes. +The dataclass lives in the contracts package so both repos share one type. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class RequestedAuthorizationContext: + """Concrete runtime request to be checked for grant containment.""" + + family: str + operation: str + target: dict[str, Any] = field(default_factory=dict) + requested_tool: str = "" + environment: dict[str, Any] = field(default_factory=dict) + responsibility_level: str = "R3_recommendation" + item_count: int = 1 + constraints: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RequestedAuthorizationContext: + return cls( + family=str(data["family"]), + operation=str(data["operation"]), + target=dict(data.get("target") or {}), + requested_tool=str(data.get("requested_tool") or ""), + environment=dict(data.get("environment") or {}), + responsibility_level=str( + data.get("responsibility_level") or "R3_recommendation" + ), + item_count=int(data.get("item_count") or 1), + constraints=dict(data.get("constraints") or {}), + ) + + def as_envelope_request(self) -> dict[str, Any]: + """Project into envelope fields used by ``envelope_covers``.""" + return { + "family": self.family, + "operation": self.operation, + "target": dict(self.target), + "allowed_tools": [self.requested_tool] if self.requested_tool else [], + "blocked_tools": [], + "cardinality": { + "mode": "bounded" if self.item_count > 1 else "single", + "maximum_uses": self.item_count, + }, + "environment": dict(self.environment), + "responsibility_ceiling": self.responsibility_level, + "constraints": dict(self.constraints), + "expiration": {}, + } + + +__all__ = ["RequestedAuthorizationContext"] From be7d7d413046c7e7fde9817678673028a9e5a6d9 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:48:23 -0700 Subject: [PATCH 02/26] build: require contracts 2.1, cryptography, and REST extras Make crypto a core dependency for grant verification and declare institutional REST optional packages explicitly. --- pyproject.toml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2831c08..8263584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dependencies = [ "jsonschema>=4.20", "PyYAML>=6.0", "setuptools>=68", + "cryptography>=41.0", + # Install from vendor before / with AKTA (no public index yet): + # pip install ./vendor/scientific-authorization-contracts + "scientific-authorization-contracts==2.1.0", ] [project.optional-dependencies] @@ -37,11 +41,16 @@ dev = [ "build>=1.2", "twine>=5.1", ] +# Kept for backwards-compatible extras; cryptography is now a core dependency. security = [ "cryptography>=41.0", ] -# REST server uses stdlib http.server; no extra deps required in v0.1. -rest = [] +# Institutional REST (WS13): OIDC verification + PostgreSQL persistence. +# Development/pilot modes work without these; institutional-service fails closed. +rest = [ + "PyJWT>=2.8", + "psycopg[binary]>=3.1", +] [project.scripts] akta = "akta.cli:main" From 134ed1ec7638fefd465a796e8365b657f300dd52 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:48:36 -0700 Subject: [PATCH 03/26] feat(contracts): pin shared contracts 2.1 and harden drift checks Keep runtime loading aligned with the vendored package and fail CI when pin or schema drift appears. --- akta/shared_contracts.py | 111 ++++++++++++----------- policy/shared_contracts_pin.json | 5 +- scripts/assert_shared_contract_drift.py | 112 +++++++++++++++++++----- tests/test_shared_contract_drift.py | 2 + 4 files changed, 152 insertions(+), 78 deletions(-) diff --git a/akta/shared_contracts.py b/akta/shared_contracts.py index eb5f587..6551524 100644 --- a/akta/shared_contracts.py +++ b/akta/shared_contracts.py @@ -1,76 +1,72 @@ -"""Resolve and load scientific-authorization-contracts (shared SCOPE/AKTA). +"""Resolve and load the installed scientific-authorization-contracts package. -Preference order: -1. Installed ``scientific_authorization_contracts`` distribution -2. Sibling SCOPE repo ``scientific-authorization-contracts`` -3. Vendored copy under ``vendor/scientific-authorization-contracts`` +Runtime imports the installed distribution only. Install from the vendor tree: + + pip install ./vendor/scientific-authorization-contracts + +before AKTA editable or wheel installs. Vendor/sibling ``sys.path`` injection +is intentionally not used. """ from __future__ import annotations -import os -import sys from functools import lru_cache -from pathlib import Path from typing import Any -_AKTA_ROOT = Path(__file__).resolve().parent.parent -_VENDOR_CONTRACTS = _AKTA_ROOT / "vendor" / "scientific-authorization-contracts" -_VENDOR_PYTHON = _VENDOR_CONTRACTS / "python" - ENVELOPE_VERSION = "scientific-authorization-envelope-v2" ENVELOPE_SCHEMA_NAME = "authorization_envelope.v2.schema.json" GRANT_V2_SCHEMA_NAME = "scope_grant.v2.schema.json" GRANT_STATUS_SCHEMA_NAME = "scope_grant_status.v1.schema.json" +CONTRACTS_PACKAGE_NAME = "scientific-authorization-contracts" +CONTRACTS_PACKAGE_VERSION = "2.1.0" -def _sibling_contracts_root() -> Path | None: - scope_repo = os.environ.get("SCOPE_REPO_PATH", "").strip() - candidates: list[Path] = [] - if scope_repo: - candidates.append(Path(scope_repo) / "scientific-authorization-contracts") - parent = _AKTA_ROOT.parent - candidates.append(parent / "SCOPE" / "scientific-authorization-contracts") - for path in candidates: - if (path / "schemas" / ENVELOPE_SCHEMA_NAME).is_file(): - return path.resolve() - return None - - -def contracts_root() -> Path: - """Prefer vendored contracts (with comparison API); fall back to sibling SCOPE.""" - if (_VENDOR_CONTRACTS / "schemas" / ENVELOPE_SCHEMA_NAME).is_file(): - # Prefer vendor when it has the comparison module (envelope.py). - vendor_envelope = ( - _VENDOR_PYTHON / "scientific_authorization_contracts" / "envelope.py" - ) - if vendor_envelope.is_file(): - return _VENDOR_CONTRACTS.resolve() - sibling = _sibling_contracts_root() - if sibling is not None: - return sibling - if (_VENDOR_CONTRACTS / "schemas" / ENVELOPE_SCHEMA_NAME).is_file(): - return _VENDOR_CONTRACTS.resolve() - raise FileNotFoundError( - "scientific-authorization-contracts not found (sibling SCOPE or vendor/)" +@lru_cache(maxsize=1) +def _contracts(): + try: + import scientific_authorization_contracts as contracts + except ImportError as exc: + raise ImportError( + "scientific-authorization-contracts is not installed. " + "Install from the vendor tree before using AKTA: " + "pip install ./vendor/scientific-authorization-contracts" + ) from exc + version = getattr(contracts, "__version__", None) or getattr( + contracts, "PACKAGE_VERSION", None ) + if version and str(version) != CONTRACTS_PACKAGE_VERSION: + raise ImportError( + f"scientific-authorization-contracts {version!r} loaded; " + f"AKTA requires {CONTRACTS_PACKAGE_VERSION!r}" + ) + return contracts -def _ensure_import_path() -> Path: - root = contracts_root() - python_path = root / "python" - path_str = str(python_path) - if path_str not in sys.path: - sys.path.insert(0, path_str) - return python_path - +def contracts_package_version() -> str: + return str(getattr(_contracts(), "__version__", CONTRACTS_PACKAGE_VERSION)) -@lru_cache(maxsize=1) -def _contracts(): - _ensure_import_path() - import scientific_authorization_contracts as contracts - return contracts +def release_artifact_hash() -> str: + c = _contracts() + if hasattr(c, "release_artifact_hash"): + return str(c.release_artifact_hash()) + # Older/sibling installs may omit the helper; fall back to package metadata hash. + version = str(getattr(c, "__version__", None) or getattr(c, "PACKAGE_VERSION", "") or "") + try: + from akta.hash import hash_object + + return hash_object( + { + "package": CONTRACTS_PACKAGE_NAME, + "package_version": version or CONTRACTS_PACKAGE_VERSION, + "envelope_schema_hash": str(c.authorization_envelope_schema_hash()), + "grant_schema_hash": str(c.grant_schema_hash()), + } + ) + except Exception as exc: # noqa: BLE001 + raise AttributeError( + "installed contracts package lacks release_artifact_hash()" + ) from exc def envelope_version() -> str: @@ -124,12 +120,15 @@ def operation_vocabulary() -> str: def shared_contract_report() -> dict[str, str]: """Acceptance report fields for AKTA ↔ SCOPE schema identity.""" c = _contracts() - return { + report = { + "package": CONTRACTS_PACKAGE_NAME, + "package_version": contracts_package_version(), "envelope_schema_version": str(c.ENVELOPE_VERSION), "envelope_schema_hash": str(c.authorization_envelope_schema_hash()), "grant_schema_version": str(c.grant_schema_version()), "grant_schema_hash": str(c.grant_schema_hash()), "family_vocabulary": ",".join(sorted(c.capability_families())), "operation_vocabulary": ",".join(sorted(c.family_operations().keys())), - "contracts_root": str(contracts_root()), + "release_artifact_hash": release_artifact_hash(), } + return report diff --git a/policy/shared_contracts_pin.json b/policy/shared_contracts_pin.json index 24644d3..bdaaaca 100644 --- a/policy/shared_contracts_pin.json +++ b/policy/shared_contracts_pin.json @@ -1,6 +1,7 @@ { "pin_version": "scientific-authorization-contracts-pin-v1", "package": "scientific-authorization-contracts", + "package_version": "2.1.0", "status": "beta", "envelope_schema_version": "scientific-authorization-envelope-v2", "envelope_schema_hash": "sha256:9881294eed119374c328f1d48bd7edb39d32b15450734716959c2a953b1ea918", @@ -11,6 +12,6 @@ "operation_vocabulary": "execution,experiment_plan,interpretation,memory,permissions,protocol,publication,queue,recommendation", "family_vocabulary_hash": "sha256:3a3547de8514d0d1488e1c5adceaf62a3034521a80c6ea650a4c91f5a0774b84", "operation_vocabulary_hash": "sha256:857c050866f6b20875fc280fddfe900f044d71305ba5e12fd9ef5845a9afc051", - "release_artifact_hash": "sha256:069d9c0ef2e6420a8484595657669fe8fd46a7b30e44d8824732631fbc2c354d", - "notes": "Bump this pin only with intentional shared-contract releases. CI asserts vendor, in-tree schemas/, and sibling SCOPE copies match." + "release_artifact_hash": "sha256:d375614f55f8d1a5a9bef5a25fd01591ee2f57ee857e7fa0ad72d7ade950c771", + "notes": "Bump this pin only with intentional shared-contract releases. Install via pip install ./vendor/scientific-authorization-contracts. CI asserts installed package hashes, vendor tree, and in-tree schemas/ copies match." } diff --git a/scripts/assert_shared_contract_drift.py b/scripts/assert_shared_contract_drift.py index e0fc391..41d6bb7 100644 --- a/scripts/assert_shared_contract_drift.py +++ b/scripts/assert_shared_contract_drift.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """Fail closed when scientific-authorization-contracts drift from the pinned release. -Compares: -1. Vendored package vs ``policy/shared_contracts_pin.json`` -2. In-tree ``schemas/`` copies vs vendor -3. Sibling SCOPE contracts (when present) vs vendor / pin +Compares the *installed* package (authoritative runtime) and the vendor tree +against ``policy/shared_contracts_pin.json``. Optionally checks sibling SCOPE +and in-tree ``schemas/`` copies. """ from __future__ import annotations import argparse import hashlib +import importlib.metadata import json import os import sys @@ -20,6 +20,8 @@ ROOT = Path(__file__).resolve().parent.parent VENDOR = ROOT / "vendor" / "scientific-authorization-contracts" PIN_PATH = ROOT / "policy" / "shared_contracts_pin.json" +EXPECTED_PACKAGE = "scientific-authorization-contracts" +EXPECTED_VERSION = "2.1.0" SCHEMA_FILES = ( "authorization_envelope.v2.schema.json", "scope_grant.v2.schema.json", @@ -36,24 +38,29 @@ def _sha256_file(path: Path) -> str: def release_artifact_hash(contracts_root: Path) -> str: - """Stable hash over schemas, vocabularies, conformance, and python helpers.""" + """Stable hash matching ``scientific_authorization_contracts.release_artifact_hash``.""" digest = hashlib.sha256() - for rel_dir in ("schemas", "vocabularies", "conformance", "python", "."): - base = contracts_root if rel_dir == "." else contracts_root / rel_dir - if not base.exists(): + + def _feed(logical: str, path: Path) -> None: + digest.update(logical.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + + for label in ("schemas", "vocabularies", "conformance"): + directory = contracts_root / label + if not directory.is_dir(): continue - if rel_dir == ".": - paths = [p for p in base.glob("*.md") if p.is_file()] - else: - paths = sorted(p for p in base.rglob("*") if p.is_file()) - for path in paths: - if "__pycache__" in path.parts or path.suffix not in {".json", ".md", ".py"}: + for path in sorted(p for p in directory.rglob("*") if p.is_file()): + if path.suffix != ".json" or "__pycache__" in path.parts: continue - rel = path.relative_to(contracts_root).as_posix() - digest.update(rel.encode("utf-8")) - digest.update(b"\0") - digest.update(path.read_bytes()) - digest.update(b"\0") + rel = path.relative_to(directory).as_posix() + _feed(f"{label}/{rel}", path) + + pkg = contracts_root / "python" / "scientific_authorization_contracts" + if pkg.is_dir(): + for path in sorted(p for p in pkg.glob("*.py") if p.is_file()): + _feed(f"python/scientific_authorization_contracts/{path.name}", path) return "sha256:" + digest.hexdigest() @@ -83,6 +90,17 @@ def _operation_vocab_csv(contracts_root: Path) -> str: return ",".join(sorted(data["families"].keys())) +def _installed_contracts() -> Any: + try: + import scientific_authorization_contracts as contracts + except ImportError as exc: + raise ImportError( + "scientific-authorization-contracts must be installed for drift checks: " + "pip install ./vendor/scientific-authorization-contracts" + ) from exc + return contracts + + def assert_no_drift(*, require_sibling: bool = False) -> dict[str, Any]: errors: list[str] = [] if not VENDOR.is_dir(): @@ -95,6 +113,59 @@ def assert_no_drift(*, require_sibling: bool = False) -> dict[str, Any]: "errors": errors, } + # --- Installed package (authoritative runtime) --- + try: + dist = importlib.metadata.version(EXPECTED_PACKAGE) + except importlib.metadata.PackageNotFoundError: + errors.append( + f"installed package {EXPECTED_PACKAGE!r} not found; " + f"pip install ./vendor/scientific-authorization-contracts" + ) + dist = None + else: + checks["installed_version"] = dist + if dist != EXPECTED_VERSION: + errors.append( + f"installed package version {dist!r} != expected {EXPECTED_VERSION!r}" + ) + pinned_version = pin.get("package_version") or pin.get("version") + if pinned_version and pinned_version != dist: + errors.append( + f"pin package_version {pinned_version!r} != installed {dist!r}" + ) + + contracts = None + try: + contracts = _installed_contracts() + except ImportError as exc: + errors.append(str(exc)) + + if contracts is not None: + installed_hashes = { + "envelope_schema_hash": str(contracts.authorization_envelope_schema_hash()), + "grant_schema_hash": str(contracts.grant_schema_hash()), + "grant_status_schema_hash": str( + getattr(contracts, "grant_status_schema_hash", lambda: "")() + ), + } + if hasattr(contracts, "release_artifact_hash"): + installed_hashes["release_artifact_hash"] = str(contracts.release_artifact_hash()) + checks["installed_hashes"] = installed_hashes + for key, value in installed_hashes.items(): + if not value: + continue + pinned = pin.get(key) + if pinned != value: + errors.append( + f"installed package mismatch {key}: pin={pinned!r} installed={value!r}" + ) + pkg_ver = getattr(contracts, "__version__", None) + if pkg_ver and str(pkg_ver) != EXPECTED_VERSION: + errors.append( + f"contracts.__version__ {pkg_ver!r} != {EXPECTED_VERSION!r}" + ) + + # --- Vendor tree vs pin --- envelope_hash = _sha256_file(VENDOR / "schemas" / SCHEMA_FILES[0]) grant_hash = _sha256_file(VENDOR / "schemas" / SCHEMA_FILES[1]) status_hash = _sha256_file(VENDOR / "schemas" / SCHEMA_FILES[2]) @@ -105,6 +176,8 @@ def assert_no_drift(*, require_sibling: bool = False) -> dict[str, Any]: ops_csv = _operation_vocab_csv(VENDOR) expected = { + "package": EXPECTED_PACKAGE, + "package_version": EXPECTED_VERSION, "envelope_schema_version": "scientific-authorization-envelope-v2", "envelope_schema_hash": envelope_hash, "grant_schema_version": "scope-grant-v2", @@ -151,7 +224,6 @@ def assert_no_drift(*, require_sibling: bool = False) -> dict[str, Any]: errors.append(f"sibling missing vocabularies/{name}") elif s_path.read_bytes() != v_bytes: errors.append(f"sibling vocabularies/{name} diverges from AKTA vendor") - # Identity fields vs pin (SCOPE may lag python helpers; schemas/vocabs are authoritative) sib_envelope = _sha256_file(sibling / "schemas" / SCHEMA_FILES[0]) if sib_envelope != pin["envelope_schema_hash"]: errors.append( diff --git a/tests/test_shared_contract_drift.py b/tests/test_shared_contract_drift.py index 8d88889..6af156b 100644 --- a/tests/test_shared_contract_drift.py +++ b/tests/test_shared_contract_drift.py @@ -21,12 +21,14 @@ def test_shared_contracts_pin_matches_vendor() -> None: def test_shared_contract_report_matches_pin() -> None: pin = json.loads(PIN.read_text(encoding="utf-8")) report = shared_contract_report() + assert report["package_version"] == pin["package_version"] assert report["envelope_schema_version"] == pin["envelope_schema_version"] assert report["envelope_schema_hash"] == pin["envelope_schema_hash"] assert report["grant_schema_version"] == pin["grant_schema_version"] assert report["grant_schema_hash"] == pin["grant_schema_hash"] assert report["family_vocabulary"] == pin["family_vocabulary"] assert report["operation_vocabulary"] == pin["operation_vocabulary"] + assert report["release_artifact_hash"] == pin["release_artifact_hash"] def test_release_artifact_hash_stable() -> None: From df897f1e6cd1f7e7aa58c69997074fa17bc578ce Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:48:52 -0700 Subject: [PATCH 04/26] feat(trust): harden institutional trust store and strict crypto Strengthen issuer key binding and fail-closed verification so forged or mis-bound grants cannot pass institutional checks. --- akta/trust_store.py | 491 ++++++++++++++---- docs/institutional_trust_store.md | 71 +-- .../fixtures/trust_keys/issuer_registry.yaml | 6 + tests/test_institutional_trust_store.py | 94 ++-- tests/test_strict_mode_crypto.py | 182 +++++++ 5 files changed, 677 insertions(+), 167 deletions(-) create mode 100644 tests/test_strict_mode_crypto.py diff --git a/akta/trust_store.py b/akta/trust_store.py index ea8c6f3..20239b5 100644 --- a/akta/trust_store.py +++ b/akta/trust_store.py @@ -1,8 +1,11 @@ -"""SCOPE issuer / trust-root key registry for grant authentication (P0-C). - -Loads approved issuer public keys from AKTA policy and optional SCOPE sibling -``reviewer_key_registry.yaml`` / issuer registry files. Production and other -strict modes fail closed when keys are unknown, revoked, or the store is empty. +"""SCOPE trust registries for grant, status, and manifest authentication (AKTA 1.1). + +Trust domains are separate: +- ``scope_grant_issuer_registry.yaml`` — grant signatures only +- ``scope_status_attestation_registry.yaml`` — grant-status attestations +- ``scope_policy_manifest_signer_registry.yaml`` — SCOPE authorization manifests +- ``reviewer_decision_registry.yaml`` — optional local reviewer decisions; + **never** auto-merged into grant-issuer keys """ from __future__ import annotations @@ -11,6 +14,7 @@ import json import os from datetime import datetime, timezone +from enum import Enum from pathlib import Path from typing import Any @@ -19,20 +23,54 @@ from akta.shared_contracts import _contracts _AKTA_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_REGISTRY_NAME = "scope_issuer_key_registry.yaml" + +GRANT_ISSUER_REGISTRY_NAME = "scope_grant_issuer_registry.yaml" +STATUS_ATTESTATION_REGISTRY_NAME = "scope_status_attestation_registry.yaml" +MANIFEST_SIGNER_REGISTRY_NAME = "scope_policy_manifest_signer_registry.yaml" +REVIEWER_DECISION_REGISTRY_NAME = "reviewer_decision_registry.yaml" +# Legacy filename retained for migration messaging only — not auto-loaded as issuer. +LEGACY_ISSUER_REGISTRY_NAME = "scope_issuer_key_registry.yaml" + SUPPORTED_ALGORITHMS = frozenset({"Ed25519", "ed25519"}) ACTIVE_STATUSES = frozenset({"active"}) +class ArtifactType(str, Enum): + SCOPE_GRANT = "scope_grant" + SCOPE_GRANT_STATUS = "scope_grant_status" + SCOPE_AUTHORIZATION_MANIFEST = "scope_authorization_manifest" + REVIEWER_DECISION = "reviewer_decision" + + +DEFAULT_ARTIFACT_TYPES: dict[str, frozenset[str]] = { + GRANT_ISSUER_REGISTRY_NAME: frozenset({ArtifactType.SCOPE_GRANT.value}), + STATUS_ATTESTATION_REGISTRY_NAME: frozenset({ArtifactType.SCOPE_GRANT_STATUS.value}), + MANIFEST_SIGNER_REGISTRY_NAME: frozenset( + {ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value} + ), + REVIEWER_DECISION_REGISTRY_NAME: frozenset({ArtifactType.REVIEWER_DECISION.value}), +} + + def _public_key_ref_from_pem(pem: str | bytes) -> str: text = pem.decode("ascii") if isinstance(pem, bytes) else pem return _contracts().compute_hash({"public_key_pem": text}) +def _require_cryptography(): + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + except ImportError as exc: # pragma: no cover - cryptography is a hard dep + raise ImportError( + "cryptography is required for SCOPE grant signature verification" + ) from exc + return serialization, Ed25519PublicKey + + def _load_ed25519_public_key(entry: dict[str, Any], *, base_dir: Path): """Return cryptography Ed25519PublicKey from registry entry.""" - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + serialization, Ed25519PublicKey = _require_cryptography() pem_text = entry.get("public_key_pem") if pem_text: @@ -103,7 +141,7 @@ def _parse_iso8601(value: Any) -> datetime | None: return dt.astimezone(timezone.utc) -def _entry_within_validity(entry: dict[str, Any], *, now: datetime | None = None) -> bool: +def entry_within_validity(entry: dict[str, Any], *, now: datetime | None = None) -> bool: """Return False when valid_from/valid_until exclude the current time.""" clock = now or datetime.now(timezone.utc) start = _parse_iso8601(entry.get("valid_from")) @@ -115,27 +153,91 @@ def _entry_within_validity(entry: dict[str, Any], *, now: datetime | None = None return True +def _entry_within_validity(entry: dict[str, Any], *, now: datetime | None = None) -> bool: + return entry_within_validity(entry, now=now) + + def _entry_algorithm_ok(entry: dict[str, Any]) -> bool: algo = entry.get("algorithm") if algo is None or algo == "": - # Legacy SCOPE reviewer entries omit algorithm; treat as Ed25519. return True return str(algo) in SUPPORTED_ALGORITHMS -def _entry_usable(entry: dict[str, Any], *, now: datetime | None = None) -> bool: +def _normalize_artifact_types( + entry: dict[str, Any], + *, + default_types: frozenset[str] | None = None, +) -> frozenset[str]: + raw = entry.get("authorized_artifact_types") or entry.get("artifact_types") + if raw is None: + return frozenset(default_types or ()) + if isinstance(raw, str): + return frozenset({raw.strip()}) if raw.strip() else frozenset() + return frozenset(str(x).strip() for x in raw if str(x).strip()) + + +def _entry_usable( + entry: dict[str, Any], + *, + now: datetime | None = None, + required_artifact_type: str | None = None, +) -> bool: if _entry_status(entry) not in ACTIVE_STATUSES: return False if not _entry_algorithm_ok(entry): return False - return _entry_within_validity(entry, now=now) + if not _entry_within_validity(entry, now=now): + return False + if required_artifact_type is not None: + allowed = entry.get("_authorized_artifact_types") + if allowed is None: + allowed = _normalize_artifact_types(entry) + if required_artifact_type not in allowed: + return False + return True -def load_issuer_registry(path: str | Path) -> dict[str, Any]: - """Load an AKTA or SCOPE-shaped issuer/reviewer key registry YAML/JSON.""" +def _normalize_registry_entry( + key_id: str, + entry: dict[str, Any], + *, + default_artifact_types: frozenset[str] | None = None, +) -> dict[str, Any]: + normalized = dict(entry) + normalized.setdefault("key_id", str(key_id)) + types = _normalize_artifact_types(normalized, default_types=default_artifact_types) + normalized["authorized_artifact_types"] = sorted(types) + normalized["_authorized_artifact_types"] = types + if "issuer_identity" not in normalized and normalized.get("identity"): + normalized["issuer_identity"] = normalized["identity"] + return normalized + + +def load_typed_registry( + path: str | Path, + *, + default_artifact_types: frozenset[str] | None = None, + section_keys: tuple[str, ...] = ("issuers", "keys", "signers", "attestors"), +) -> dict[str, Any]: + """Load a typed AKTA trust registry (YAML/JSON). + + Entry schema (authoritative fields): + key_id, public key material, authorized_artifact_types, issuer_identity, + tenant/institution, status, valid_from/until, replacement_key_id, + revocation_reason. + + Multi-role keys are allowed only via explicit ``authorized_artifact_types``. + """ registry_path = Path(path) if not registry_path.is_file(): - return {"version": None, "keys": {}, "revoked_key_ids": [], "source": str(registry_path)} + return { + "version": None, + "keys": {}, + "revoked_key_ids": [], + "source": str(registry_path), + "default_artifact_types": sorted(default_artifact_types or ()), + } raw = registry_path.read_text(encoding="utf-8") if registry_path.suffix.lower() == ".json": @@ -143,73 +245,83 @@ def load_issuer_registry(path: str | Path) -> dict[str, Any]: else: data = yaml.safe_load(raw) or {} + file_defaults = _normalize_artifact_types( + {"authorized_artifact_types": data.get("default_authorized_artifact_types")}, + default_types=default_artifact_types, + ) + if not file_defaults and default_artifact_types: + file_defaults = frozenset(default_artifact_types) + keys: dict[str, dict[str, Any]] = {} revoked: set[str] = set(str(x) for x in (data.get("revoked_key_ids") or [])) - # AKTA issuer registry shape - issuers = data.get("issuers") or {} - for key_id, entry in issuers.items(): - if not isinstance(entry, dict): + for section in section_keys: + section_data = data.get(section) or {} + if not isinstance(section_data, dict): continue - normalized = dict(entry) - normalized.setdefault("key_id", str(key_id)) - if _entry_status(normalized) == "revoked": - revoked.add(str(key_id)) - if normalized.get("public_key_ref"): - revoked.add(str(normalized["public_key_ref"])) - keys[str(key_id)] = normalized - ref = normalized.get("public_key_ref") - if ref: - keys[str(ref)] = normalized - - # SCOPE reviewer_key_registry.yaml shape - reviewers = data.get("reviewers") or {} - for reviewer_id, entry in reviewers.items(): - if isinstance(entry, str): - entry = {"public_key_ref": entry} - if not isinstance(entry, dict): - continue - normalized = dict(entry) - normalized.setdefault("key_id", str(reviewer_id)) - normalized.setdefault("issuer_identity", str(reviewer_id)) - if _entry_status(normalized) == "revoked": - revoked.add(str(reviewer_id)) - if normalized.get("public_key_ref"): - revoked.add(str(normalized["public_key_ref"])) - keys[str(reviewer_id)] = normalized - ref = normalized.get("public_key_ref") - if ref: - keys[str(ref)] = normalized + for key_id, entry in section_data.items(): + if isinstance(entry, str): + entry = {"public_key_ref": entry} + if not isinstance(entry, dict): + continue + normalized = _normalize_registry_entry( + str(key_id), + entry, + default_artifact_types=file_defaults, + ) + if _entry_status(normalized) == "revoked": + revoked.add(str(key_id)) + if normalized.get("public_key_ref"): + revoked.add(str(normalized["public_key_ref"])) + keys[str(key_id)] = normalized + ref = normalized.get("public_key_ref") + if ref: + keys[str(ref)] = normalized return { "version": data.get("version"), "keys": keys, "revoked_key_ids": sorted(revoked), "source": str(registry_path.resolve()), + "default_artifact_types": sorted(file_defaults), "raw": data, } -def _default_registry_candidates(policy_dir: Path | None = None) -> list[Path]: - candidates: list[Path] = [] - env = os.environ.get("AKTA_TRUST_STORE_PATH", "").strip() - if env: - candidates.append(Path(env)) - env_reg = os.environ.get("AKTA_ISSUER_KEY_REGISTRY", "").strip() - if env_reg: - candidates.append(Path(env_reg)) - base = Path(policy_dir) if policy_dir else _AKTA_ROOT / "policy" - candidates.append(base / DEFAULT_REGISTRY_NAME) - - scope_repo = os.environ.get("SCOPE_REPO_PATH", "").strip() - scope_roots: list[Path] = [] - if scope_repo: - scope_roots.append(Path(scope_repo)) - scope_roots.append(_AKTA_ROOT.parent / "SCOPE") - for root in scope_roots: - candidates.append(root / "policy" / "reviewer_key_registry.yaml") - candidates.append(root / "policy" / "issuer_key_registry.yaml") - return candidates +def load_issuer_registry(path: str | Path) -> dict[str, Any]: + """Load a grant-issuer registry (backward-compatible name).""" + return load_typed_registry( + path, + default_artifact_types=frozenset({ArtifactType.SCOPE_GRANT.value}), + section_keys=("issuers", "keys", "signers"), + ) + + +def load_status_attestation_registry(path: str | Path) -> dict[str, Any]: + return load_typed_registry( + path, + default_artifact_types=frozenset({ArtifactType.SCOPE_GRANT_STATUS.value}), + section_keys=("attestors", "issuers", "keys", "signers"), + ) + + +def load_manifest_signer_registry(path: str | Path) -> dict[str, Any]: + return load_typed_registry( + path, + default_artifact_types=frozenset( + {ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value} + ), + section_keys=("signers", "issuers", "keys"), + ) + + +def load_reviewer_decision_registry(path: str | Path) -> dict[str, Any]: + """Optional reviewer registry — never auto-used as grant-issuer trust.""" + return load_typed_registry( + path, + default_artifact_types=frozenset({ArtifactType.REVIEWER_DECISION.value}), + section_keys=("reviewers", "keys", "issuers"), + ) def merge_registries(parts: list[dict[str, Any]]) -> dict[str, Any]: @@ -232,22 +344,38 @@ def build_trust_store( registry: dict[str, Any], *, base_dir: Path | None = None, + required_artifact_type: str | None = None, ) -> dict[str, Any]: """Build a verifier trust_store dict consumed by ``scope_grant_verifier``.""" base = base_dir or _AKTA_ROOT / "policy" keys = dict(registry.get("keys") or {}) revoked = set(str(x) for x in (registry.get("revoked_key_ids") or [])) loaded_keys: dict[str, dict[str, Any]] = {} + artifact_type = required_artifact_type or ( + (registry.get("default_artifact_types") or [None])[0] + if registry.get("default_artifact_types") + else None + ) for key_id, entry in keys.items(): enriched = dict(entry) + if "_authorized_artifact_types" not in enriched: + enriched["_authorized_artifact_types"] = _normalize_artifact_types( + enriched, + default_types=frozenset(registry.get("default_artifact_types") or ()), + ) + enriched["authorized_artifact_types"] = sorted( + enriched["_authorized_artifact_types"] + ) try: public_key = _load_ed25519_public_key(enriched, base_dir=base) pem = None if enriched.get("public_key_pem"): pem = str(enriched["public_key_pem"]) elif enriched.get("public_key_file") or enriched.get("public_key_path"): - path_value = enriched.get("public_key_file") or enriched.get("public_key_path") + path_value = enriched.get("public_key_file") or enriched.get( + "public_key_path" + ) path = Path(str(path_value)) if not path.is_absolute(): path = (base / path).resolve() @@ -260,16 +388,15 @@ def build_trust_store( if ref: loaded_keys[str(ref)] = enriched except Exception: - # Keep structural entry for unknown-key messaging; crypto verify will fail. loaded_keys[str(key_id)] = enriched def _verify(payload: dict[str, Any], hash_field: str, signature: str) -> bool: issuer = payload.get("issuer") or {} key_id = ( issuer.get("issuer_key_id") - or payload.get("reviewer_public_key_ref") or payload.get("attestation_key_id") or payload.get("issuer_key_id") + or payload.get("manifest_signature_key_id") ) if not key_id: return False @@ -278,12 +405,11 @@ def _verify(payload: dict[str, Any], hash_field: str, signature: str) -> bool: entry = loaded_keys.get(str(key_id)) if entry is None: return False - if not _entry_usable(entry): + if not _entry_usable(entry, required_artifact_type=artifact_type): return False public_key = entry.get("_public_key") if public_key is None: return False - # SCOPE parity: signed_payload_hash must equal the hashed field when present. signed_payload = payload.get("signed_payload_hash") if signed_payload is not None and signed_payload != payload.get(hash_field): return False @@ -297,44 +423,190 @@ def _verify(payload: dict[str, Any], hash_field: str, signature: str) -> bool: return { "keys": loaded_keys, "revoked_key_ids": sorted(revoked), - "sources": list(registry.get("sources") or ([registry.get("source")] if registry.get("source") else [])), + "sources": list( + registry.get("sources") + or ([registry.get("source")] if registry.get("source") else []) + ), + "required_artifact_type": artifact_type, "verify": _verify, } -def resolve_trust_store( +def _policy_dir(policy_dir: Path | None) -> Path: + return Path(policy_dir) if policy_dir else _AKTA_ROOT / "policy" + + +def _env_path(*names: str) -> Path | None: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return Path(value) + return None + + +def resolve_grant_issuer_trust_store( *, policy_dir: str | Path | None = None, explicit: dict[str, Any] | None = None, require_keys: bool = False, ) -> dict[str, Any] | None: - """Resolve production trust store from explicit override or on-disk registries.""" + """Resolve grant-issuer trust store (signatures on scope grants).""" if explicit is not None: if "verify" not in explicit and explicit.get("keys") is not None: - return build_trust_store(explicit, base_dir=Path(policy_dir) if policy_dir else None) + return build_trust_store( + explicit, + base_dir=_policy_dir(Path(policy_dir) if policy_dir else None), + required_artifact_type=ArtifactType.SCOPE_GRANT.value, + ) return explicit - built_parts: list[dict[str, Any]] = [] + base = _policy_dir(Path(policy_dir) if policy_dir else None) + candidates: list[Path] = [] + env = _env_path("AKTA_GRANT_ISSUER_REGISTRY", "AKTA_TRUST_STORE_PATH", "AKTA_ISSUER_KEY_REGISTRY") + if env is not None: + candidates.append(env) + candidates.append(base / GRANT_ISSUER_REGISTRY_NAME) + + parts: list[dict[str, Any]] = [] seen: set[str] = set() - for candidate in _default_registry_candidates(Path(policy_dir) if policy_dir else None): + for candidate in candidates: resolved = str(candidate.resolve()) if candidate.exists() else str(candidate) if resolved in seen: continue seen.add(resolved) if candidate.is_file(): part = load_issuer_registry(candidate) - built_parts.append(build_trust_store(part, base_dir=candidate.parent)) + parts.append( + build_trust_store( + part, + base_dir=candidate.parent, + required_artifact_type=ArtifactType.SCOPE_GRANT.value, + ) + ) + + if not parts: + if require_keys: + return build_trust_store( + {"keys": {}, "revoked_key_ids": []}, + required_artifact_type=ArtifactType.SCOPE_GRANT.value, + ) + return None + return _merge_built_stores(parts, required_artifact_type=ArtifactType.SCOPE_GRANT.value) + - if not built_parts: +def resolve_status_attestation_trust_store( + *, + policy_dir: str | Path | None = None, + explicit: dict[str, Any] | None = None, + require_keys: bool = False, +) -> dict[str, Any] | None: + if explicit is not None: + if "verify" not in explicit and explicit.get("keys") is not None: + return build_trust_store( + explicit, + base_dir=_policy_dir(Path(policy_dir) if policy_dir else None), + required_artifact_type=ArtifactType.SCOPE_GRANT_STATUS.value, + ) + return explicit + + base = _policy_dir(Path(policy_dir) if policy_dir else None) + candidates: list[Path] = [] + env = _env_path("AKTA_STATUS_ATTESTATION_REGISTRY") + if env is not None: + candidates.append(env) + candidates.append(base / STATUS_ATTESTATION_REGISTRY_NAME) + + parts: list[dict[str, Any]] = [] + seen: set[str] = set() + for candidate in candidates: + resolved = str(candidate.resolve()) if candidate.exists() else str(candidate) + if resolved in seen: + continue + seen.add(resolved) + if candidate.is_file(): + part = load_status_attestation_registry(candidate) + parts.append( + build_trust_store( + part, + base_dir=candidate.parent, + required_artifact_type=ArtifactType.SCOPE_GRANT_STATUS.value, + ) + ) + + if not parts: + if require_keys: + return build_trust_store( + {"keys": {}, "revoked_key_ids": []}, + required_artifact_type=ArtifactType.SCOPE_GRANT_STATUS.value, + ) + return None + return _merge_built_stores( + parts, required_artifact_type=ArtifactType.SCOPE_GRANT_STATUS.value + ) + + +def resolve_manifest_signer_trust_store( + *, + policy_dir: str | Path | None = None, + explicit: dict[str, Any] | None = None, + require_keys: bool = False, +) -> dict[str, Any] | None: + """Resolve manifest-signer registry (wired in Phase B WS5).""" + if explicit is not None: + if "verify" not in explicit and explicit.get("keys") is not None: + return build_trust_store( + explicit, + base_dir=_policy_dir(Path(policy_dir) if policy_dir else None), + required_artifact_type=ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value, + ) + return explicit + + base = _policy_dir(Path(policy_dir) if policy_dir else None) + candidates: list[Path] = [] + env = _env_path("AKTA_MANIFEST_SIGNER_REGISTRY") + if env is not None: + candidates.append(env) + candidates.append(base / MANIFEST_SIGNER_REGISTRY_NAME) + + parts: list[dict[str, Any]] = [] + seen: set[str] = set() + for candidate in candidates: + resolved = str(candidate.resolve()) if candidate.exists() else str(candidate) + if resolved in seen: + continue + seen.add(resolved) + if candidate.is_file(): + part = load_manifest_signer_registry(candidate) + parts.append( + build_trust_store( + part, + base_dir=candidate.parent, + required_artifact_type=ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value, + ) + ) + + if not parts: if require_keys: - return build_trust_store({"keys": {}, "revoked_key_ids": []}) + return build_trust_store( + {"keys": {}, "revoked_key_ids": []}, + required_artifact_type=ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value, + ) return None + return _merge_built_stores( + parts, + required_artifact_type=ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value, + ) + - # Merge already-built stores so relative public_key_file paths resolve per registry. +def _merge_built_stores( + parts: list[dict[str, Any]], + *, + required_artifact_type: str | None, +) -> dict[str, Any]: keys: dict[str, dict[str, Any]] = {} revoked: set[str] = set() sources: list[str] = [] - for part in built_parts: + for part in parts: sources.extend(str(s) for s in (part.get("sources") or []) if s) revoked.update(str(x) for x in (part.get("revoked_key_ids") or [])) for key_id, entry in (part.get("keys") or {}).items(): @@ -344,14 +616,16 @@ def _verify(payload: dict[str, Any], hash_field: str, signature: str) -> bool: issuer = payload.get("issuer") or {} key_id = ( issuer.get("issuer_key_id") - or payload.get("reviewer_public_key_ref") or payload.get("attestation_key_id") or payload.get("issuer_key_id") + or payload.get("manifest_signature_key_id") ) if not key_id or str(key_id) in revoked: return False entry = keys.get(str(key_id)) - if entry is None or not _entry_usable(entry): + if entry is None or not _entry_usable( + entry, required_artifact_type=required_artifact_type + ): return False public_key = entry.get("_public_key") if public_key is None: @@ -366,15 +640,31 @@ def _verify(payload: dict[str, Any], hash_field: str, signature: str) -> bool: signature=signature, ) - store = { + return { "keys": keys, "revoked_key_ids": sorted(revoked), "sources": sources, + "required_artifact_type": required_artifact_type, "verify": _verify, } - if require_keys and institutional_registry_is_empty(store): - return store - return store + + +def resolve_trust_store( + *, + policy_dir: str | Path | None = None, + explicit: dict[str, Any] | None = None, + require_keys: bool = False, +) -> dict[str, Any] | None: + """Resolve grant-issuer trust store (primary alias for grant authentication). + + Does **not** auto-import SCOPE ``reviewer_key_registry.yaml`` or the local + ``reviewer_decision_registry.yaml`` into grant-issuer keys. + """ + return resolve_grant_issuer_trust_store( + policy_dir=policy_dir, + explicit=explicit, + require_keys=require_keys, + ) def sign_payload_hash_ed25519(private_key_pem: bytes, digest_field_value: str) -> str: @@ -425,21 +715,40 @@ def institutional_registry_is_empty(store: dict[str, Any] | None) -> bool: return True keys = store.get("keys") or {} revoked = set(str(x) for x in (store.get("revoked_key_ids") or [])) + required = store.get("required_artifact_type") for key_id, entry in keys.items(): if str(key_id) in revoked: continue - if isinstance(entry, dict) and _entry_usable(entry) and entry.get("_public_key") is not None: + if ( + isinstance(entry, dict) + and _entry_usable(entry, required_artifact_type=required) + and entry.get("_public_key") is not None + ): return False return True __all__ = [ + "ArtifactType", + "GRANT_ISSUER_REGISTRY_NAME", + "LEGACY_ISSUER_REGISTRY_NAME", + "MANIFEST_SIGNER_REGISTRY_NAME", + "REVIEWER_DECISION_REGISTRY_NAME", + "STATUS_ATTESTATION_REGISTRY_NAME", "SUPPORTED_ALGORITHMS", "build_trust_store", + "entry_within_validity", "generate_test_keypair", "institutional_registry_is_empty", "load_issuer_registry", + "load_manifest_signer_registry", + "load_reviewer_decision_registry", + "load_status_attestation_registry", + "load_typed_registry", "merge_registries", + "resolve_grant_issuer_trust_store", + "resolve_manifest_signer_trust_store", + "resolve_status_attestation_trust_store", "resolve_trust_store", "sign_payload_hash_ed25519", ] diff --git a/docs/institutional_trust_store.md b/docs/institutional_trust_store.md index dc673fe..c7fd799 100644 --- a/docs/institutional_trust_store.md +++ b/docs/institutional_trust_store.md @@ -1,51 +1,60 @@ -# Institutional trust store (issuer key registry) +# Institutional trust store (AKTA 1.1 trust domains) -AKTA authenticates SCOPE grants against an **issuer public-key registry**. -Production and other institutional profiles **fail closed** when the registry -is empty, missing, or contains only revoked/expired keys. +AKTA authenticates SCOPE artifacts against **separate** public-key registries: -Beta designation: this is a reference trust-binding mechanism, not a claim that -AKTA is production-ready for SCOPE-authorized action. +| Registry | Artifact | Env override | +|----------|----------|--------------| +| `policy/scope_grant_issuer_registry.yaml` | grant signatures | `AKTA_GRANT_ISSUER_REGISTRY` / `AKTA_TRUST_STORE_PATH` | +| `policy/scope_status_attestation_registry.yaml` | grant-status attestations | `AKTA_STATUS_ATTESTATION_REGISTRY` | +| `policy/scope_policy_manifest_signer_registry.yaml` | authorization manifests (WS5) | `AKTA_MANIFEST_SIGNER_REGISTRY` | +| `policy/reviewer_decision_registry.yaml` | local reviewer decisions only | (explicit load; never grant-issuer) | -## Registry schema +Strict modes (`pilot` \| `release` \| `cross_repo` \| `production`) **require real +cryptography** and fail closed when the grant-issuer registry is empty, missing, +or contains only revoked/expired keys. -Default file: `policy/scope_issuer_key_registry.yaml` (ships empty). +Beta designation: reference trust-binding mechanism, not an institutional +certification claim. -Each issuer entry supports: +## Entry schema + +Each registry entry supports: | Field | Required | Notes | |-------|----------|-------| -| `key_id` | yes | Stable issuer key identifier (also used as map key) | +| `key_id` | yes | Stable key identifier (also used as map key) | | `status` | yes | `active` \| `revoked` \| `expired` \| `pending` | | `algorithm` | yes | `Ed25519` only in v1 | | public material | one of | `public_key_ref`, `public_key_pem`, or `public_key_file` | +| `authorized_artifact_types` | recommended | Multi-role only via explicit list | | `valid_from` / `valid_until` | recommended | ISO-8601; outside window → reject | | `issuer_identity` | optional | Human/org label; not a secret | -| `notes` | optional | Operational commentary | +| `tenant` / `institution` | optional | Tenancy binding | +| `replacement_key_id` | optional | Rotation pointer | +| `revocation_reason` | optional | When status is revoked | -SCOPE sibling `policy/reviewer_key_registry.yaml` entries (`reviewers:`) are -also accepted when discovered via `SCOPE_REPO_PATH`. Private key fields are -never loaded into the trust store. +**Reviewer keys are never auto-imported as grant issuers.** SCOPE sibling +`reviewer_key_registry.yaml` is not merged into the grant-issuer store. ## How institutions deploy 1. Generate or obtain an Ed25519 **issuer** keypair offline (HSM/KMS preferred). -2. Export **public** PEM only into an institutional registry file. Never commit - private keys to the AKTA tree. -3. Point AKTA at that file: +2. Export **public** PEM only into `scope_grant_issuer_registry.yaml` (and + separately into status/manifest registries as appropriate). +3. Point AKTA at those files: ```bash -export AKTA_TRUST_STORE_PATH=/etc/akta/issuer_key_registry.yaml -# optional alias: -export AKTA_ISSUER_KEY_REGISTRY=/etc/akta/issuer_key_registry.yaml +export AKTA_GRANT_ISSUER_REGISTRY=/etc/akta/scope_grant_issuer_registry.yaml +export AKTA_STATUS_ATTESTATION_REGISTRY=/etc/akta/scope_status_attestation_registry.yaml +# optional alias still accepted: +export AKTA_TRUST_STORE_PATH=/etc/akta/scope_grant_issuer_registry.yaml ``` -4. Enable crypto-required grant verification: +4. Enable a strict grant mode (crypto is automatic for all strict modes): ```bash export AKTA_GRANT_MODE=production -# or: -export AKTA_REQUIRE_GRANT_CRYPTO=1 +# or: pilot | release | cross_repo ``` 5. Optionally configure live grant-status attestation: @@ -54,25 +63,21 @@ export AKTA_REQUIRE_GRANT_CRYPTO=1 export SCOPE_GRANT_STATUS_URL=https://scope.example/v1/grants/{grant_id}/status ``` -When `SCOPE_GRANT_STATUS_URL` is set in strict modes, AKTA fetches the signed -attestation and verifies `attestation_signature` against the trust store. - ## Dev / unit tests -Use the clearly marked fixtures under `tests/fixtures/trust_keys/`: +Use fixtures under `tests/fixtures/trust_keys/`: ```bash export AKTA_TRUST_STORE_PATH=tests/fixtures/trust_keys/issuer_registry.yaml ``` -Those keys are **test-only**. CI unit profiles may load them; institutional -release profiles must not treat them as production trust roots. +Those keys are **test-only**. ## Fail-closed rules -| Profile | Empty registry | Unknown key_id | Revoked / expired / outside validity | -|---------|----------------|----------------|--------------------------------------| -| `unit` / local | may warn | reject signature | reject | -| `pilot` / `release` / `cross_repo` / `production` | hard fail when crypto required | hard fail | hard fail | +| Profile | Empty grant-issuer registry | Unknown key_id | Revoked / expired / outside validity | +|---------|-----------------------------|----------------|--------------------------------------| +| `unit` / local | may warn | reject when store provided | reject | +| `pilot` / `release` / `cross_repo` / `production` | hard fail | hard fail | hard fail | See `akta/trust_store.py` and `akta/scope_grant_verifier.py`. diff --git a/tests/fixtures/trust_keys/issuer_registry.yaml b/tests/fixtures/trust_keys/issuer_registry.yaml index fc538cd..56dd494 100644 --- a/tests/fixtures/trust_keys/issuer_registry.yaml +++ b/tests/fixtures/trust_keys/issuer_registry.yaml @@ -3,6 +3,9 @@ description: | DEV/TEST ONLY issuer registry. Never use these keys for institutional production profiles. Public material only in this file; private key is sibling issuer_ed25519.pem for local signing in tests. +default_authorized_artifact_types: + - scope_grant + - scope_grant_status issuers: akta-test-issuer-ed25519: key_id: akta-test-issuer-ed25519 @@ -13,5 +16,8 @@ issuers: valid_until: "2099-01-01T00:00:00Z" public_key_file: issuer_ed25519.pub.pem public_key_ref: sha256:63cce5d566aa83e745ceb507a6f2f38500d9db6e5b2bc60b5168e05ec112a76a + authorized_artifact_types: + - scope_grant + - scope_grant_status notes: Generated for AKTA unit/integration tests only. revoked_key_ids: [] diff --git a/tests/test_institutional_trust_store.py b/tests/test_institutional_trust_store.py index 3d9a1aa..b4635f2 100644 --- a/tests/test_institutional_trust_store.py +++ b/tests/test_institutional_trust_store.py @@ -30,7 +30,7 @@ ROOT = Path(__file__).resolve().parent.parent FIXTURE_KEYS = ROOT / "tests" / "fixtures" / "trust_keys" FIXTURE_REGISTRY = FIXTURE_KEYS / "issuer_registry.yaml" -POLICY_REGISTRY = ROOT / "policy" / "scope_issuer_key_registry.yaml" +POLICY_REGISTRY = ROOT / "policy" / "scope_grant_issuer_registry.yaml" def _envelope() -> dict[str, Any]: @@ -83,7 +83,11 @@ def _sign_grant(grant: dict[str, Any], private_pem: bytes, key_id: str) -> dict[ @pytest.fixture def fixture_trust_store() -> dict[str, Any]: registry = load_issuer_registry(FIXTURE_REGISTRY) - return build_trust_store(registry, base_dir=FIXTURE_KEYS) + return build_trust_store( + registry, + base_dir=FIXTURE_KEYS, + required_artifact_type="scope_grant", + ) def test_policy_registry_ships_empty_for_fail_closed() -> None: @@ -158,24 +162,32 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 def test_grant_status_attestation_accept_and_reject( - fixture_trust_store: dict[str, Any], monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: """High-fidelity mock of authenticated SCOPE /grant-status attestation.""" - priv = (FIXTURE_KEYS / "issuer_ed25519.pem").read_bytes() - key_id = "akta-test-issuer-ed25519" - grant = _sign_grant( - { - **_base_grant(key_id=key_id), + from datetime import datetime, timezone + + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + kit = make_institutional_kit(tmp_path) + grant, attestation, arts = make_signed_grant( + kit, + overrides={ "constraints": { "single_use": True, "policy_manifest_hash": "sha256:" + ("b" * 64), "protocol_version": "proto-1", - }, + "tenant_id": "tenant-a", + } }, - priv, - key_id, ) + # Ensure attestation is fresh for max-age checks. + attestation["checked_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + from tests.helpers.institutional_grant import sign_status + + attestation = sign_status(attestation, kit["status_keys"]) + bound_context = { "metadata": { "policy_manifest_hash": "sha256:" + ("b" * 64), @@ -191,44 +203,34 @@ def test_grant_status_attestation_accept_and_reject( } } - attestation: dict[str, Any] = { - "grant_id": grant["grant_id"], - "status": "active", - "checked_at": "2026-01-02T00:00:00Z", - "cardinality_remaining": 1, - "uses_consumed": 0, - "grant_hash": grant["grant_hash"], - "scope_trust_root_hash": grant["provenance"]["scope_trust_root_hash"], - "attestation_key_id": key_id, - "authorization_schema_version": ENVELOPE_VERSION, - "authorization_schema_hash": authorization_envelope_schema_hash(), - } - attestation = _contracts().attach_hash(attestation, "attestation_hash") - attestation["signed_payload_hash"] = attestation["attestation_hash"] - attestation["attestation_signature"] = sign_payload_hash_ed25519( - priv, attestation["attestation_hash"] - ) - server, base_url = _start_attestation_server(attestation) try: monkeypatch.setenv("SCOPE_GRANT_STATUS_URL", base_url + "/{grant_id}") ok_remote = verify_scope_grant( grant, mode="pilot", - trust_store=fixture_trust_store, + trust_store=kit["issuer_store"], ledger_status=None, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context=bound_context, - record={"record_id": "rec-trust-1"}, + record=arts["record"], ) assert ok_remote.ok, ok_remote.errors ok2 = verify_scope_grant( grant, mode="pilot", - trust_store=fixture_trust_store, + trust_store=kit["issuer_store"], ledger_status=attestation, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context=bound_context, - record={"record_id": "rec-trust-1"}, + record=arts["record"], ) assert ok2.ok, ok2.errors @@ -237,28 +239,34 @@ def test_grant_status_attestation_accept_and_reject( bad = verify_scope_grant( grant, mode="pilot", - trust_store=fixture_trust_store, + trust_store=kit["issuer_store"], ledger_status=forged, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context=bound_context, - record={"record_id": "rec-trust-1"}, + record=arts["record"], ) assert not bad.ok - assert any("attestation" in e for e in bad.errors) + assert any("attestation" in e or "signature" in e for e in bad.errors) revoked = dict(attestation) revoked["status"] = "revoked" - revoked = _contracts().attach_hash(revoked, "attestation_hash") - revoked["signed_payload_hash"] = revoked["attestation_hash"] - revoked["attestation_signature"] = sign_payload_hash_ed25519( - priv, revoked["attestation_hash"] - ) + from tests.helpers.institutional_grant import sign_status + + revoked = sign_status(revoked, kit["status_keys"]) bad2 = verify_scope_grant( grant, mode="pilot", - trust_store=fixture_trust_store, + trust_store=kit["issuer_store"], ledger_status=revoked, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context=bound_context, - record={"record_id": "rec-trust-1"}, + record=arts["record"], ) assert not bad2.ok assert any("revoked" in e or "status" in e for e in bad2.errors) diff --git a/tests/test_strict_mode_crypto.py b/tests/test_strict_mode_crypto.py new file mode 100644 index 0000000..bebc7b2 --- /dev/null +++ b/tests/test_strict_mode_crypto.py @@ -0,0 +1,182 @@ +"""AKTA 1.1 WS2: crypto required in every strict grant mode.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from akta.capability_envelope import legacy_scope_to_envelope +from akta.errors import GrantVerificationError +from akta.scope_authorization_manifest import clear_manifest_cache +from akta.scope_grant_verifier import STRICT_MODES, verify_scope_grant, verify_scope_grant_or_raise +from akta.shared_contracts import ( + ENVELOPE_VERSION, + _contracts, + authorization_envelope_schema_hash, +) +from akta.trust_store import ( + build_trust_store, + generate_test_keypair, + sign_payload_hash_ed25519, +) +from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + clear_manifest_cache() + + +def _envelope() -> dict: + return legacy_scope_to_envelope( + approved_scope="protocol_draft", + allowed_tools=["protocol_editor.draft_change"], + blocked_tools=["robot_queue.submit"], + ) + + +def _base_grant(**overrides: object) -> dict: + envelope = _envelope() + grant = { + "grant_id": "SCOPE-GRANT-STRICT-CRYPTO-1", + "grant_version": "2.0.0", + "created_at": "2026-01-01T00:00:00Z", + "authorization": { + "approved_scope": "protocol_draft", + "allowed_tools": envelope["allowed_tools"], + "blocked_tools": envelope["blocked_tools"], + "authorization_envelope": envelope, + }, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), + "issuer": {"issuer_identity": "scope-test", "issuer_key_id": "pending"}, + "source": { + "packet_id": "pkt-1", + "decision_id": "dec-1", + "akta_record_id": "rec-1", + }, + "provenance": {"scope_trust_root_hash": "sha256:" + ("d" * 64)}, + "constraints": {"single_use": True, "policy_manifest_hash": "sha256:" + ("e" * 64)}, + "expiration": { + "absolute_expiration": "2030-01-01T00:00:00Z", + "expires_after": [], + }, + } + grant.update(overrides) + return grant + + +def _sign(grant: dict, issuer_keys: dict[str, str]) -> dict: + grant = dict(grant) + grant["issuer"] = { + "issuer_identity": "scope-test", + "issuer_key_id": issuer_keys["public_key_ref"], + } + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + priv = Path(issuer_keys["private_key_path"]).read_bytes() + grant["grant_signature"] = sign_payload_hash_ed25519(priv, grant["grant_hash"]) + grant["signature_algorithm"] = "Ed25519" + return grant + + +@pytest.fixture +def issuer_keys(tmp_path: Path) -> dict[str, str]: + return generate_test_keypair(tmp_path / "keys") + + +@pytest.fixture +def trust_store(issuer_keys: dict[str, str], tmp_path: Path) -> dict: + registry = { + "keys": { + issuer_keys["public_key_ref"]: { + "public_key_pem": issuer_keys["public_key_pem"], + "public_key_ref": issuer_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant"], + } + }, + "revoked_key_ids": [], + "source": str(tmp_path), + "default_artifact_types": ["scope_grant"], + } + return build_trust_store( + registry, base_dir=tmp_path, required_artifact_type="scope_grant" + ) + + +def test_pilot_rejects_structural_fake_signature() -> None: + """Structural signature presence alone must never pass in pilot mode.""" + grant = _contracts().attach_hash(_base_grant(), "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + grant["grant_signature"] = "not-a-real-ed25519-signature" + grant["signature_algorithm"] = "Ed25519" + grant["issuer"] = { + "issuer_identity": "fake", + "issuer_key_id": "sha256:" + ("f" * 64), + } + result = verify_scope_grant(grant, mode="pilot") + assert not result.ok + assert any( + "trust store" in e or "signature" in e or "unknown issuer" in e + for e in result.errors + ) + + +def test_release_rejects_missing_trust_store(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AKTA_TRUST_STORE_PATH", raising=False) + monkeypatch.delenv("AKTA_ISSUER_KEY_REGISTRY", raising=False) + monkeypatch.delenv("AKTA_GRANT_ISSUER_REGISTRY", raising=False) + grant = _contracts().attach_hash(_base_grant(), "grant_hash") + grant["grant_signature"] = "structural-only" + grant["signed_payload_hash"] = grant["grant_hash"] + grant["issuer"] = {"issuer_identity": "x", "issuer_key_id": "k1"} + result = verify_scope_grant(grant, mode="release") + assert not result.ok + assert any("trust store" in e for e in result.errors) + + +def test_cross_repo_rejects_unknown_issuer( + issuer_keys: dict[str, str], trust_store: dict +) -> None: + grant = _sign(_base_grant(), issuer_keys) + grant["issuer"]["issuer_key_id"] = "sha256:" + ("a" * 64) + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + with pytest.raises(GrantVerificationError, match="unknown issuer"): + verify_scope_grant_or_raise(grant, mode="cross_repo", trust_store=trust_store) + + +def test_all_strict_modes_verify_final_grant_signature(tmp_path: Path) -> None: + assert STRICT_MODES == frozenset({"pilot", "release", "cross_repo", "production"}) + kit = make_institutional_kit(tmp_path) + good, status, arts = make_signed_grant(kit) + for mode in sorted(STRICT_MODES): + ok = verify_scope_grant( + good, + mode=mode, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert ok.ok, (mode, ok.errors) + + forged = dict(good) + forged["grant_signature"] = "AAAA" + bad = verify_scope_grant( + forged, + mode=mode, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not bad.ok, mode + assert any("signature" in e for e in bad.errors), (mode, bad.errors) From d4c4ba5d2d1a7b474fb18f20195af557d082a863 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:01 -0700 Subject: [PATCH 05/26] feat(policy): register grant issuers and issuer key material Publish institutional issuer identities and keys so verifiers can resolve trust anchors without ad-hoc configuration. --- policy/scope_grant_issuer_registry.yaml | 45 +++++++++++++++++++++++++ policy/scope_issuer_key_registry.yaml | 40 ++++------------------ 2 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 policy/scope_grant_issuer_registry.yaml diff --git a/policy/scope_grant_issuer_registry.yaml b/policy/scope_grant_issuer_registry.yaml new file mode 100644 index 0000000..3f0d9fe --- /dev/null +++ b/policy/scope_grant_issuer_registry.yaml @@ -0,0 +1,45 @@ +version: akta-scope-grant-issuer-keys-v1 +description: | + Approved SCOPE grant-issuer public keys for grant authentication (AKTA 1.1). + + Institutions populate this registry with real issuer public material only. + The default tree ships EMPTY so strict modes (pilot|release|cross_repo|production) + fail closed until AKTA_GRANT_ISSUER_REGISTRY / AKTA_TRUST_STORE_PATH is populated. + + Multi-role keys must list explicit authorized_artifact_types (default: scope_grant). + NEVER place private keys in this registry. + +default_authorized_artifact_types: + - scope_grant + +schema: + entry_required: + - key_id + - status + - algorithm + entry_public_material_one_of: + - public_key_ref + - public_key_pem + - public_key_file + status_enum: + - active + - revoked + - expired + - pending + algorithm_enum: + - Ed25519 + optional_fields: + - valid_from + - valid_until + - issuer_identity + - tenant + - institution + - authorized_artifact_types + - replacement_key_id + - revocation_reason + - notes + +# Production institutional registry: intentionally empty. +issuers: {} + +revoked_key_ids: [] diff --git a/policy/scope_issuer_key_registry.yaml b/policy/scope_issuer_key_registry.yaml index 218a969..3dc63c6 100644 --- a/policy/scope_issuer_key_registry.yaml +++ b/policy/scope_issuer_key_registry.yaml @@ -1,42 +1,14 @@ version: akta-scope-issuer-keys-v1 description: | - Approved SCOPE issuer public keys for grant authentication (P0-C / institutional trust). + DEPRECATED alias path. Use policy/scope_grant_issuer_registry.yaml instead. - Institutions populate this registry with real issuer public material only. - The default tree ships EMPTY so production/institutional profiles fail closed - until AKTA_TRUST_STORE_PATH (or this file) is populated. + Retained so older documentation that references this filename still points at + an empty fail-closed registry. AKTA 1.1 no longer auto-loads this file as the + primary grant-issuer registry (see scope_grant_issuer_registry.yaml). - Schema mirrors SCOPE reviewer_key_registry.yaml patterns and extends them with - explicit lifecycle fields required by AKTA institutional deploys: - key_id, public_key_ref / public_key_pem / public_key_file, status, - algorithm, valid_from, valid_until, issuer_identity (optional). +default_authorized_artifact_types: + - scope_grant - NEVER place private keys in this registry. Test-only material lives under - tests/fixtures/trust_keys/ and must not be treated as production keys. - -schema: - entry_required: - - key_id - - status - - algorithm - entry_public_material_one_of: - - public_key_ref - - public_key_pem - - public_key_file - status_enum: - - active - - revoked - - expired - - pending - algorithm_enum: - - Ed25519 - optional_fields: - - valid_from - - valid_until - - issuer_identity - - notes - -# Production institutional registry: intentionally empty. issuers: {} revoked_key_ids: [] From 162efddffe3dea88af449f020feaae81b869bb08 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:07 -0700 Subject: [PATCH 06/26] feat(policy): add manifest signer and status attestation registries Bind policy manifest signatures and lifecycle status attestations to named institutional authorities. --- ...scope_policy_manifest_signer_registry.yaml | 40 +++++++++++++++++++ policy/scope_status_attestation_registry.yaml | 40 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 policy/scope_policy_manifest_signer_registry.yaml create mode 100644 policy/scope_status_attestation_registry.yaml diff --git a/policy/scope_policy_manifest_signer_registry.yaml b/policy/scope_policy_manifest_signer_registry.yaml new file mode 100644 index 0000000..fa22d3c --- /dev/null +++ b/policy/scope_policy_manifest_signer_registry.yaml @@ -0,0 +1,40 @@ +version: akta-scope-policy-manifest-signer-keys-v1 +description: | + Approved public keys for SCOPE authorization-policy manifest signatures (AKTA 1.1). + + Separate trust domain from grant issuers and status attestors. + Wired for verification in Phase B (WS5). Ships EMPTY. + +default_authorized_artifact_types: + - scope_authorization_manifest + +schema: + entry_required: + - key_id + - status + - algorithm + entry_public_material_one_of: + - public_key_ref + - public_key_pem + - public_key_file + status_enum: + - active + - revoked + - expired + - pending + algorithm_enum: + - Ed25519 + optional_fields: + - valid_from + - valid_until + - issuer_identity + - tenant + - institution + - authorized_artifact_types + - replacement_key_id + - revocation_reason + - notes + +signers: {} + +revoked_key_ids: [] diff --git a/policy/scope_status_attestation_registry.yaml b/policy/scope_status_attestation_registry.yaml new file mode 100644 index 0000000..cfdee80 --- /dev/null +++ b/policy/scope_status_attestation_registry.yaml @@ -0,0 +1,40 @@ +version: akta-scope-status-attestation-keys-v1 +description: | + Approved public keys for SCOPE grant-status attestations (AKTA 1.1). + + Separate trust domain from grant issuers. Ships EMPTY for fail-closed + institutional deploys. Populate via AKTA_STATUS_ATTESTATION_REGISTRY. + +default_authorized_artifact_types: + - scope_grant_status + +schema: + entry_required: + - key_id + - status + - algorithm + entry_public_material_one_of: + - public_key_ref + - public_key_pem + - public_key_file + status_enum: + - active + - revoked + - expired + - pending + algorithm_enum: + - Ed25519 + optional_fields: + - valid_from + - valid_until + - issuer_identity + - tenant + - institution + - authorized_artifact_types + - replacement_key_id + - revocation_reason + - notes + +attestors: {} + +revoked_key_ids: [] From d4856ac8229e2d672c4d650a5d9c5fc19b91d0ce Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:14 -0700 Subject: [PATCH 07/26] feat(policy): map tools, capabilities, and reviewer decisions Connect runtime actions to authorized capabilities and record which reviewer decisions are admissible under institutional policy. --- policy/action_tool_authorization_mapping.yaml | 84 +++++++++++++++++++ policy/reviewer_decision_registry.yaml | 41 +++++++++ policy/runtime_capability_mapping.yaml | 62 ++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 policy/action_tool_authorization_mapping.yaml create mode 100644 policy/reviewer_decision_registry.yaml create mode 100644 policy/runtime_capability_mapping.yaml diff --git a/policy/action_tool_authorization_mapping.yaml b/policy/action_tool_authorization_mapping.yaml new file mode 100644 index 0000000..764be65 --- /dev/null +++ b/policy/action_tool_authorization_mapping.yaml @@ -0,0 +1,84 @@ +version: akta-action-tool-auth-v1 +description: > + Maps AKTA action classes and tools to shared authorization (family, operation). + A5 distinguishes draft vs update_active; A7 queue priority; A9 per frozen vocab. +action_class_defaults: + A0_informational_or_exploratory: + family: interpretation + operation: clarify + A1_retrieval_or_summary: + family: interpretation + operation: clarify + A2_analysis_or_interpretation: + family: interpretation + operation: interpret + A3_hypothesis_generation: + family: interpretation + operation: discuss + A4_recommendation: + family: recommendation + operation: draft + A5_protocol_modification: + family: protocol + operation: draft + action_overrides: + draft: {family: protocol, operation: draft} + draft_change: {family: protocol, operation: draft} + draft_timing: {family: protocol, operation: draft} + update_active: {family: protocol, operation: update_active} + update_active_protocol: {family: protocol, operation: update_active} + tool_overrides: + protocol_editor.draft_change: {family: protocol, operation: draft} + protocol_editor.update_active_protocol: {family: protocol, operation: update_active} + A6_experimental_planning: + family: experiment_plan + operation: plan + tool_overrides: + experiment_planner.create_validation_draft: + {family: experiment_plan, operation: run_draft} + experiment_planner.create_run_plan: + {family: experiment_plan, operation: plan} + A7_resource_or_queue_prioritization: + family: queue + operation: single_run_priority + tool_overrides: + lab_scheduler.prioritize: {family: queue, operation: single_run_priority} + A8_tool_or_workflow_mutation: + family: execution + operation: prepare_payload + A9_execution_adjacent_or_external_action: + family: execution + operation: prepare_payload + tool_overrides: + robot_queue.submit: {family: queue, operation: robot_submit} + A10_publication_or_claim_escalation: + family: publication + operation: claim +tools: + protocol_editor.draft_change: + family: protocol + operation: draft + protocol_editor.update_active_protocol: + family: protocol + operation: update_active + experiment_planner.create_validation_draft: + family: experiment_plan + operation: run_draft + experiment_planner.create_run_plan: + family: experiment_plan + operation: plan + lab_scheduler.prioritize: + family: queue + operation: single_run_priority + robot_queue.submit: + family: queue + operation: robot_submit + workflow.update_state: + family: execution + operation: prepare_payload + publication.draft_claim: + family: publication + operation: claim + scientific_memory.import: + family: memory + operation: import diff --git a/policy/reviewer_decision_registry.yaml b/policy/reviewer_decision_registry.yaml new file mode 100644 index 0000000..fdfd055 --- /dev/null +++ b/policy/reviewer_decision_registry.yaml @@ -0,0 +1,41 @@ +version: akta-reviewer-decision-keys-v1 +description: | + Optional local reviewer-decision key registry (AKTA 1.1). + + This registry is NOT auto-merged into grant-issuer trust. Reviewer keys must + never masquerade as SCOPE grant issuers. Load explicitly only when verifying + local reviewer decision artifacts. + +default_authorized_artifact_types: + - reviewer_decision + +schema: + entry_required: + - key_id + - status + - algorithm + entry_public_material_one_of: + - public_key_ref + - public_key_pem + - public_key_file + status_enum: + - active + - revoked + - expired + - pending + algorithm_enum: + - Ed25519 + optional_fields: + - valid_from + - valid_until + - issuer_identity + - tenant + - institution + - authorized_artifact_types + - replacement_key_id + - revocation_reason + - notes + +reviewers: {} + +revoked_key_ids: [] diff --git a/policy/runtime_capability_mapping.yaml b/policy/runtime_capability_mapping.yaml new file mode 100644 index 0000000..b88d402 --- /dev/null +++ b/policy/runtime_capability_mapping.yaml @@ -0,0 +1,62 @@ +# Maps AKTA action classes / tools to shared-authorization (family, operation). +# Consumed when building RequestedAuthorizationContext (WS8) and hashed into +# effective-policy provenance (WS11). Keep in sync with shared contract vocab. +version: akta-runtime-capability-mapping-v1 +policy_file_version: runtime_capability_mapping-v1.0 +shared_contract_envelope: scientific-authorization-envelope-v2 + +# Default when a tool has no explicit mapping: fail closed at request build. +default_on_unmapped: abstain_unknown_capability + +action_class_defaults: + A1_literature_or_hypothesis: + family: interpretation + operation: discuss + A2_evidence_summary: + family: interpretation + operation: interpret + A3_analysis_plan: + family: experiment_plan + operation: plan + A4_analysis_result: + family: interpretation + operation: interpret + A5_protocol_draft_or_update: + family: protocol + operation: draft + A6_validation_plan: + family: experiment_plan + operation: plan + A7_queue_or_priority: + family: queue + operation: single_run_priority + A8_tool_permission: + family: permissions + operation: escalate + A9_execution_payload: + family: execution + operation: prepare_payload + A10_publication_or_claim: + family: publication + operation: claim + +# Tool-level overrides (win over action_class_defaults). +tool_mappings: + protocol.draft: + family: protocol + operation: draft + protocol.update_active: + family: protocol + operation: update_active + protocol.diff_review: + family: protocol + operation: diff_review + queue.single_run_priority: + family: queue + operation: single_run_priority + queue.bounded_batch_priority: + family: queue + operation: bounded_batch_priority + literature_search.query: + family: interpretation + operation: discuss From 2729ebd7f74629c2fcff567e4c81df52714d960c Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:21 -0700 Subject: [PATCH 08/26] feat(authz): add requested authorization request model Normalize how clients declare intended scope so grants can be checked against an explicit request contract. --- akta/requested_authorization.py | 371 ++++++++++++++++++++++++++ tests/test_requested_authorization.py | 187 +++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 akta/requested_authorization.py create mode 100644 tests/test_requested_authorization.py diff --git a/akta/requested_authorization.py b/akta/requested_authorization.py new file mode 100644 index 0000000..cf2a37d --- /dev/null +++ b/akta/requested_authorization.py @@ -0,0 +1,371 @@ +"""Requested authorization context builder and containment (AKTA 1.1 WS8).""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +from akta.capability_envelope import authorization_covers, legacy_scope_to_envelope +from akta.shared_contracts import _contracts, family_operations + +_AKTA_ROOT = Path(__file__).resolve().parent.parent +_DEFAULT_MAPPING = _AKTA_ROOT / "policy" / "action_tool_authorization_mapping.yaml" + + +@dataclass(frozen=True) +class RequestedAuthorizationContext: + """Actual requested capability envelope inputs for containment comparison.""" + + family: str + operation: str + target: dict + requested_tool: str + environment: dict + responsibility_level: str + item_count: int + constraints: dict + + def to_envelope(self) -> dict[str, Any]: + """Materialize a shared authorization envelope for containment checks.""" + api = _contracts() + target = dict(self.target) + # Normalize common aliases onto schema field names. + if "id" in target and "protocol_id" not in target and target.get("type") == "protocol": + target["protocol_id"] = target.pop("id") + elif "id" in target and target.get("type") == "protocol": + target.setdefault("protocol_id", target.pop("id")) + elif "id" in target: + target.pop("id", None) + + environment = dict(self.environment) + tenant = target.pop("tenant_id", None) or environment.get("tenant_id") + if tenant: + environment["tenant_id"] = tenant + target_env = target.pop("environment", None) + if target_env and "deployment_id" not in environment: + environment["deployment_id"] = str(target_env) + + cardinality: dict[str, Any] = { + "mode": "single" if self.item_count <= 1 else "bounded", + "maximum_uses": 1, + "maximum_items": max(1, int(self.item_count)), + } + payload = { + "envelope_version": api.ENVELOPE_VERSION, + "capability_id": api.new_capability_id(), + "family": self.family, + "operation": self.operation, + "target": target, + "allowed_tools": [self.requested_tool] if self.requested_tool else [], + "blocked_tools": [], + "cardinality": cardinality, + "environment": environment, + "responsibility_ceiling": self.responsibility_level, + "constraints": dict(self.constraints), + "expiration": {"absolute_expiration": None, "expires_after": []}, + } + return api.AuthorizationEnvelope.from_dict(payload, validate=True).to_dict() + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@lru_cache(maxsize=4) +def load_action_tool_authorization_mapping( + path: str | None = None, +) -> dict[str, Any]: + mapping_path = Path(path) if path else _DEFAULT_MAPPING + if not mapping_path.is_file(): + return {"version": "akta-action-tool-auth-v1", "mappings": {}} + data = yaml.safe_load(mapping_path.read_text(encoding="utf-8")) or {} + return data + + +def resolve_family_operation( + *, + action_class: str | None = None, + requested_tool: str = "", + requested_action: str = "", + structured_action: dict[str, Any] | None = None, + mapping_path: str | None = None, +) -> tuple[str, str]: + """Map AKTA action class + tool → (family, operation).""" + mapping = load_action_tool_authorization_mapping(mapping_path) + tools = mapping.get("tools") or {} + if requested_tool and requested_tool in tools: + entry = tools[requested_tool] + return str(entry["family"]), str(entry["operation"]) + + action_defaults = mapping.get("action_class_defaults") or {} + action_key = action_class or "" + if action_key in action_defaults: + entry = action_defaults[action_key] + # Tool-sensitive overrides (A5 draft vs update_active, A9 robot, etc.) + tool_overrides = entry.get("tool_overrides") or {} + if requested_tool and requested_tool in tool_overrides: + ov = tool_overrides[requested_tool] + return str(ov["family"]), str(ov["operation"]) + # Action-name sensitive (draft vs update) + action_overrides = entry.get("action_overrides") or {} + if requested_action and requested_action in action_overrides: + ov = action_overrides[requested_action] + return str(ov["family"]), str(ov["operation"]) + return str(entry["family"]), str(entry["operation"]) + + sa = structured_action or {} + if sa.get("family") and sa.get("operation"): + return str(sa["family"]), str(sa["operation"]) + + # Fall back to legacy scalar migration when a scope hint is present. + legacy_scope = ( + sa.get("requested_scope") + or mapping.get("tool_legacy_scopes", {}).get(requested_tool) + ) + if legacy_scope: + mig = _contracts().legacy_scope_migration() + if legacy_scope in mig: + return mig[legacy_scope] + + # Last resort: protocol draft for protocol tools, interpretation otherwise. + if "protocol" in requested_tool: + return "protocol", "draft" + if "robot" in requested_tool: + return "queue", "robot_submit" + if "lab_scheduler" in requested_tool or "prioritize" in requested_action: + return "queue", "single_run_priority" + return "interpretation", "clarify" + + +def build_requested_authorization_context( + *, + requested_tool: str, + requested_action: str = "", + action_class: str | None = None, + classification: dict[str, Any] | None = None, + structured_action: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, + mapping_path: str | None = None, + responsibility_level: str | None = None, + item_count: int | None = None, + target_overrides: dict[str, Any] | None = None, + environment_overrides: dict[str, Any] | None = None, + constraints_overrides: dict[str, Any] | None = None, +) -> RequestedAuthorizationContext: + """Build frozen request context from classification/tool/action/context.""" + ctx = context or {} + metadata = dict(ctx.get("metadata") or {}) + classification = classification or {} + structured_action = structured_action or dict(ctx.get("structured_action") or {}) + + resolved_action_class = ( + action_class + or classification.get("action_type") + or classification.get("action_class") + or structured_action.get("action_type") + ) + family, operation = resolve_family_operation( + action_class=str(resolved_action_class) if resolved_action_class else None, + requested_tool=requested_tool, + requested_action=requested_action, + structured_action=structured_action, + mapping_path=mapping_path, + ) + + # Validate vocabulary. + ops = family_operations() + if family not in ops: + raise ValueError(f"unknown authorization family: {family!r}") + if operation not in ops[family]: + raise ValueError( + f"unknown operation {operation!r} for family {family!r}" + ) + + protocol_id = ( + structured_action.get("protocol_id") + or structured_action.get("target_id") + or structured_action.get("target_resource") + or ctx.get("protocol_version") + or ctx.get("active_protocol_id") + or metadata.get("protocol_version") + or "unspecified" + ) + target_type = ( + structured_action.get("target_type") + or metadata.get("target_type") + or "protocol" + ) + target: dict[str, Any] = {"type": target_type} + if target_type == "protocol": + target["protocol_id"] = protocol_id + if ctx.get("protocol_version") or metadata.get("protocol_version"): + target["protocol_version"] = str( + ctx.get("protocol_version") or metadata.get("protocol_version") + ) + if target_overrides: + target.update(target_overrides) + + environment = { + "tenant_id": ( + structured_action.get("tenant_id") + or ctx.get("tenant_id") + or metadata.get("tenant_id") + or "default" + ), + "domain_overlay": ctx.get("domain_overlay") or metadata.get("domain_overlay"), + "deployment_id": ( + structured_action.get("target_environment") + or ctx.get("environment") + or metadata.get("environment") + or ctx.get("deployment_profile") + or metadata.get("deployment_profile") + ), + } + environment = {k: v for k, v in environment.items() if v is not None} + if environment_overrides: + environment.update(environment_overrides) + + level = ( + responsibility_level + or structured_action.get("responsibility_level") + or classification.get("responsibility_level") + or metadata.get("responsibility_level") + or "R4_methodological_modification" + ) + count = ( + item_count + if item_count is not None + else int( + structured_action.get("item_count") + or metadata.get("item_count") + or ctx.get("item_count") + or 1 + ) + ) + constraints = dict(structured_action.get("constraints") or {}) + if constraints_overrides: + constraints.update(constraints_overrides) + + return RequestedAuthorizationContext( + family=family, + operation=operation, + target=target, + requested_tool=requested_tool, + environment=environment, + responsibility_level=str(level), + item_count=count, + constraints=constraints, + ) + + +@dataclass +class AuthorizationContainmentResult: + covers: bool + comparator: str + requested: RequestedAuthorizationContext | None = None + requested_envelope: dict[str, Any] | None = None + granted_envelope: dict[str, Any] | None = None + errors: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "covers": self.covers, + "comparator": self.comparator, + "requested": self.requested.to_dict() if self.requested else None, + "requested_envelope": self.requested_envelope, + "granted_envelope": self.granted_envelope, + "errors": list(self.errors), + } + + +def evaluate_authorization_containment( + *, + granted_envelope: dict[str, Any], + requested: RequestedAuthorizationContext, + ledger_status: dict[str, Any] | None = None, +) -> AuthorizationContainmentResult: + """Compare actual request against granted envelope; enforce remaining uses.""" + errors: list[str] = [] + if ledger_status is not None: + remaining = ledger_status.get("cardinality_remaining") + if remaining is None: + remaining = ledger_status.get("single_use_remaining") + if remaining is not None and int(remaining) <= 0: + errors.append("no remaining use for grant (cardinality_remaining=0)") + status = str(ledger_status.get("status") or "") + if status and status != "active": + errors.append(f"grant status not active ({status})") + + grant_constraints = dict(granted_envelope.get("constraints") or {}) + req_constraints = dict(requested.constraints or {}) + for key in req_constraints: + if key not in grant_constraints: + errors.append(f"unknown request constraint not in grant: {key!r}") + + # Build envelope using only grant-authorized constraint keys so vocabulary + # validation does not raise before our unknown-constraint errors surface. + safe_requested = RequestedAuthorizationContext( + family=requested.family, + operation=requested.operation, + target=dict(requested.target), + requested_tool=requested.requested_tool, + environment=dict(requested.environment), + responsibility_level=requested.responsibility_level, + item_count=requested.item_count, + constraints={k: v for k, v in req_constraints.items() if k in grant_constraints}, + ) + requested_envelope = safe_requested.to_envelope() + # Requested must honor grant blocked_tools (narrowing requires superset blocks). + granted_blocked = list(granted_envelope.get("blocked_tools") or []) + requested_envelope["blocked_tools"] = sorted( + set(requested_envelope.get("blocked_tools") or []) | set(granted_blocked) + ) + # Inherit grant expiration upper bound so the request is not treated as wider. + grant_exp = dict(granted_envelope.get("expiration") or {}) + req_exp = dict(requested_envelope.get("expiration") or {}) + if grant_exp.get("absolute_expiration") and not req_exp.get("absolute_expiration"): + req_exp["absolute_expiration"] = grant_exp["absolute_expiration"] + if grant_exp.get("expires_after") and not req_exp.get("expires_after"): + req_exp["expires_after"] = list(grant_exp["expires_after"]) + requested_envelope["expiration"] = req_exp + requested_envelope["constraints"] = {**grant_constraints, **safe_requested.constraints} + # Item-count defense: requested items must not exceed grant cardinality. + granted_card = granted_envelope.get("cardinality") or {} + max_items = granted_card.get("maximum_items") + if max_items is not None and int(requested.item_count) > int(max_items): + errors.append( + f"requested item_count {requested.item_count} exceeds grant " + f"maximum_items {max_items}" + ) + covers, comparator = authorization_covers( + granted_envelope=granted_envelope, + requested_envelope=requested_envelope, + allow_legacy_scalar=False, + ) + if not covers: + errors.append("requested authorization not covered by grant envelope") + return AuthorizationContainmentResult( + covers=covers and not errors, + comparator=comparator, + requested=requested, + requested_envelope=requested_envelope, + granted_envelope=granted_envelope, + errors=errors, + ) + + +def legacy_requested_envelope_from_scope( + requested_scope: str, + *, + allowed_tools: list[str] | None = None, + responsibility_ceiling: str = "R4_methodological_modification", +) -> dict[str, Any]: + """Migration helper — not used as primary comparator in strict modes.""" + return legacy_scope_to_envelope( + approved_scope=requested_scope, + allowed_tools=allowed_tools, + responsibility_ceiling=responsibility_ceiling, + ) diff --git a/tests/test_requested_authorization.py b/tests/test_requested_authorization.py new file mode 100644 index 0000000..c064dee --- /dev/null +++ b/tests/test_requested_authorization.py @@ -0,0 +1,187 @@ +"""AKTA 1.1 WS8: RequestedAuthorizationContext containment acceptance.""" + +from __future__ import annotations + +from akta.capability_envelope import legacy_scope_to_envelope +from akta.requested_authorization import ( + build_requested_authorization_context, + evaluate_authorization_containment, + resolve_family_operation, +) + + +def _granted(**overrides): + env = legacy_scope_to_envelope( + approved_scope="protocol_draft", + allowed_tools=["protocol_editor.draft_change"], + blocked_tools=["robot_queue.submit"], + target={"type": "protocol", "protocol_id": "protocol-v3"}, + environment={"tenant_id": "tenant-a"}, + responsibility_ceiling="R4_methodological_modification", + cardinality={"mode": "single", "maximum_uses": 1, "maximum_items": 1}, + constraints={"protocol_version": "protocol-v3"}, + ) + env.update(overrides) + return env + + +def test_a5_draft_vs_update_active_mapping() -> None: + assert resolve_family_operation( + action_class="A5_protocol_modification", + requested_tool="protocol_editor.draft_change", + ) == ("protocol", "draft") + assert resolve_family_operation( + action_class="A5_protocol_modification", + requested_tool="protocol_editor.update_active_protocol", + ) == ("protocol", "update_active") + + +def test_a7_queue_priority_mapping() -> None: + assert resolve_family_operation( + action_class="A7_resource_or_queue_prioritization", + requested_tool="lab_scheduler.prioritize", + ) == ("queue", "single_run_priority") + + +def test_a9_robot_mapping() -> None: + assert resolve_family_operation( + action_class="A9_execution_adjacent_or_external_action", + requested_tool="robot_queue.submit", + ) == ("queue", "robot_submit") + + +def test_rejects_broader_operation() -> None: + granted = _granted() + # Force update_active request against draft grant. + requested = build_requested_authorization_context( + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_active", + action_class="A5_protocol_modification", + context={ + "protocol_version": "protocol-v3", + "tenant_id": "tenant-a", + }, + constraints_overrides={"protocol_version": "protocol-v3"}, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + + +def test_rejects_other_protocol() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v9", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v9"}, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + + +def test_rejects_other_tenant() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-b"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + environment_overrides={"tenant_id": "tenant-b"}, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + + +def test_rejects_responsibility_ceiling() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + responsibility_level="R6_physical_execution", + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + + +def test_rejects_item_count() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + item_count=5, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + assert any("item_count" in e for e in result.errors) + + +def test_rejects_unknown_constraint() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3", "evil_flag": True}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, requested=requested + ) + assert not result.covers + assert any("unknown request constraint" in e for e in result.errors) + + +def test_rejects_no_remaining_use() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + ) + result = evaluate_authorization_containment( + granted_envelope=granted, + requested=requested, + ledger_status={"status": "active", "cardinality_remaining": 0}, + ) + assert not result.covers + assert any("no remaining use" in e for e in result.errors) + + +def test_valid_success() -> None: + granted = _granted() + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + environment_overrides={"tenant_id": "tenant-a"}, + item_count=1, + responsibility_level="R3_recommendation", + ) + result = evaluate_authorization_containment( + granted_envelope=granted, + requested=requested, + ledger_status={"status": "active", "cardinality_remaining": 1}, + ) + assert result.covers, result.errors From 09ef2ed38d7e1281e4f60ae63797594cd7763f45 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:31 -0700 Subject: [PATCH 09/26] feat(authz): gate grants against authorization manifest schema Reject malformed or incomplete grant payloads before cryptographic verification so schema errors surface clearly. --- akta/scope_authorization_manifest.py | 467 +++++++++++++++++++++++++++ tests/test_grant_schema_gate.py | 231 +++++++++++++ 2 files changed, 698 insertions(+) create mode 100644 akta/scope_authorization_manifest.py create mode 100644 tests/test_grant_schema_gate.py diff --git a/akta/scope_authorization_manifest.py b/akta/scope_authorization_manifest.py new file mode 100644 index 0000000..88f0db4 --- /dev/null +++ b/akta/scope_authorization_manifest.py @@ -0,0 +1,467 @@ +"""Authenticate SCOPE authorization manifests (AKTA 1.1 WS5). + +Accepts embedded-by-reference, authenticated SCOPE fetch, or locally pinned +signed manifests. Grant ``provenance.scope_trust_root_hash`` must equal a +verified manifest hash. Cache is keyed by hash and refreshed on key revocation. +""" + +from __future__ import annotations + +import json +import os +import threading +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from akta.hash import hash_object +from akta.shared_contracts import ( + CONTRACTS_PACKAGE_VERSION, + authorization_envelope_schema_hash, + grant_schema_hash, + release_artifact_hash, +) +from akta.trust_store import ( + ArtifactType, + entry_within_validity, + institutional_registry_is_empty, + resolve_manifest_signer_trust_store, +) + +SUPPORTED_MANIFEST_ALGORITHMS = frozenset({"Ed25519", "ed25519"}) +REQUIRED_MANIFEST_HASH_FIELDS = ( + "shared_contract_release_hash", + "authorization_envelope_schema_hash", + "grant_schema_hash", + "vocabulary_hash", + "scope_policy_hash", + "issuer_registry_hash", + "identity_policy_hash", + "rbac_hash", + "domain_overlay_hash", +) + + +@dataclass +class ManifestVerificationResult: + ok: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + manifest: dict[str, Any] | None = None + manifest_hash: str | None = None + + +class _ManifestCache: + """Hash-keyed cache; entries invalidated when signer keys are revoked.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._by_hash: dict[str, dict[str, Any]] = {} + self._signer_revocation_epoch: int = 0 + + def get(self, manifest_hash: str) -> dict[str, Any] | None: + with self._lock: + entry = self._by_hash.get(manifest_hash) + if entry is None: + return None + return dict(entry["manifest"]) + + def put(self, manifest_hash: str, manifest: dict[str, Any], *, key_id: str) -> None: + with self._lock: + self._by_hash[manifest_hash] = { + "manifest": dict(manifest), + "key_id": key_id, + "cached_at": datetime.now(timezone.utc).isoformat(), + "revocation_epoch": self._signer_revocation_epoch, + } + + def invalidate_on_revocation(self, revoked_key_ids: set[str] | frozenset[str]) -> None: + with self._lock: + self._signer_revocation_epoch += 1 + drop = [ + h + for h, entry in self._by_hash.items() + if entry.get("key_id") in revoked_key_ids + ] + for h in drop: + del self._by_hash[h] + + def clear(self) -> None: + with self._lock: + self._by_hash.clear() + self._signer_revocation_epoch += 1 + + +_CACHE = _ManifestCache() + + +def clear_manifest_cache() -> None: + _CACHE.clear() + + +def invalidate_manifest_cache_for_revoked_keys( + revoked_key_ids: set[str] | frozenset[str] | list[str], +) -> None: + _CACHE.invalidate_on_revocation(frozenset(str(x) for x in revoked_key_ids)) + + +def compute_manifest_hash(manifest: dict[str, Any]) -> str: + """Canonical hash over unsigned manifest body (excludes signature fields).""" + body = { + k: v + for k, v in manifest.items() + if k + not in { + "manifest_hash", + "signed_payload_hash", + "manifest_signature", + "signature", + "attestation_signature", + } + } + return hash_object(body) + + +def _fetch_manifest_by_hash(manifest_hash: str, *, endpoint: str | None = None) -> dict[str, Any] | None: + base = (endpoint or os.environ.get("SCOPE_AUTHORIZATION_MANIFEST_URL") or "").strip() + if not base: + return None + url = base.rstrip("/") + if "{manifest_hash}" in url: + url = url.format(manifest_hash=manifest_hash) + elif "{hash}" in url: + url = url.format(hash=manifest_hash) + else: + digest = manifest_hash.removeprefix("sha256:") + url = f"{url}/{digest}" + try: + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, ValueError): + return None + return payload if isinstance(payload, dict) else None + + +def _load_pinned_manifest(path: str | Path | None = None) -> dict[str, Any] | None: + env = os.environ.get("AKTA_SCOPE_AUTHORIZATION_MANIFEST_PATH", "").strip() + candidate = Path(path) if path else (Path(env) if env else None) + if candidate is None or not candidate.is_file(): + return None + text = candidate.read_text(encoding="utf-8") + if candidate.suffix.lower() in {".yaml", ".yml"}: + import yaml + + data = yaml.safe_load(text) + else: + data = json.loads(text) + return data if isinstance(data, dict) else None + + +def _contract_hash_mismatches(manifest: dict[str, Any]) -> list[str]: + errors: list[str] = [] + declared_version = manifest.get("shared_contract_version") + if declared_version and str(declared_version) != CONTRACTS_PACKAGE_VERSION: + errors.append( + f"authorization manifest shared_contract_version mismatch " + f"({declared_version!r} != {CONTRACTS_PACKAGE_VERSION!r})" + ) + try: + expected_release = release_artifact_hash() + except Exception: # noqa: BLE001 + expected_release = None + declared_release = manifest.get("shared_contract_release_hash") + if expected_release and declared_release and declared_release != expected_release: + errors.append("authorization manifest shared_contract_release_hash mismatch") + + expected_env = authorization_envelope_schema_hash() + declared_env = manifest.get("authorization_envelope_schema_hash") + if declared_env and declared_env != expected_env: + errors.append("authorization manifest authorization_envelope_schema_hash mismatch") + + expected_grant = grant_schema_hash() + declared_grant = manifest.get("grant_schema_hash") + if declared_grant and declared_grant != expected_grant: + errors.append("authorization manifest grant_schema_hash mismatch") + return errors + + +def _signature_errors( + manifest: dict[str, Any], + *, + trust_store: dict[str, Any] | None, + require_crypto: bool, +) -> list[str]: + errors: list[str] = [] + sig = manifest.get("manifest_signature") or manifest.get("signature") + if not sig: + errors.append("authorization manifest signature missing") + return errors + + algo = manifest.get("signature_algorithm") or "Ed25519" + if str(algo) not in SUPPORTED_MANIFEST_ALGORITHMS: + errors.append(f"unsupported authorization manifest signature_algorithm: {algo!r}") + + issuer = manifest.get("issuer") or {} + key_id = ( + issuer.get("issuer_key_id") + or manifest.get("manifest_signature_key_id") + or manifest.get("signer_key_id") + ) + if not key_id: + errors.append("authorization manifest signer key id missing") + return errors + + if trust_store is None: + if require_crypto: + errors.append("manifest signer trust store required") + return errors + + if require_crypto and ( + not (trust_store.get("keys") or {}) or institutional_registry_is_empty(trust_store) + ): + errors.append("manifest signer trust store has no approved keys") + + revoked = set(trust_store.get("revoked_key_ids") or []) + if key_id in revoked: + errors.append(f"revoked authorization manifest signer key: {key_id}") + invalidate_manifest_cache_for_revoked_keys({str(key_id)}) + return errors + + keys = trust_store.get("keys") or {} + entry = keys.get(key_id) + if entry is None: + errors.append(f"unknown authorization manifest signer key: {key_id}") + return errors + + status = str(entry.get("status") or "active").lower() + if status != "active": + errors.append(f"authorization manifest signer key not active (status={status})") + if not entry_within_validity(entry): + errors.append(f"authorization manifest signer key outside validity window: {key_id}") + + allowed = entry.get("_authorized_artifact_types") or set( + entry.get("authorized_artifact_types") or [] + ) + required = ArtifactType.SCOPE_AUTHORIZATION_MANIFEST.value + if allowed and required not in allowed: + errors.append( + f"signer key {key_id} not authorized for artifact type {required!r}" + ) + + hash_field = "manifest_hash" if "manifest_hash" in manifest else "signed_payload_hash" + if hash_field not in manifest: + errors.append("authorization manifest hash missing") + return errors + + signed_payload = manifest.get("signed_payload_hash") + if signed_payload is not None and signed_payload != manifest.get(hash_field): + errors.append("authorization manifest signed_payload_hash must equal manifest_hash") + + verifier = trust_store.get("verify") + if callable(verifier): + try: + if not verifier(manifest, hash_field, str(sig)): + errors.append("authorization manifest signature invalid") + except Exception as exc: # noqa: BLE001 + errors.append(f"authorization manifest signature verification failed: {exc}") + elif require_crypto: + errors.append("manifest signer trust store missing verify callback") + return errors + + +def verify_authorization_manifest( + manifest: dict[str, Any], + *, + expected_hash: str | None = None, + trust_store: dict[str, Any] | None = None, + require_crypto: bool = True, + use_cache: bool = True, +) -> ManifestVerificationResult: + """Verify a signed SCOPE authorization manifest.""" + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(manifest, dict) or not manifest: + return ManifestVerificationResult(ok=False, errors=["authorization manifest empty"]) + + computed = compute_manifest_hash(manifest) + declared = manifest.get("manifest_hash") + if declared and declared != computed: + if require_crypto: + errors.append( + f"authorization manifest_hash mismatch ({declared} != {computed})" + ) + else: + warnings.append( + f"authorization manifest_hash mismatch ({declared} != {computed})" + ) + manifest_hash = declared or computed + if expected_hash and manifest_hash != expected_hash: + errors.append( + f"authorization manifest hash does not match grant trust root " + f"({manifest_hash} != {expected_hash})" + ) + + if use_cache and not errors: + cached = _CACHE.get(manifest_hash) + if cached is not None: + return ManifestVerificationResult( + ok=True, + manifest=cached, + manifest_hash=manifest_hash, + ) + + for field_name in REQUIRED_MANIFEST_HASH_FIELDS: + if not manifest.get(field_name): + msg = f"authorization manifest missing {field_name}" + if require_crypto: + errors.append(msg) + else: + warnings.append(msg) + + errors.extend(_contract_hash_mismatches(manifest)) + + resolved_trust = trust_store + if resolved_trust is None and require_crypto: + resolved_trust = resolve_manifest_signer_trust_store(require_keys=True) + + errors.extend( + _signature_errors( + manifest, + trust_store=resolved_trust, + require_crypto=require_crypto, + ) + ) + + ok = not errors + if ok and use_cache: + issuer = manifest.get("issuer") or {} + key_id = str( + issuer.get("issuer_key_id") + or manifest.get("manifest_signature_key_id") + or "" + ) + stored = dict(manifest) + stored.setdefault("manifest_hash", manifest_hash) + _CACHE.put(manifest_hash, stored, key_id=key_id) + + return ManifestVerificationResult( + ok=ok, + errors=errors, + warnings=warnings, + manifest=dict(manifest) if ok else None, + manifest_hash=manifest_hash if ok else None, + ) + + +def resolve_and_verify_authorization_manifest( + *, + expected_trust_root_hash: str | None, + embedded_manifest: dict[str, Any] | None = None, + pinned_path: str | Path | None = None, + fetch_endpoint: str | None = None, + trust_store: dict[str, Any] | None = None, + require_crypto: bool = True, +) -> ManifestVerificationResult: + """Resolve manifest via cache, embed, pin, or authenticated fetch, then verify.""" + if not expected_trust_root_hash: + return ManifestVerificationResult( + ok=False, + errors=["provenance.scope_trust_root_hash missing"], + ) + + if require_crypto: + cached = _CACHE.get(expected_trust_root_hash) + if cached is not None: + return verify_authorization_manifest( + cached, + expected_hash=expected_trust_root_hash, + trust_store=trust_store, + require_crypto=require_crypto, + use_cache=True, + ) + + candidates: list[dict[str, Any]] = [] + if isinstance(embedded_manifest, dict): + candidates.append(embedded_manifest) + pinned = _load_pinned_manifest(pinned_path) + if pinned is not None: + candidates.append(pinned) + fetched = _fetch_manifest_by_hash( + expected_trust_root_hash, endpoint=fetch_endpoint + ) + if fetched is not None: + candidates.append(fetched) + + if not candidates: + return ManifestVerificationResult( + ok=False, + errors=[ + "authorization manifest unavailable for trust root " + f"{expected_trust_root_hash}" + ], + ) + + last: ManifestVerificationResult | None = None + for candidate in candidates: + last = verify_authorization_manifest( + candidate, + expected_hash=expected_trust_root_hash, + trust_store=trust_store, + require_crypto=require_crypto, + ) + if last.ok: + return last + return last or ManifestVerificationResult( + ok=False, errors=["authorization manifest verification failed"] + ) + + +def build_test_authorization_manifest( + *, + signer_keys: dict[str, str], + extras: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a signed manifest for tests (uses installed contract hashes).""" + from akta.trust_store import sign_payload_hash_ed25519 + + try: + vocab_hash = hash_object({"vocabulary": "scientific-authorization-vocab-v1"}) + except Exception: # noqa: BLE001 + vocab_hash = "sha256:" + ("a" * 64) + + try: + release_hash = release_artifact_hash() + except Exception: # noqa: BLE001 + release_hash = "sha256:" + ("b" * 64) + + manifest: dict[str, Any] = { + "manifest_version": "scope-authorization-manifest-v1", + "created_at": "2026-01-01T00:00:00Z", + "shared_contract_version": CONTRACTS_PACKAGE_VERSION, + "shared_contract_release_hash": release_hash, + "authorization_envelope_schema_hash": authorization_envelope_schema_hash(), + "grant_schema_hash": grant_schema_hash(), + "vocabulary_hash": vocab_hash, + "scope_policy_hash": "sha256:" + ("c" * 64), + "issuer_registry_hash": "sha256:" + ("d" * 64), + "identity_policy_hash": "sha256:" + ("e" * 64), + "rbac_hash": "sha256:" + ("f" * 64), + "domain_overlay_hash": "sha256:" + ("1" * 64), + "issuer": { + "issuer_identity": "scope-manifest-signer", + "issuer_key_id": signer_keys["public_key_ref"], + }, + "signature_algorithm": "Ed25519", + } + if extras: + manifest.update(extras) + manifest_hash = compute_manifest_hash(manifest) + manifest["manifest_hash"] = manifest_hash + manifest["signed_payload_hash"] = manifest_hash + priv = Path(signer_keys["private_key_path"]).read_bytes() + manifest["manifest_signature"] = sign_payload_hash_ed25519(priv, manifest_hash) + return manifest diff --git a/tests/test_grant_schema_gate.py b/tests/test_grant_schema_gate.py new file mode 100644 index 0000000..4a68b6f --- /dev/null +++ b/tests/test_grant_schema_gate.py @@ -0,0 +1,231 @@ +"""AKTA 1.1 WS4–7: grant schema gate, manifest, status, source hashes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from akta.errors import GrantVerificationError +from akta.scope_authorization_manifest import ( + clear_manifest_cache, + verify_authorization_manifest, +) +from akta.scope_grant_verifier import ( + migrate_legacy_grant_to_v2, + verify_scope_grant, + verify_scope_grant_or_raise, +) +from akta.shared_contracts import _contracts +from tests.helpers.institutional_grant import ( + make_institutional_kit, + make_signed_grant, + make_source_artifacts, +) + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + clear_manifest_cache() + + +@pytest.fixture +def kit(tmp_path: Path) -> dict: + return make_institutional_kit(tmp_path) + + +def test_release_rejects_legacy_grant_version(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + grant["grant_version"] = "akta-scope-grant-v1.0" + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not result.ok + assert any("legacy grant version" in e for e in result.errors) + + +def test_release_rejects_missing_signature_algorithm(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + grant.pop("signature_algorithm", None) + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not result.ok + assert any("signature_algorithm missing" in e for e in result.errors) + + +def test_release_rejects_missing_source_hashes(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + grant["source"] = { + "packet_id": "pkt-1", + "decision_id": "dec-1", + "akta_record_id": "rec-1", + } + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not result.ok + assert any("missing source hashes" in e for e in result.errors) + + +def test_release_rejects_unknown_grant_version(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + grant["grant_version"] = "9.9.9-unknown" + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not result.ok + assert any("unknown grant schema version" in e for e in result.errors) + + +def test_migrate_legacy_grant_cli_helper() -> None: + legacy = { + "grant_id": "LEGACY-1", + "authorization": { + "approved_scope": "protocol_draft", + "allowed_tools": ["protocol_editor.draft_change"], + }, + } + migrated = migrate_legacy_grant_to_v2(legacy) + assert migrated["grant_version"] == "2.0.0" + assert "authorization_envelope" in migrated["authorization"] + + +def test_authorization_manifest_binds_trust_root(kit: dict) -> None: + result = verify_authorization_manifest( + kit["manifest"], + expected_hash=kit["trust_root"], + trust_store=kit["manifest_store"], + require_crypto=True, + ) + assert result.ok + assert result.manifest_hash == kit["trust_root"] + + +def test_strict_rejects_trust_root_without_manifest(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + result = verify_scope_grant( + grant, + mode="pilot", + trust_store=kit["issuer_store"], + ledger_status=status, + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + # no authorization_manifest and no pin/fetch + ) + assert not result.ok + assert any("authorization manifest" in e for e in result.errors) + + +def test_grant_status_unavailable_in_strict(kit: dict) -> None: + grant, _status, arts = make_signed_grant(kit) + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ledger_status=None, + ) + assert not result.ok + assert any(e == "grant_status_unavailable" for e in result.errors) + + +def test_grant_status_rejects_stale_and_wrong_hash(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + status["checked_at"] = "2020-01-01T00:00:00Z" + status["grant_hash"] = "sha256:" + ("0" * 64) + from tests.helpers.institutional_grant import sign_status + + status = sign_status(status, kit["status_keys"]) + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert not result.ok + assert any("stale" in e or "grant_hash" in e for e in result.errors) + + +def test_source_hash_mismatch_different_record(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + other = dict(arts["record"]) + other["record_id"] = "rec-other" + other["summary"] = "different" + from akta.hash import hash_object + + other["record_hash"] = hash_object( + {k: v for k, v in other.items() if k != "record_hash"} + ) + bad_arts = dict(arts) + bad_arts["record"] = other + # Keep grant bound to rec-1 hash but supply different record artifact. + result = verify_scope_grant( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=bad_arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + record=other, + ) + assert not result.ok + assert any("hash" in e or "record" in e for e in result.errors) + + +def test_strict_accepts_full_institutional_grant(kit: dict) -> None: + grant, status, arts = make_signed_grant(kit) + result = verify_scope_grant_or_raise( + grant, + mode="release", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert result.ok + assert result.authorization_manifest_hash == kit["trust_root"] From 08775f5dbe45db83b9c25d415c33ee8fc69b9c91 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:40 -0700 Subject: [PATCH 10/26] test(helpers): add institutional grant fixture builders Share signed grant construction across verifier, REST, and acceptance tests to keep fixtures consistent. --- tests/helpers/__init__.py | 1 + tests/helpers/institutional_grant.py | 227 +++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/institutional_grant.py diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..1a9dff1 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1 @@ +"""Test helpers package.""" diff --git a/tests/helpers/institutional_grant.py b/tests/helpers/institutional_grant.py new file mode 100644 index 0000000..d51f888 --- /dev/null +++ b/tests/helpers/institutional_grant.py @@ -0,0 +1,227 @@ +"""Shared builders for institutional grant / status / manifest fixtures.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from akta.capability_envelope import legacy_scope_to_envelope +from akta.hash import hash_object +from akta.scope_authorization_manifest import build_test_authorization_manifest +from akta.shared_contracts import ENVELOPE_VERSION, _contracts, authorization_envelope_schema_hash +from akta.trust_store import ( + build_trust_store, + generate_test_keypair, + sign_payload_hash_ed25519, +) + + +def make_source_artifacts() -> dict[str, Any]: + record = {"record_id": "rec-1", "summary": "record-body"} + decision = {"decision_id": "dec-1", "admissibility": "review_required"} + packet = {"packet_id": "pkt-1", "payload": "packet"} + trigger = {"review_trigger_id": "trg-1", "packet_id": "pkt-1"} + record["record_hash"] = hash_object( + {k: v for k, v in record.items() if k != "record_hash"} + ) + decision["decision_hash"] = hash_object( + {k: v for k, v in decision.items() if k != "decision_hash"} + ) + packet["packet_hash"] = hash_object( + {k: v for k, v in packet.items() if k != "packet_hash"} + ) + trigger["review_trigger_hash"] = hash_object( + {k: v for k, v in trigger.items() if k != "review_trigger_hash"} + ) + return { + "record": record, + "decision": decision, + "packet": packet, + "review_trigger": trigger, + } + + +def make_institutional_kit(tmp_path: Path) -> dict[str, Any]: + """Issuer + status + manifest signer keys and trust stores.""" + issuer_keys = generate_test_keypair(tmp_path / "issuer") + status_keys = generate_test_keypair(tmp_path / "status") + manifest_keys = generate_test_keypair(tmp_path / "manifest") + + issuer_store = build_trust_store( + { + "keys": { + issuer_keys["public_key_ref"]: { + "public_key_pem": issuer_keys["public_key_pem"], + "public_key_ref": issuer_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_grant"], + }, + base_dir=tmp_path, + required_artifact_type="scope_grant", + ) + status_store = build_trust_store( + { + "keys": { + status_keys["public_key_ref"]: { + "public_key_pem": status_keys["public_key_pem"], + "public_key_ref": status_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant_status"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_grant_status"], + }, + base_dir=tmp_path, + required_artifact_type="scope_grant_status", + ) + manifest_store = build_trust_store( + { + "keys": { + manifest_keys["public_key_ref"]: { + "public_key_pem": manifest_keys["public_key_pem"], + "public_key_ref": manifest_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_authorization_manifest"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_authorization_manifest"], + }, + base_dir=tmp_path, + required_artifact_type="scope_authorization_manifest", + ) + manifest = build_test_authorization_manifest(signer_keys=manifest_keys) + return { + "issuer_keys": issuer_keys, + "status_keys": status_keys, + "manifest_keys": manifest_keys, + "issuer_store": issuer_store, + "status_store": status_store, + "manifest_store": manifest_store, + "manifest": manifest, + "trust_root": manifest["manifest_hash"], + } + + +def sign_status( + status: dict[str, Any], + status_keys: dict[str, str], +) -> dict[str, Any]: + payload = dict(status) + payload["issuer"] = { + "issuer_identity": "scope-status", + "issuer_key_id": status_keys["public_key_ref"], + } + if "attestation_hash" not in payload: + body = { + k: v + for k, v in payload.items() + if k + not in { + "attestation_hash", + "attestation_signature", + "signed_payload_hash", + } + } + payload["attestation_hash"] = hash_object(body) + payload["signed_payload_hash"] = payload["attestation_hash"] + priv = Path(status_keys["private_key_path"]).read_bytes() + payload["attestation_signature"] = sign_payload_hash_ed25519( + priv, payload["attestation_hash"] + ) + return payload + + +def make_signed_grant( + kit: dict[str, Any], + *, + artifacts: dict[str, Any] | None = None, + envelope: dict[str, Any] | None = None, + overrides: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """Return (grant, ledger_status, source_artifacts).""" + arts = artifacts or make_source_artifacts() + env = envelope or legacy_scope_to_envelope( + approved_scope="protocol_draft", + allowed_tools=["protocol_editor.draft_change"], + blocked_tools=["robot_queue.submit"], + target={"type": "protocol", "protocol_id": "protocol-v3"}, + environment={"tenant_id": "tenant-a"}, + cardinality={"mode": "single", "maximum_uses": 1, "maximum_items": 1}, + constraints={"protocol_version": "protocol-v3"}, + ) + grant: dict[str, Any] = { + "grant_id": "SCOPE-GRANT-INST-1", + "grant_version": "2.0.0", + "created_at": "2026-01-01T00:00:00Z", + "authorization": { + "approved_scope": "protocol_draft", + "allowed_tools": list(env["allowed_tools"]), + "blocked_tools": list(env["blocked_tools"]), + "authorization_envelope": env, + }, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), + "issuer": { + "issuer_identity": "scope-test", + "issuer_key_id": kit["issuer_keys"]["public_key_ref"], + }, + "source": { + "packet_id": arts["packet"]["packet_id"], + "packet_hash": arts["packet"]["packet_hash"], + "decision_id": arts["decision"]["decision_id"], + "decision_hash": arts["decision"]["decision_hash"], + "akta_record_id": arts["record"]["record_id"], + "akta_record_hash": arts["record"]["record_hash"], + "review_trigger_id": arts["review_trigger"]["review_trigger_id"], + "review_trigger_hash": arts["review_trigger"]["review_trigger_hash"], + }, + "provenance": {"scope_trust_root_hash": kit["trust_root"]}, + "constraints": {"single_use": True, "tenant_id": "tenant-a"}, + "expiration": { + "absolute_expiration": "2030-01-01T00:00:00Z", + "expires_after": [], + }, + "signature_algorithm": "Ed25519", + } + if overrides: + # Deep-merge common nested maps when provided. + for key, value in overrides.items(): + if ( + key in {"constraints", "expiration", "provenance", "source", "authorization"} + and isinstance(value, dict) + and isinstance(grant.get(key), dict) + ): + merged = dict(grant[key]) + merged.update(value) + grant[key] = merged + else: + grant[key] = value + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + priv = Path(kit["issuer_keys"]["private_key_path"]).read_bytes() + grant["grant_signature"] = sign_payload_hash_ed25519(priv, grant["grant_hash"]) + + status = { + "grant_id": grant["grant_id"], + "status": "active", + "checked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "cardinality_remaining": 1, + "grant_hash": grant["grant_hash"], + "scope_trust_root_hash": kit["trust_root"], + "tenant_id": "tenant-a", + "ledger_sequence": 1, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), + } + status = sign_status(status, kit["status_keys"]) + return grant, status, arts From 46af4adb30f794d85569f86f0d2e50165a564d7c Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:49:51 -0700 Subject: [PATCH 11/26] feat(verifier): harden scope grant verification lifecycle Enforce issuer trust, signatures, status, and request binding so only institutionally valid grants authorize actions. --- akta/scope_grant_verifier.py | 714 ++++++++++++++++++++++++----- tests/test_scope_grant_verifier.py | 112 +++-- 2 files changed, 662 insertions(+), 164 deletions(-) diff --git a/akta/scope_grant_verifier.py b/akta/scope_grant_verifier.py index 138fc9a..79b5d84 100644 --- a/akta/scope_grant_verifier.py +++ b/akta/scope_grant_verifier.py @@ -22,18 +22,67 @@ validate_capability_envelope, ) from akta.errors import GrantVerificationError +from akta.hash import hash_object +from akta.lifecycle_semantics import check_expires_after_bindings, status_lifecycle_errors from akta.policy_integrity import is_production_mode, require_signed_policy +from akta.scope_authorization_manifest import ( + resolve_and_verify_authorization_manifest, +) from akta.shared_contracts import ( ENVELOPE_VERSION, GRANT_STATUS_SCHEMA_NAME, + GRANT_V2_SCHEMA_NAME, _contracts, authorization_envelope_schema_hash, load_contract_schema, ) -from akta.trust_store import resolve_trust_store +from akta.trust_store import ( + ArtifactType, + institutional_registry_is_empty, + resolve_grant_issuer_trust_store, + resolve_manifest_signer_trust_store, + resolve_status_attestation_trust_store, +) STRICT_MODES = frozenset({"pilot", "release", "cross_repo", "production"}) UNIT_MODES = frozenset({"unit", "dev", "test"}) +SUPPORTED_SIGNATURE_ALGORITHMS = frozenset({"Ed25519", "ed25519"}) +# Strict modes accept only institutional grant v2 identifiers. +STRICT_GRANT_VERSIONS = frozenset({"2.0.0", "2.0", "2.1.0", "scope-grant-v2"}) +LEGACY_GRANT_VERSIONS = frozenset( + {"0.5", "0.6", "0.7", "0.8", "0.9", "1.0", "akta-scope-grant-v1.0"} +) +# Default max age for signed status attestations (seconds). +DEFAULT_MAX_STATUS_AGE_SECONDS = 300 + +# Source-chain hash field pairs: (id_field, hash_field, artifact_key) +SOURCE_HASH_BINDINGS: tuple[tuple[str, str, str], ...] = ( + ("akta_record_id", "akta_record_hash", "record"), + ("akta_record_id", "record_hash", "record"), + ("decision_id", "decision_hash", "decision"), + ("review_trigger_id", "review_trigger_hash", "review_trigger"), + ("packet_id", "packet_hash", "packet"), + ("scope_decision_id", "scope_decision_hash", "scope_decision"), + ("session_resolution_id", "session_resolution_hash", "session_resolution"), + ("session_id", "resolution_hash", "session_resolution"), +) + + +def _env_require_grant_crypto() -> bool: + return os.environ.get("AKTA_REQUIRE_GRANT_CRYPTO", "").lower() in ( + "1", + "true", + "yes", + ) + + +def _require_cryptography_for_strict() -> str | None: + """Fail closed when cryptography cannot be imported in crypto-required modes.""" + try: + import cryptography # noqa: F401 + except ImportError: + return "cryptography package required for strict SCOPE grant verification" + return None # Ordered authentication sequence (P0-C). Each check records its name in checks_run. REQUIRED_GRANT_CHECKS: tuple[str, ...] = ( @@ -73,6 +122,194 @@ class GrantVerificationResult: mode: str = "unit" checks_run: list[str] = field(default_factory=list) ledger_status: dict[str, Any] | None = None + authorization_manifest_hash: str | None = None + + +def _max_status_age_seconds() -> int: + raw = os.environ.get("AKTA_MAX_GRANT_STATUS_AGE_SECONDS", "").strip() + if raw: + try: + return max(0, int(raw)) + except ValueError: + return DEFAULT_MAX_STATUS_AGE_SECONDS + return DEFAULT_MAX_STATUS_AGE_SECONDS + + +def _grant_schema_gate_errors( + scope_grant: dict[str, Any], + *, + strict: bool, +) -> list[str]: + """Full scope-grant-v2 validation + version / structural gate (WS4).""" + errors: list[str] = [] + schema_version = ( + scope_grant.get("grant_version") + or scope_grant.get("schema_version") + or scope_grant.get("grant_schema_version") + or (scope_grant.get("authorization") or {}).get("schema_version") + ) + version_s = str(schema_version) if schema_version is not None else "" + + if strict: + if not version_s: + errors.append("grant_version missing") + elif version_s in LEGACY_GRANT_VERSIONS: + errors.append( + f"legacy grant version {version_s!r} rejected in strict mode; " + "use migrate-legacy-grant CLI" + ) + elif version_s not in STRICT_GRANT_VERSIONS: + errors.append(f"unknown grant schema version rejected: {version_s!r}") + + # Structural rejects required by institutional gate. + if not scope_grant.get("grant_signature"): + errors.append("grant_signature missing") + algo = scope_grant.get("signature_algorithm") or ( + (scope_grant.get("issuer") or {}).get("signature_algorithm") + ) + if not algo: + errors.append("signature_algorithm missing") + elif str(algo) not in SUPPORTED_SIGNATURE_ALGORITHMS: + errors.append(f"unsupported signature_algorithm: {algo!r}") + if extract_capability_envelope(scope_grant) is None: + errors.append("authorization_envelope missing") + source = scope_grant.get("source") + if not isinstance(source, dict) or not source: + errors.append("source bindings missing") + provenance = scope_grant.get("provenance") + if not isinstance(provenance, dict) or not provenance: + errors.append("provenance missing or malformed") + elif not provenance.get("scope_trust_root_hash"): + errors.append("provenance.scope_trust_root_hash missing") + + # Full JSON Schema validate against shared grant v2. + try: + import copy + + import jsonschema + + schema = load_contract_schema(GRANT_V2_SCHEMA_NAME) + # Envelope vocabulary is validated separately; rewrite nested $ref so + # grant-level required fields can be checked without resolver fragility. + schema = copy.deepcopy(schema) + try: + auth_props = ( + schema.get("properties", {}) + .get("authorization", {}) + .get("properties", {}) + ) + if isinstance(auth_props, dict) and "authorization_envelope" in auth_props: + auth_props["authorization_envelope"] = {"type": "object"} + except Exception: # noqa: BLE001 + pass + jsonschema.validate(instance=scope_grant, schema=schema) + except ImportError: + errors.append("jsonschema required for institutional grant schema gate") + except Exception as exc: # noqa: BLE001 + errors.append(f"scope-grant-v2 schema validation failed: {exc}") + else: + # Soft path: warn-equivalent errors returned as empty; callers use warnings. + if version_s and version_s not in STRICT_GRANT_VERSIONS | LEGACY_GRANT_VERSIONS: + errors.append(f"unrecognized grant schema_version: {version_s!r}") + return errors + + +def _artifact_id_and_hash( + artifact: Any, + *, + preferred_id_fields: tuple[str, ...] | None = None, +) -> tuple[str | None, str | None]: + if artifact is None: + return None, None + if isinstance(artifact, dict): + id_fields = preferred_id_fields or ( + "record_id", + "decision_id", + "review_trigger_id", + "trigger_id", + "packet_id", + "scope_decision_id", + "session_id", + "id", + ) + art_id = None + for field_name in id_fields: + if artifact.get(field_name) is not None: + art_id = artifact.get(field_name) + break + art_hash = ( + artifact.get("record_hash") + or artifact.get("decision_hash") + or artifact.get("packet_hash") + or artifact.get("review_trigger_hash") + or artifact.get("hash") + or artifact.get("artifact_hash") + or artifact.get("resolution_hash") + or artifact.get("scope_decision_hash") + ) + if art_hash is None and artifact.get("_authenticated_hash"): + art_hash = artifact.get("_authenticated_hash") + # Compute hash over body when raw artifact supplied without hash. + if art_hash is None and art_id is not None: + body = { + k: v + for k, v in artifact.items() + if not str(k).endswith("_hash") and k != "_authenticated_hash" + } + art_hash = hash_object(body) + return (str(art_id) if art_id is not None else None), ( + str(art_hash) if art_hash is not None else None + ) + return None, None + + +def migrate_legacy_grant_to_v2( + legacy_grant: dict[str, Any], + *, + grant_version: str = "2.0.0", +) -> dict[str, Any]: + """Explicit migration helper for legacy scalar grants (CLI / offline only).""" + from akta.capability_envelope import resolve_grant_envelope + + migrated = dict(legacy_grant) + migrated["grant_version"] = grant_version + envelope = resolve_grant_envelope( + migrated, require_envelope=False, allow_legacy_adapter=True + ) + auth = dict(migrated.get("authorization") or {}) + auth["authorization_envelope"] = envelope + auth.setdefault("allowed_tools", list(envelope.get("allowed_tools") or [])) + auth.setdefault("blocked_tools", list(envelope.get("blocked_tools") or [])) + migrated["authorization"] = auth + migrated["authorization_schema_version"] = ENVELOPE_VERSION + migrated["authorization_schema_hash"] = authorization_envelope_schema_hash() + if "created_at" not in migrated: + migrated["created_at"] = datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + if "constraints" not in migrated: + migrated["constraints"] = {} + if "expiration" not in migrated: + migrated["expiration"] = { + "absolute_expiration": None, + "expires_after": [], + } + if "provenance" not in migrated: + migrated["provenance"] = {} + if "source" not in migrated: + migrated["source"] = { + "packet_id": "migrated-unknown", + "decision_id": "migrated-unknown", + "akta_record_id": "migrated-unknown", + } + if "issuer" not in migrated: + migrated["issuer"] = { + "issuer_identity": "migration-unspecified", + "issuer_key_id": "migration-unspecified", + } + if "grant_id" not in migrated: + migrated["grant_id"] = migrated.get("scope_grant_id") or "migrated-grant" + return migrated def resolve_grant_verification_mode(explicit: str | None = None) -> str: @@ -154,7 +391,14 @@ def _issuer_signature_error( trust_store: dict[str, Any] | None, require_crypto: bool, ) -> str | None: - """Verify issuer signature; fail closed when crypto trust store is required.""" + """Verify issuer signature; fail closed when crypto trust store is required. + + Strict / crypto-required modes enforce the full checklist: nonempty issuer + registry, approved key, grant-issuance artifact permission, active status, + validity window, supported algorithm, valid signature, matching signed + payload hash, and valid final grant hash. Structural signature presence + alone never passes when ``require_crypto`` is set. + """ sig = scope_grant.get("grant_signature") if not sig: return "unsigned grant: grant_signature missing" @@ -162,26 +406,72 @@ def _issuer_signature_error( key_id = issuer.get("issuer_key_id") if not key_id: return "issuer.issuer_key_id missing" + + algo = scope_grant.get("signature_algorithm") or issuer.get("signature_algorithm") + if require_crypto and algo and str(algo) not in SUPPORTED_SIGNATURE_ALGORITHMS: + return f"unsupported signature_algorithm: {algo!r}" + if require_crypto and not algo: + # Default expected algorithm when crypto is mandatory. + algo = "Ed25519" + + if "grant_hash" not in scope_grant: + return "grant_hash missing" + if not _contracts().verify_hash(scope_grant, "grant_hash"): + return "grant_hash mismatch (invalid or tampered grant payload)" + signed_payload = scope_grant.get("signed_payload_hash") - if signed_payload is not None and signed_payload != scope_grant.get("grant_hash"): + if signed_payload is None: + if require_crypto: + return "signed_payload_hash missing" + elif signed_payload != scope_grant.get("grant_hash"): return "signed_payload_hash must equal grant_hash" + if trust_store is None: if require_crypto: return "trust store required for issuer signature verification" - # Structural presence only when no cryptographic trust store configured. + # Structural presence only when crypto is not required (unit/dev). return None + revoked = set(trust_store.get("revoked_key_ids") or []) if key_id in revoked: return f"revoked issuer key: {key_id}" keys = trust_store.get("keys") or {} - if require_crypto and not keys: + if require_crypto and (not keys or institutional_registry_is_empty(trust_store)): return "trust store has no approved issuer keys" entry = keys.get(key_id) if entry is None: return f"unknown issuer key: {key_id}" + status = str(entry.get("status") or "active").lower() - if status == "revoked": - return f"revoked issuer key: {key_id}" + if status != "active": + return f"issuer key not active (status={status}): {key_id}" + + entry_algo = entry.get("algorithm") + if entry_algo and str(entry_algo) not in SUPPORTED_SIGNATURE_ALGORITHMS: + return f"issuer key uses unsupported algorithm: {entry_algo!r}" + if require_crypto and algo and entry_algo and str(algo).lower() != str(entry_algo).lower(): + return "grant signature_algorithm does not match issuer registry algorithm" + + allowed_types = entry.get("_authorized_artifact_types") or set( + entry.get("authorized_artifact_types") or [] + ) + required_type = ( + trust_store.get("required_artifact_type") or ArtifactType.SCOPE_GRANT.value + ) + if require_crypto and allowed_types and required_type not in allowed_types: + return ( + f"issuer key {key_id} not authorized for artifact type {required_type!r}" + ) + + # Validity window (fail closed in crypto modes; also enforced in verify callback). + from akta.trust_store import entry_within_validity + + if require_crypto and not entry_within_validity(entry): + return f"issuer key outside validity window: {key_id}" + + if require_crypto and entry.get("_public_key") is None: + return f"issuer key missing loadable public material: {key_id}" + verifier: Callable[[dict[str, Any], str, str], bool] | None = trust_store.get("verify") if callable(verifier): try: @@ -249,33 +539,116 @@ def _ledger_status_errors( strict: bool, trust_store: dict[str, Any] | None = None, require_crypto: bool = False, + expected_tenant_id: str | None = None, + seen_sequences: set[int] | None = None, ) -> list[str]: + """Validate authoritative grant lifecycle status (WS6). + + Strict modes require a status payload; absence → ``grant_status_unavailable``. + """ errors: list[str] = [] if ledger_status is None: - if strict and os.environ.get("SCOPE_GRANT_STATUS_URL"): - errors.append("grant ledger status attestation unavailable") + if strict: + errors.append("grant_status_unavailable") + elif os.environ.get("SCOPE_GRANT_STATUS_URL"): + errors.append("grant_status_unavailable") return errors + + # Prefer status-authoritative lifecycle semantics. + errors.extend( + status_lifecycle_errors( + ledger_status, + prefer_status_over_context=True, + context_metadata={}, + ) + ) + status = str(ledger_status.get("status") or "") - if status in {"revoked", "consumed", "expired", "superseded", "invalid", "not_found", "used"}: - errors.append(f"grant ledger status={status}") - if ledger_status.get("revoked") is True: - errors.append("grant revoked per attestation") remaining = ledger_status.get("single_use_remaining") if remaining is None: remaining = ledger_status.get("cardinality_remaining") constraints = scope_grant.get("constraints") or {} if constraints.get("single_use") and remaining is not None and int(remaining) <= 0: errors.append("single-use grant already consumed") - # Attestation verification for remote / provided status payloads + + # Max age / freshness + checked_at = ledger_status.get("checked_at") + if checked_at: + try: + checked_dt = _parse_iso(str(checked_at)) + age = (datetime.now(timezone.utc) - checked_dt).total_seconds() + max_age = _max_status_age_seconds() + if age > max_age: + errors.append( + f"grant status attestation stale " + f"(age={int(age)}s > max={max_age}s)" + ) + except (ValueError, TypeError): + errors.append(f"grant status checked_at invalid: {checked_at!r}") + elif strict: + errors.append("grant status checked_at missing") + + # Tenant binding + status_tenant = ledger_status.get("tenant_id") or ( + (ledger_status.get("environment") or {}).get("tenant_id") + ) + grant_tenant = ( + expected_tenant_id + or (scope_grant.get("constraints") or {}).get("tenant_id") + or ((scope_grant.get("authorization") or {}).get("authorization_envelope") or {}) + .get("environment", {}) + .get("tenant_id") + ) + if grant_tenant and status_tenant and str(grant_tenant) != str(status_tenant): + errors.append( + f"grant status tenant mismatch ({status_tenant!r} != {grant_tenant!r})" + ) + + # Sequence replay protection + seq = ledger_status.get("ledger_sequence") + if seq is None: + seq = ledger_status.get("sequence") + if seq is not None: + try: + seq_i = int(seq) + except (TypeError, ValueError): + errors.append(f"grant status ledger_sequence invalid: {seq!r}") + else: + if seen_sequences is not None and seq_i in seen_sequences: + errors.append(f"grant status ledger_sequence replay detected: {seq_i}") + prev = scope_grant.get("_last_seen_ledger_sequence") + if prev is not None: + try: + if seq_i <= int(prev): + errors.append( + f"grant status ledger_sequence replay/out-of-order: {seq_i}" + ) + except (TypeError, ValueError): + pass + + # Attestation verification for remote / provided / strict status payloads remote = bool(os.environ.get("SCOPE_GRANT_STATUS_URL")) - if strict and (remote or ledger_status.get("attestation_signature") is not None): + has_sig = ledger_status.get("attestation_signature") is not None + if strict or remote or has_sig: expected_hash = scope_grant.get("grant_hash") if expected_hash and ledger_status.get("grant_hash") not in (None, expected_hash): errors.append("grant status grant_hash does not match grant") + elif strict and expected_hash and "grant_hash" not in ledger_status: + errors.append("grant status missing grant_hash binding") trust = (scope_grant.get("provenance") or {}).get("scope_trust_root_hash") if trust and ledger_status.get("scope_trust_root_hash") not in (None, trust): errors.append("grant status trust-root mismatch") - # Schema shape (shared contract) + elif strict and trust and "scope_trust_root_hash" not in ledger_status: + errors.append("grant status missing scope_trust_root_hash binding") + + signer = ( + (ledger_status.get("issuer") or {}).get("issuer_key_id") + or ledger_status.get("attestation_key_id") + or ledger_status.get("issuer_key_id") + ) + if strict and not signer: + errors.append("grant status attestation signer missing") + try: import jsonschema @@ -288,10 +661,12 @@ def _ledger_status_errors( sig_err = _attestation_signature_error( ledger_status, trust_store=trust_store, - require_crypto=require_crypto or remote, + require_crypto=require_crypto or remote or strict, ) if sig_err: errors.append(sig_err) + if "unknown" in sig_err.lower(): + errors.append("grant status unknown signer") return errors @@ -301,7 +676,10 @@ def _source_binding_errors( record: dict[str, Any] | None, trigger: dict[str, Any] | None, expected_packet_id: str | None, + source_artifacts: dict[str, Any] | None = None, + strict: bool = False, ) -> list[str]: + """Verify source ID bindings and (in strict) ID+hash chain (WS7).""" errors: list[str] = [] source = scope_grant.get("source") or {} if not source.get("packet_id"): @@ -323,9 +701,80 @@ def _source_binding_errors( f"({source.get('packet_id')} != {expected_packet_id})" ) if trigger is not None: - packet_id = trigger.get("packet_id") or (trigger.get("source") or {}).get("packet_id") + packet_id = trigger.get("packet_id") or (trigger.get("source") or {}).get( + "packet_id" + ) if packet_id and source.get("packet_id") not in (None, packet_id): errors.append("grant packet_id does not match review trigger/packet") + + artifacts = dict(source_artifacts or {}) + if record is not None and "record" not in artifacts: + artifacts["record"] = record + if trigger is not None and "review_trigger" not in artifacts: + artifacts["review_trigger"] = trigger + + # Strict: require hash fields on grant source + verify against artifacts/refs. + required_hash_fields = ( + "akta_record_hash", + "decision_hash", + "packet_hash", + ) + if strict: + missing_hashes = [f for f in required_hash_fields if not source.get(f)] + # Accept record_hash alias for akta_record_hash. + if "akta_record_hash" in missing_hashes and source.get("record_hash"): + missing_hashes = [f for f in missing_hashes if f != "akta_record_hash"] + if missing_hashes: + errors.append( + "missing source hashes: " + ", ".join(missing_hashes) + ) + if not artifacts: + errors.append( + "source artifacts or authenticated refs required to verify source hashes" + ) + + seen_pairs: set[tuple[str, str]] = set() + for id_field, hash_field, artifact_key in SOURCE_HASH_BINDINGS: + source_id = source.get(id_field) + source_hash = source.get(hash_field) + if source_id is None and source_hash is None: + continue + if source_hash is None: + continue + artifact = artifacts.get(artifact_key) + if artifact is None: + if strict and hash_field in required_hash_fields + ("record_hash",): + # Already covered by missing artifacts / hashes messages. + continue + continue + art_id, art_hash = _artifact_id_and_hash( + artifact, + preferred_id_fields=(id_field, "id"), + ) + if art_id is not None and source_id is not None and str(art_id) != str(source_id): + errors.append( + f"source {id_field} mismatch vs artifact " + f"({source_id} != {art_id})" + ) + if art_hash is not None and str(art_hash) != str(source_hash): + errors.append( + f"source {hash_field} mismatch vs artifact " + f"({source_hash} != {art_hash})" + ) + pair = (str(source_id), str(source_hash)) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + + # Same tool/envelope across different records must fail hash binding. + if record is not None and source.get("akta_record_id"): + rec_hash = source.get("akta_record_hash") or source.get("record_hash") + actual_hash = record.get("record_hash") + if rec_hash and actual_hash and str(rec_hash) != str(actual_hash): + errors.append( + "source record hash binding failed for different AKTA record " + "(same tool/envelope cannot cross records)" + ) return errors @@ -334,71 +783,19 @@ def _bound_context_errors( *, context: dict[str, Any] | None, strict: bool, + ledger_status: dict[str, Any] | None = None, ) -> list[str]: - """P0-E: missing consequential bound context → abstain (error in strict).""" - constraints = scope_grant.get("constraints") or {} - expiration = scope_grant.get("expiration") or {} - expires_after = list(expiration.get("expires_after") or []) - if isinstance(expiration.get("expires_on"), str): - expires_after.append(expiration["expires_on"]) - - consequential = { - "protocol_version_change": "protocol_version", - "evidence_state_change": "evidence_state", - "validation_status_change": "validation_status", - "policy_version_change": "policy_manifest_hash", - "model_version_change": "model_version", - "tool_registry_change": "tool_registry_hash", - "domain_overlay_change": "domain_overlay_hash", - "safety_incident": "safety_incident_state", - } - errors: list[str] = [] - ctx = context or {} - metadata = dict(ctx.get("metadata") or {}) - - required_keys = { - "protocol_version", - "evidence_state", - "validation_status", - "policy_manifest_hash", - "domain_overlay_hash", - "model_version", - "tool_registry_hash", - "grant_use_count", - "safety_incident_state", - } - # Always require presence of keys listed in expires_after bindings. - for event in expires_after: - key = consequential.get(str(event)) - if key: - required_keys.add(key) - + """P0-E / WS10: typed expires_after comparators vs AKTA scientific state.""" if not strict: - return errors - - for key in sorted(required_keys): - bound = ( - constraints.get(key) - or metadata.get(f"bound_{key}") - or metadata.get(key) - or ctx.get(key) - ) - # Do not infer missing == bound. - if bound is None and key in { - "protocol_version", - "evidence_state", - "policy_manifest_hash", - "tool_registry_hash", - }: - # Soft-required core keys when grant declares them in constraints. - if key in constraints or any( - consequential.get(str(e)) == key for e in expires_after - ): - errors.append( - f"missing consequential grant context {key!r}; " - "abstain / renewed review required (never infer missing == bound)" - ) - return errors + return [] + result = check_expires_after_bindings( + scope_grant, + context=context, + ledger_status=ledger_status, + ) + # Status-authoritative errors are reported via _ledger_status_errors; avoid dupes. + status_msgs = set(status_lifecycle_errors(ledger_status)) + return [e for e in result.errors if e not in status_msgs] def _tools_fabricated_error(scope_grant: dict[str, Any], envelope: dict[str, Any]) -> str | None: @@ -427,14 +824,16 @@ def verify_scope_grant( expected_packet_id: str | None = None, trust_store: dict[str, Any] | None = None, ledger_status: dict[str, Any] | None = None, + authorization_manifest: dict[str, Any] | None = None, + source_artifacts: dict[str, Any] | None = None, + status_trust_store: dict[str, Any] | None = None, + manifest_trust_store: dict[str, Any] | None = None, raise_on_error: bool = False, ) -> GrantVerificationResult: - """Authenticate a SCOPE grant before apply (full P0-C sequence).""" + """Authenticate a SCOPE grant before apply (full P0-C / AKTA 1.1 WS4–7).""" resolved_mode = resolve_grant_verification_mode(mode) strict = resolved_mode in STRICT_MODES - require_crypto = resolved_mode == "production" or os.environ.get( - "AKTA_REQUIRE_GRANT_CRYPTO", "" - ).lower() in ("1", "true", "yes") + require_crypto = (resolved_mode in STRICT_MODES) or _env_require_grant_crypto() require_envelope = ( require_capability_envelope if require_capability_envelope is not None @@ -444,6 +843,7 @@ def verify_scope_grant( errors: list[str] = [] warnings: list[str] = [] checks_run: list[str] = [] + verified_manifest_hash: str | None = None def _fail(msg: str, *, as_warning: bool = False) -> None: if as_warning: @@ -451,20 +851,52 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: else: errors.append(msg) + # --- WS4: institutional grant schema gate (first) --- + checks_run.append("schema_version_supported") + schema_errors = _grant_schema_gate_errors(scope_grant, strict=strict) + for err in schema_errors: + if strict: + _fail(err) + else: + # Soft warnings in unit/dev for unrecognized versions only. + if "unrecognized" in err or "legacy" in err: + warnings.append(err) + else: + warnings.append(err) + + if require_crypto: + crypto_err = _require_cryptography_for_strict() + if crypto_err: + _fail(crypto_err) + resolved_trust = trust_store if resolved_trust is None and ( require_crypto or os.environ.get("AKTA_TRUST_STORE_PATH") or os.environ.get("AKTA_ISSUER_KEY_REGISTRY") + or os.environ.get("AKTA_GRANT_ISSUER_REGISTRY") + ): + resolved_trust = resolve_grant_issuer_trust_store(require_keys=require_crypto) + if require_crypto and ( + resolved_trust is None + or not (resolved_trust.get("keys") or {}) + or institutional_registry_is_empty(resolved_trust) ): - resolved_trust = resolve_trust_store(require_keys=require_crypto) - if require_crypto and (resolved_trust is None or not (resolved_trust.get("keys") or {})): - _fail("production/institutional trust store required (empty or missing issuer registry)") - if require_crypto and resolved_trust is not None: - from akta.trust_store import institutional_registry_is_empty + _fail( + "production/institutional trust store required " + "(empty or missing issuer registry)" + ) - if institutional_registry_is_empty(resolved_trust): - _fail("production/institutional trust store required (empty or missing issuer registry)") + # Status attestation uses a separate trust domain when populated. + status_trust = status_trust_store + if status_trust is None: + status_trust = resolve_status_attestation_trust_store(require_keys=False) + if status_trust is None or institutional_registry_is_empty(status_trust): + status_trust = resolved_trust + + manifest_trust = manifest_trust_store + if manifest_trust is None: + manifest_trust = resolve_manifest_signer_trust_store(require_keys=False) # 1. grant_id checks_run.append("grant_id_present") @@ -501,7 +933,9 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: checks_run.append("envelope_schema_hash") declared_hash = ( scope_grant.get("authorization_schema_hash") - or (scope_grant.get("authorization") or {}).get("authorization_schema_hash") + or (scope_grant.get("authorization") or {}).get( + "authorization_schema_hash" + ) ) expected_hash = authorization_envelope_schema_hash() if declared_hash and declared_hash != expected_hash: @@ -512,7 +946,6 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: checks_run.append("envelope_vocabulary") env_errors = validate_capability_envelope(envelope) if env_errors: - # Unknown family/operation are hard failures. for err in env_errors: _fail(err) except GrantVerificationError as exc: @@ -540,7 +973,7 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: if sig_err: _fail(sig_err, as_warning=not strict) - # 11–12. source bindings + # 11–12. source bindings (+ hashes in strict) checks_run.append("source_packet_binding") checks_run.append("source_akta_record_binding") for err in _source_binding_errors( @@ -548,17 +981,40 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: record=record, trigger=trigger, expected_packet_id=expected_packet_id, + source_artifacts=source_artifacts, + strict=strict, ): _fail(err, as_warning=not strict) - # 13. trust root + # 13. trust root + WS5 signed authorization manifest checks_run.append("trust_root_present") provenance = scope_grant.get("provenance") or {} - if not provenance.get("scope_trust_root_hash"): + trust_root = provenance.get("scope_trust_root_hash") + if not trust_root: _fail("provenance.scope_trust_root_hash missing", as_warning=not strict) - - # 14. policy manifest freshness hint checks_run.append("policy_manifest_fresh") + if trust_root and (strict or authorization_manifest is not None): + embedded = authorization_manifest or provenance.get( + "authorization_manifest" + ) + if isinstance(embedded, dict) or strict: + manifest_result = resolve_and_verify_authorization_manifest( + expected_trust_root_hash=str(trust_root), + embedded_manifest=embedded if isinstance(embedded, dict) else None, + trust_store=manifest_trust, + require_crypto=require_crypto or strict, + ) + if not manifest_result.ok: + for err in manifest_result.errors: + _fail(err, as_warning=not strict) + else: + verified_manifest_hash = manifest_result.manifest_hash + if verified_manifest_hash != trust_root: + _fail( + "grant scope_trust_root_hash does not match verified " + "authorization manifest hash" + ) + # AKTA effective-policy freshness vs grant binding (independent of SCOPE manifest). if context is not None: current_manifest = (context.get("metadata") or {}).get("policy_manifest_hash") grant_manifest = ( @@ -569,18 +1025,33 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: if current_manifest and grant_manifest and current_manifest != grant_manifest: _fail("stale policy manifest relative to grant binding") - # 15–16. ledger / single-use + # 15–16. ledger / single-use (WS6 mandatory in strict) checks_run.append("ledger_status_active") checks_run.append("single_use_remaining") status_payload = ledger_status if status_payload is None and grant_id: + # Authenticated SCOPE client / remote attestation. status_payload = _fetch_grant_status_attestation(str(grant_id)) + # Embedded authoritative ledger on grant (explicit institutional path). + if status_payload is None: + embedded_ledger = ( + scope_grant.get("ledger_status") + or provenance.get("ledger_status") + or (scope_grant.get("constraints") or {}).get("ledger_status") + ) + if isinstance(embedded_ledger, dict): + status_payload = embedded_ledger for err in _ledger_status_errors( scope_grant, ledger_status=status_payload, strict=strict, - trust_store=resolved_trust, + trust_store=status_trust, require_crypto=require_crypto, + expected_tenant_id=( + (context or {}).get("tenant_id") + if isinstance(context, dict) + else None + ), ): _fail(err) @@ -616,37 +1087,19 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: if not auth_blocked.issubset(env_blocked) and not env_blocked.issubset( auth_blocked ): - # Prefer envelope as authority; warn/fail if disjoint fabrication. if auth_blocked - env_blocked: _fail("blocked_tools fabrication vs envelope") - # 20. bound context (P0-E) + # 20. bound context (P0-E / WS10) checks_run.append("bound_context_present") - for err in _bound_context_errors(scope_grant, context=context, strict=strict): + for err in _bound_context_errors( + scope_grant, + context=context, + strict=strict, + ledger_status=status_payload, + ): _fail(err) - checks_run.append("schema_version_supported") - schema_version = ( - scope_grant.get("grant_version") - or scope_grant.get("schema_version") - or scope_grant.get("grant_schema_version") - or (scope_grant.get("authorization") or {}).get("schema_version") - ) - supported = { - "2.0.0", - "2.0", - "scope-grant-v2", - "0.5", - "0.6", - "0.7", - "0.8", - "0.9", - "1.0", - "akta-scope-grant-v1.0", - } - if schema_version and str(schema_version) not in supported: - warnings.append(f"unrecognized grant schema_version: {schema_version!r}") - # Scalar narrowing rank comparison intentionally removed from production paths. # Envelope set-inclusion is the sole authorization comparator in strict modes. @@ -658,6 +1111,7 @@ def _fail(msg: str, *, as_warning: bool = False) -> None: mode=resolved_mode, checks_run=checks_run, ledger_status=status_payload, + authorization_manifest_hash=verified_manifest_hash, ) if raise_on_error and not result.ok: raise GrantVerificationError( @@ -678,6 +1132,10 @@ def verify_scope_grant_or_raise( expected_packet_id: str | None = None, trust_store: dict[str, Any] | None = None, ledger_status: dict[str, Any] | None = None, + authorization_manifest: dict[str, Any] | None = None, + source_artifacts: dict[str, Any] | None = None, + status_trust_store: dict[str, Any] | None = None, + manifest_trust_store: dict[str, Any] | None = None, ) -> GrantVerificationResult: return verify_scope_grant( scope_grant, @@ -689,5 +1147,9 @@ def verify_scope_grant_or_raise( expected_packet_id=expected_packet_id, trust_store=trust_store, ledger_status=ledger_status, + authorization_manifest=authorization_manifest, + source_artifacts=source_artifacts, + status_trust_store=status_trust_store, + manifest_trust_store=manifest_trust_store, raise_on_error=True, ) diff --git a/tests/test_scope_grant_verifier.py b/tests/test_scope_grant_verifier.py index 0c6b5bc..893ede0 100644 --- a/tests/test_scope_grant_verifier.py +++ b/tests/test_scope_grant_verifier.py @@ -64,7 +64,10 @@ def test_release_mode_rejects_missing_allowed_tools() -> None: "grant_id": "SCOPE-GRANT-2", "authorization": {"approved_scope": "protocol_draft"}, } - with pytest.raises(GrantVerificationError, match="authorization_envelope|allowed_tools"): + with pytest.raises( + GrantVerificationError, + match="authorization_envelope|allowed_tools|trust store", + ): verify_scope_grant_or_raise(grant, mode="release") @@ -76,32 +79,75 @@ def test_release_mode_rejects_scalar_only_grant() -> None: "allowed_tools": ["protocol_editor.draft_change"], }, } - with pytest.raises(GrantVerificationError, match="authorization_envelope"): + with pytest.raises( + GrantVerificationError, + match="authorization_envelope|trust store", + ): verify_scope_grant_or_raise(grant, mode="pilot") -def test_production_env_rejects_simulated(monkeypatch: pytest.MonkeyPatch) -> None: +def test_production_env_rejects_simulated( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from akta.trust_store import ( + build_trust_store, + generate_test_keypair, + sign_payload_hash_ed25519, + ) + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + keys = generate_test_keypair(tmp_path / "keys") + store = build_trust_store( + { + "keys": { + keys["public_key_ref"]: { + "public_key_pem": keys["public_key_pem"], + "public_key_ref": keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_grant"], + }, + base_dir=tmp_path, + required_artifact_type="scope_grant", + ) + envelope = _shared_envelope() grant = { "grant_id": "SCOPE-GRANT-SIM", "authorization": { "approved_scope": "protocol_draft", "allowed_tools": [], - "authorization_envelope": _shared_envelope(), + "authorization_envelope": envelope, }, "adapter_mode": "simulated", - "grant_hash": "sha256:" + ("a" * 64), - "issuer": {"issuer_identity": "test", "issuer_key_id": "k1"}, - "grant_signature": "sig", + "issuer": { + "issuer_identity": "test", + "issuer_key_id": keys["public_key_ref"], + }, "source": { "packet_id": "p1", "decision_id": "d1", "akta_record_id": "r1", }, "provenance": {"scope_trust_root_hash": "sha256:" + ("b" * 64)}, + "constraints": {}, + "expiration": {"absolute_expiration": "2030-01-01T00:00:00Z", "expires_after": []}, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), } + from akta.shared_contracts import _contracts + + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + priv = Path(keys["private_key_path"]).read_bytes() + grant["grant_signature"] = sign_payload_hash_ed25519(priv, grant["grant_hash"]) + grant["signature_algorithm"] = "Ed25519" with pytest.raises(GrantVerificationError, match="simulated"): - verify_scope_grant_or_raise(grant) + verify_scope_grant_or_raise(grant, trust_store=store) + def test_evaluate_with_grant_fail_closed_missing_tools(gate: AKTAGate) -> None: @@ -110,7 +156,10 @@ def test_evaluate_with_grant_fail_closed_missing_tools(gate: AKTAGate) -> None: "authorization": {"approved_scope": "protocol_draft"}, "expires_at": "2030-01-01T00:00:00Z", } - with pytest.raises(GrantVerificationError, match="authorization_envelope|allowed_tools"): + with pytest.raises( + GrantVerificationError, + match="authorization_envelope|allowed_tools|trust store", + ): gate.evaluate_with_grant( ai_output={"summary": "Draft"}, requested_tool="protocol_editor.draft_change", @@ -203,35 +252,22 @@ def test_require_envelope_rejects_scalar_only() -> None: verify_scope_grant_or_raise(grant, mode="unit", require_capability_envelope=True) -def test_explicit_envelope_accepted() -> None: - from akta.shared_contracts import _contracts - - envelope = _shared_envelope() - grant = { - "grant_id": "SCOPE-GRANT-CE", - "grant_version": "2.0.0", - "created_at": "2026-01-01T00:00:00Z", - "authorization": { - "approved_scope": "protocol_draft", - "allowed_tools": envelope["allowed_tools"], - "blocked_tools": envelope["blocked_tools"], - "authorization_envelope": envelope, - }, - "authorization_schema_version": ENVELOPE_VERSION, - "authorization_schema_hash": authorization_envelope_schema_hash(), - "issuer": {"issuer_identity": "scope-test", "issuer_key_id": "k-test"}, - "grant_signature": "test-sig", - "source": { - "packet_id": "pkt-1", - "decision_id": "dec-1", - "akta_record_id": "rec-1", - }, - "provenance": {"scope_trust_root_hash": "sha256:" + ("d" * 64)}, - "constraints": {}, - "expiration": {"absolute_expiration": "2030-01-01T00:00:00Z", "expires_after": []}, - } - grant = _contracts().attach_hash(grant, "grant_hash") - result = verify_scope_grant_or_raise(grant, mode="release", require_capability_envelope=True) +def test_explicit_envelope_accepted(tmp_path: Path) -> None: + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant(kit) + result = verify_scope_grant_or_raise( + grant, + mode="release", + require_capability_envelope=True, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) assert result.ok, result.errors assert result.capability_envelope["family"] == "protocol" assert "family" in result.capability_envelope From 2bf77b77a43c6f903727b970e022d84bcb28b4c3 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:50:06 -0700 Subject: [PATCH 12/26] feat(negotiation): tighten scope contract and peer checks Require consistent scope contract fields and stricter negotiation so peers cannot silently widen authority. --- akta/scope_contract.py | 9 +++++++ akta/scope_negotiation.py | 33 ++++++++++++++++++++++++++ tests/test_scope_negotiation_strict.py | 15 +++++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/akta/scope_contract.py b/akta/scope_contract.py index dd48975..3d65976 100644 --- a/akta/scope_contract.py +++ b/akta/scope_contract.py @@ -43,6 +43,15 @@ "akta-scope-contract-v2.0", "akta-scope-contract-v1.0", }), + "scope-core-v2.1": frozenset({ + "akta-scope-contract-v2.0", + }), + "2.1.0": frozenset({ + "akta-scope-contract-v2.0", + }), + "2.1.1": frozenset({ + "akta-scope-contract-v2.0", + }), } # v0.2 review_scope vocabulary -> v0.3 requested_scope (compat for SCOPE simulator). diff --git a/akta/scope_negotiation.py b/akta/scope_negotiation.py index 0778a3c..0a5d2a2 100644 --- a/akta/scope_negotiation.py +++ b/akta/scope_negotiation.py @@ -200,6 +200,17 @@ def negotiate_scope_contract( ) exact = manifest.get("strict_exact_versions") or {} + legacy_ids = { + str(x) + for x in ( + manifest.get("legacy_ids_rejected_in_strict") + or [ + "akta-capability-envelope-v1.0", + "akta-scope-grant-v1.0", + "akta-scope-contract-v1.0", + ] + ) + } for key, expected in exact.items(): # Strict: compare only peer-declared values, never local fallback. actual = peer.get(key) @@ -209,6 +220,8 @@ def negotiate_scope_contract( actual = peer.get("capability_envelope_version") or peer.get( "authorization_schema_version" ) + if actual is None and key == "grant_status_schema_version": + actual = peer.get("grant_status_schema_version") if actual is None: if not strict: # Non-strict may consult local for warnings only. @@ -217,15 +230,35 @@ def negotiate_scope_contract( warnings.append(f"missing contract version for {key}") continue continue # already recorded as missing peer key + if strict and str(actual) in legacy_ids: + errors.append( + f"strict rejects legacy contract id for {key}: {actual!r} " + "(delete former envelope/grant IDs from peer negotiation)" + ) + continue if strict and str(actual) != str(expected): errors.append(f"strict mismatch {key}: got {actual!r}, expected {expected!r}") + # Reject legacy IDs anywhere in peer declarations under strict mode. + if strict: + for key, value in peer.items(): + if str(value) in legacy_ids: + errors.append( + f"strict rejects legacy peer value {key}={value!r}" + ) + peer_pkg = peer.get("scope_package_version") allowed_pkgs = (manifest.get("compatible_scope_packages") or {}).get("scope-protocol") or [] if peer_pkg and allowed_pkgs and peer_pkg not in allowed_pkgs: msg = f"scope package {peer_pkg!r} not in compatible set {allowed_pkgs}" (errors if strict else warnings).append(msg) + peer_core = peer.get("scope_core_version") + allowed_cores = (manifest.get("compatible_scope_packages") or {}).get("scope-core") or [] + if peer_core and allowed_cores and peer_core not in allowed_cores: + msg = f"scope core {peer_core!r} not in compatible set {allowed_cores}" + (errors if strict else warnings).append(msg) + peer_policy = peer.get("scope_policy_version") or peer.get("policy_manifest_version") allowed_policies = manifest.get("compatible_scope_policy_versions") or [] if peer_policy and allowed_policies and peer_policy not in allowed_policies: diff --git a/tests/test_scope_negotiation_strict.py b/tests/test_scope_negotiation_strict.py index b0105b8..e9694a9 100644 --- a/tests/test_scope_negotiation_strict.py +++ b/tests/test_scope_negotiation_strict.py @@ -9,16 +9,25 @@ def _full_peer() -> dict[str, str]: return { - "scope_package_version": "2.0.0", - "scope_core_version": "scope-core-v2.0", + "scope_package_version": "2.1.0", + "scope_core_version": "scope-core-v2.1", "akta_review_contract_version": "scope-akta-review-v1.0", "grant_schema_version": "scope-grant-v2", "envelope_schema_version": "scientific-authorization-envelope-v2", "summary_schema_version": "scope-akta-review-summary-v1.0", - "policy_manifest_version": "scope-policy-v2.0", + "policy_manifest_version": "scope-policy-v2.1", } +def test_strict_rejects_legacy_envelope_ids() -> None: + peer = _full_peer() + peer["envelope_schema_version"] = "akta-capability-envelope-v1.0" + peer["grant_schema_version"] = "akta-scope-grant-v1.0" + result = negotiate_scope_contract(peer_versions=peer, strict=True) + assert not result.ok + assert any("legacy" in e for e in result.errors) + + def test_strict_rejects_missing_peer_versions() -> None: result = negotiate_scope_contract(peer_versions={}, strict=True) assert not result.ok From da223f943d2f641643839bba03514fac8943f955 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:50:26 -0700 Subject: [PATCH 13/26] feat(lifecycle): codify grant status and forgery semantics Make revoked, expired, and forged grants fail with explicit lifecycle outcomes instead of ambiguous denials. --- akta/lifecycle_semantics.py | 351 ++++++++++++++++++++++++++ tests/test_grant_forgery_lifecycle.py | 198 ++++++++++----- tests/test_lifecycle_semantics.py | 58 +++++ 3 files changed, 540 insertions(+), 67 deletions(-) create mode 100644 akta/lifecycle_semantics.py create mode 100644 tests/test_lifecycle_semantics.py diff --git a/akta/lifecycle_semantics.py b/akta/lifecycle_semantics.py new file mode 100644 index 0000000..51ede29 --- /dev/null +++ b/akta/lifecycle_semantics.py @@ -0,0 +1,351 @@ +"""Lifecycle context semantics (AKTA 1.1 WS10). + +Signed grant status is authoritative for remaining uses, revocation, +supersession, reviewer withdrawal, and SCOPE expiration events. +AKTA context remains authoritative for scientific/protocol/evidence/ +validation/model/tool-registry/policy/overlay/safety state. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable + +from akta.evaluation_types import EvaluationLayer + +# Events whose truth comes from signed status / SCOPE ledger — never caller metadata. +STATUS_AUTHORITATIVE_EVENTS = frozenset( + { + "grant_revoked", + "grant_consumed", + "grant_superseded", + "reviewer_withdrawal", + "scope_expiration", + "cardinality_exhausted", + "single_use_consumed", + } +) + +# Events compared against AKTA scientific / deployment context. +AKTA_CONTEXT_EVENTS = frozenset( + { + "protocol_version_change", + "evidence_state_change", + "validation_status_change", + "policy_version_change", + "model_version_change", + "tool_registry_change", + "domain_overlay_change", + "safety_incident", + } +) + +EVIDENCE_RANK_PREFIX = "E" + + +def _parse_iso(value: Any) -> datetime | None: + if value is None or value == "": + return None + text = str(value).replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _evidence_rank(value: Any) -> int | None: + text = str(value or "") + if text.startswith(EVIDENCE_RANK_PREFIX) and len(text) > 1 and text[1].isdigit(): + digits = "" + for ch in text[1:]: + if ch.isdigit(): + digits += ch + else: + break + return int(digits) if digits else None + return None + + +def compare_exact(bound: Any, current: Any) -> bool: + """True when bound and current are present and equal as strings.""" + if bound is None or current is None: + return False + return str(bound) == str(current) + + +def compare_evidence_rank_non_decreasing(bound: Any, current: Any) -> bool: + """Evidence may stay or improve; downgrade invalidates the grant.""" + b = _evidence_rank(bound) + c = _evidence_rank(current) + if b is None or c is None: + return compare_exact(bound, current) + return c >= b + + +def compare_hash_identity(bound: Any, current: Any) -> bool: + return compare_exact(bound, current) + + +def compare_safety_incident(bound: Any, current: Any) -> bool: + """Any new safety incident relative to bound 'clear' state invalidates.""" + if bound is None or current is None: + return False + bound_s = str(bound).lower() + current_s = str(current).lower() + if bound_s in {"clear", "none", "ok", "false", "0"}: + return current_s in {"clear", "none", "ok", "false", "0"} + return bound_s == current_s + + +EXPIRES_AFTER_COMPARATORS: dict[str, Callable[[Any, Any], bool]] = { + "protocol_version_change": compare_exact, + "evidence_state_change": compare_evidence_rank_non_decreasing, + "validation_status_change": compare_exact, + "policy_version_change": compare_hash_identity, + "model_version_change": compare_exact, + "tool_registry_change": compare_hash_identity, + "domain_overlay_change": compare_hash_identity, + "safety_incident": compare_safety_incident, +} + + +@dataclass +class LifecycleCheckResult: + ok: bool + errors: list[str] = field(default_factory=list) + renewed_review_required: bool = False + reasons: list[str] = field(default_factory=list) + + def to_evaluation_layer(self) -> EvaluationLayer | None: + if self.ok: + return None + reason = "; ".join(self.reasons or self.errors) or "grant lifecycle invalid" + return EvaluationLayer( + source="lifecycle_semantics", + decision="review_required", + reason=reason, + ) + + +def extract_status_lifecycle(ledger_status: dict[str, Any] | None) -> dict[str, Any]: + """Normalize status-authoritative lifecycle fields.""" + if not ledger_status: + return {} + remaining = ledger_status.get("cardinality_remaining") + if remaining is None: + remaining = ledger_status.get("single_use_remaining") + return { + "status": ledger_status.get("status"), + "cardinality_remaining": remaining, + "uses_consumed": ledger_status.get("uses_consumed"), + "revoked": ledger_status.get("revoked") is True + or str(ledger_status.get("status") or "") == "revoked", + "superseded": str(ledger_status.get("status") or "") == "superseded", + "reviewer_withdrawal": bool( + ledger_status.get("reviewer_withdrawal") + or ledger_status.get("withdrawn_by_reviewer") + ), + "scope_expires_at": ledger_status.get("scope_expires_at") + or ledger_status.get("expires_at"), + "revocation_reason": ledger_status.get("revocation_reason"), + "ledger_sequence": ledger_status.get("ledger_sequence") + or ledger_status.get("sequence"), + } + + +def status_lifecycle_errors( + ledger_status: dict[str, Any] | None, + *, + prefer_status_over_context: bool = True, + context_metadata: dict[str, Any] | None = None, +) -> list[str]: + """Errors from status-authoritative lifecycle fields. + + When status is present, caller metadata such as ``grant_use_count`` is ignored + for remaining-use / revocation / supersession / withdrawal decisions. + """ + errors: list[str] = [] + life = extract_status_lifecycle(ledger_status) + if not life: + return errors + + status = str(life.get("status") or "") + if status in { + "revoked", + "consumed", + "expired", + "superseded", + "invalid", + "not_found", + "used", + }: + errors.append(f"status-authoritative lifecycle status={status}") + if life.get("revoked"): + errors.append("grant revoked per signed status") + if life.get("superseded"): + errors.append("grant superseded per signed status") + if life.get("reviewer_withdrawal"): + errors.append("grant withdrawn by reviewer per signed status") + + remaining = life.get("cardinality_remaining") + if remaining is not None and int(remaining) <= 0: + errors.append("no remaining uses per signed status") + + scope_exp = life.get("scope_expires_at") + if scope_exp: + exp_dt = _parse_iso(scope_exp) + if exp_dt is not None and exp_dt <= datetime.now(timezone.utc): + errors.append(f"SCOPE expiration reached per signed status ({scope_exp})") + + if prefer_status_over_context and context_metadata: + # Explicitly do not trust caller grant_use_count when status speaks. + _ = context_metadata.get("grant_use_count") + return errors + + +def resolve_bound_and_current( + event: str, + *, + scope_grant: dict[str, Any], + context: dict[str, Any] | None, +) -> tuple[str, Any, Any]: + """Return (label, bound_value, current_value) for an expires_after event.""" + constraints = scope_grant.get("constraints") or {} + ctx = context or {} + metadata = dict(ctx.get("metadata") or {}) + + mapping = { + "protocol_version_change": ( + "protocol_version", + constraints.get("protocol_version") + or metadata.get("bound_protocol_version"), + ctx.get("protocol_version") + or ctx.get("active_protocol_id") + or (ctx.get("scientific_context") or {}).get("protocol_version"), + ), + "evidence_state_change": ( + "evidence_state", + constraints.get("evidence_state") or metadata.get("bound_evidence_state"), + ctx.get("evidence_state"), + ), + "validation_status_change": ( + "validation_status", + constraints.get("validation_status") + or metadata.get("bound_validation_status"), + ctx.get("validation_status") or metadata.get("validation_status"), + ), + "policy_version_change": ( + "policy_manifest_hash", + constraints.get("policy_manifest_hash") + or metadata.get("bound_policy_manifest_hash"), + metadata.get("policy_manifest_hash"), + ), + "model_version_change": ( + "model_version", + constraints.get("model_version") or metadata.get("bound_model_version"), + ctx.get("model_version") or metadata.get("model_version"), + ), + "tool_registry_change": ( + "tool_registry_hash", + constraints.get("tool_registry_hash") + or constraints.get("tool_registry_version") + or metadata.get("bound_tool_registry_hash"), + metadata.get("tool_registry_hash"), + ), + "domain_overlay_change": ( + "domain_overlay_hash", + constraints.get("domain_overlay") + or constraints.get("domain_overlay_hash") + or metadata.get("bound_domain_overlay_hash"), + ctx.get("domain_overlay") or metadata.get("domain_overlay_hash"), + ), + "safety_incident": ( + "safety_incident_state", + constraints.get("safety_incident_state") + or metadata.get("bound_safety_incident_state"), + metadata.get("safety_incident_state"), + ), + } + return mapping.get(str(event), (str(event), None, None)) + + +def check_expires_after_bindings( + scope_grant: dict[str, Any], + *, + context: dict[str, Any] | None, + ledger_status: dict[str, Any] | None = None, +) -> LifecycleCheckResult: + """Evaluate every expires_after event with typed comparators. + + Missing bound or current value → renewed review / abstain (never infer equality). + Status-authoritative events are evaluated from signed status only. + """ + errors: list[str] = [] + reasons: list[str] = [] + renewed = False + + expiration = scope_grant.get("expiration") or {} + expires_after = list(expiration.get("expires_after") or []) + if isinstance(expiration.get("expires_on"), str): + expires_after.append(expiration["expires_on"]) + + # Status-authoritative lifecycle first. + status_errs = status_lifecycle_errors( + ledger_status, + prefer_status_over_context=True, + context_metadata=dict((context or {}).get("metadata") or {}), + ) + errors.extend(status_errs) + reasons.extend(status_errs) + + for event in expires_after: + event_s = str(event) + if event_s in STATUS_AUTHORITATIVE_EVENTS: + # Already covered by status_lifecycle_errors when status present. + if ledger_status is None: + renewed = True + msg = ( + f"missing signed status for status-authoritative event {event_s!r}; " + "renewed review required" + ) + errors.append(msg) + reasons.append(msg) + continue + + if event_s not in AKTA_CONTEXT_EVENTS and event_s not in EXPIRES_AFTER_COMPARATORS: + continue + + label, bound, current = resolve_bound_and_current( + event_s, scope_grant=scope_grant, context=context + ) + if bound is None or current is None: + renewed = True + msg = ( + f"missing consequential grant context {label!r} for expiration " + f"event {event_s!r}; abstain / renewed review required " + "(never infer missing == bound)" + ) + errors.append(msg) + reasons.append(msg) + continue + + comparator = EXPIRES_AFTER_COMPARATORS.get(event_s, compare_exact) + if not comparator(bound, current): + renewed = True + msg = ( + f"Grant bound state changed for {label} ({bound} -> {current}); " + "SCOPE grant requires renewed review." + ) + errors.append(msg) + reasons.append(msg) + + return LifecycleCheckResult( + ok=not errors, + errors=errors, + renewed_review_required=renewed or bool(errors), + reasons=reasons, + ) diff --git a/tests/test_grant_forgery_lifecycle.py b/tests/test_grant_forgery_lifecycle.py index 219fae2..99cacdd 100644 --- a/tests/test_grant_forgery_lifecycle.py +++ b/tests/test_grant_forgery_lifecycle.py @@ -75,12 +75,17 @@ def trust_store(issuer_keys: dict[str, str], tmp_path: Path) -> dict: "public_key_pem": issuer_keys["public_key_pem"], "public_key_ref": issuer_keys["public_key_ref"], "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant", "scope_grant_status"], } }, "revoked_key_ids": [], "source": str(tmp_path), + "default_artifact_types": ["scope_grant"], } - return build_trust_store(registry, base_dir=tmp_path) + return build_trust_store( + registry, base_dir=tmp_path, required_artifact_type="scope_grant" + ) def _sign_grant(grant: dict, issuer_keys: dict[str, str]) -> dict: @@ -114,6 +119,9 @@ def test_invalid_grant_hash_fails(issuer_keys: dict[str, str], trust_store: dict def test_unknown_issuer_fails(issuer_keys: dict[str, str], trust_store: dict) -> None: grant = _sign_grant(_unsigned_base_grant(), issuer_keys) grant["issuer"]["issuer_key_id"] = "sha256:" + ("f" * 64) + # Recompute hash after issuer mutation so the failure is unknown-key, not hash. + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] with pytest.raises(GrantVerificationError, match="unknown issuer"): verify_scope_grant_or_raise(grant, mode="pilot", trust_store=trust_store) @@ -125,12 +133,17 @@ def test_revoked_issuer_key_fails(issuer_keys: dict[str, str], tmp_path: Path) - "public_key_pem": issuer_keys["public_key_pem"], "public_key_ref": issuer_keys["public_key_ref"], "status": "revoked", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant"], } }, "revoked_key_ids": [issuer_keys["public_key_ref"]], "source": str(tmp_path), + "default_artifact_types": ["scope_grant"], } - store = build_trust_store(registry, base_dir=tmp_path) + store = build_trust_store( + registry, base_dir=tmp_path, required_artifact_type="scope_grant" + ) grant = _sign_grant(_unsigned_base_grant(), issuer_keys) with pytest.raises(GrantVerificationError, match="revoked issuer"): verify_scope_grant_or_raise(grant, mode="pilot", trust_store=store) @@ -158,115 +171,166 @@ def test_wrong_packet_fails(issuer_keys: dict[str, str], trust_store: dict) -> N ) -def test_stale_policy_manifest_fails(issuer_keys: dict[str, str], trust_store: dict) -> None: - grant = _sign_grant(_unsigned_base_grant(), issuer_keys) +def test_stale_policy_manifest_fails(tmp_path: Path) -> None: + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant( + kit, + overrides={ + "constraints": { + "single_use": True, + "policy_manifest_hash": "sha256:" + ("e" * 64), + "tenant_id": "tenant-a", + } + }, + ) with pytest.raises(GrantVerificationError, match="stale policy manifest"): verify_scope_grant_or_raise( grant, mode="pilot", - trust_store=trust_store, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context={"metadata": {"policy_manifest_hash": "sha256:" + ("a" * 64)}}, ) -def test_consumed_single_use_fails(issuer_keys: dict[str, str], trust_store: dict) -> None: - grant = _sign_grant(_unsigned_base_grant(), issuer_keys) - with pytest.raises(GrantVerificationError, match="single-use|consumed"): +def test_consumed_single_use_fails(tmp_path: Path) -> None: + from datetime import datetime, timezone + + from tests.helpers.institutional_grant import ( + make_institutional_kit, + make_signed_grant, + sign_status, + ) + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant(kit) + status["cardinality_remaining"] = 0 + status["single_use_remaining"] = 0 + status["checked_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + status = sign_status(status, kit["status_keys"]) + with pytest.raises(GrantVerificationError, match="single-use|consumed|remaining"): verify_scope_grant_or_raise( grant, mode="pilot", - trust_store=trust_store, - ledger_status={ - "grant_id": grant["grant_id"], - "status": "active", - "checked_at": "2026-01-02T00:00:00Z", - "cardinality_remaining": 0, - "single_use_remaining": 0, - "grant_hash": grant["grant_hash"], - }, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], ) -def test_revoked_grant_fails(issuer_keys: dict[str, str], trust_store: dict) -> None: - grant = _sign_grant(_unsigned_base_grant(), issuer_keys) +def test_revoked_grant_fails(tmp_path: Path) -> None: + from datetime import datetime, timezone + + from tests.helpers.institutional_grant import ( + make_institutional_kit, + make_signed_grant, + sign_status, + ) + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant(kit) + status["status"] = "revoked" + status["revoked"] = True + status["cardinality_remaining"] = 0 + status["checked_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + status = sign_status(status, kit["status_keys"]) with pytest.raises(GrantVerificationError, match="revoked"): verify_scope_grant_or_raise( grant, mode="pilot", - trust_store=trust_store, - ledger_status={ - "grant_id": grant["grant_id"], - "status": "revoked", - "checked_at": "2026-01-02T00:00:00Z", - "cardinality_remaining": 0, - "revoked": True, - "grant_hash": grant["grant_hash"], - }, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], ) -def test_bound_context_protocol_and_model_required( - issuer_keys: dict[str, str], trust_store: dict -) -> None: - grant = _sign_grant(_unsigned_base_grant(), issuer_keys) +def test_bound_context_protocol_and_model_required(tmp_path: Path) -> None: + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant( + kit, + overrides={ + "expiration": { + "absolute_expiration": "2030-01-01T00:00:00Z", + "expires_after": ["protocol_version_change", "model_version_change"], + }, + "constraints": {"single_use": True, "tenant_id": "tenant-a"}, + }, + ) with pytest.raises(GrantVerificationError, match="missing consequential grant context"): verify_scope_grant_or_raise( grant, mode="pilot", - trust_store=trust_store, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context={"metadata": {}}, ) -def test_fabricated_allowed_tools_fails(issuer_keys: dict[str, str], trust_store: dict) -> None: - grant = _sign_grant(_unsigned_base_grant(), issuer_keys) +def test_fabricated_allowed_tools_fails(tmp_path: Path) -> None: + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant + + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant(kit) grant["authorization"]["allowed_tools"] = ["totally.fabricated.tool"] grant = _contracts().attach_hash(grant, "grant_hash") grant["signed_payload_hash"] = grant["grant_hash"] - priv = Path(issuer_keys["private_key_path"]).read_bytes() + priv = Path(kit["issuer_keys"]["private_key_path"]).read_bytes() grant["grant_signature"] = sign_payload_hash_ed25519(priv, grant["grant_hash"]) with pytest.raises(GrantVerificationError, match="fabricated allowed_tools"): - verify_scope_grant_or_raise(grant, mode="pilot", trust_store=trust_store) + verify_scope_grant_or_raise( + grant, + mode="pilot", + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + +def test_valid_signed_grant_with_attestation(tmp_path: Path) -> None: + from tests.helpers.institutional_grant import make_institutional_kit, make_signed_grant -def test_valid_signed_grant_with_attestation( - issuer_keys: dict[str, str], trust_store: dict -) -> None: - grant = _sign_grant( - _unsigned_base_grant( - constraints={ + kit = make_institutional_kit(tmp_path) + grant, status, arts = make_signed_grant( + kit, + overrides={ + "constraints": { "single_use": True, "policy_manifest_hash": "sha256:" + ("e" * 64), "protocol_version": "proto-1", + "tenant_id": "tenant-a", } - ), - issuer_keys, - ) - attestation = { - "grant_id": grant["grant_id"], - "status": "active", - "checked_at": "2026-01-02T00:00:00Z", - "cardinality_remaining": 1, - "single_use_remaining": 1, - "grant_hash": grant["grant_hash"], - "scope_trust_root_hash": grant["provenance"]["scope_trust_root_hash"], - "attestation_key_id": issuer_keys["public_key_ref"], - "issuer": { - "issuer_identity": "scope-test", - "issuer_key_id": issuer_keys["public_key_ref"], }, - } - attestation = _contracts().attach_hash(attestation, "attestation_hash") - attestation["signed_payload_hash"] = attestation["attestation_hash"] - priv = Path(issuer_keys["private_key_path"]).read_bytes() - attestation["attestation_signature"] = sign_payload_hash_ed25519( - priv, attestation["attestation_hash"] ) result = verify_scope_grant_or_raise( grant, mode="pilot", - trust_store=trust_store, - ledger_status=attestation, + trust_store=kit["issuer_store"], + ledger_status=status, + authorization_manifest=kit["manifest"], + source_artifacts=arts, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], context={ "metadata": { "policy_manifest_hash": "sha256:" + ("e" * 64), @@ -274,7 +338,7 @@ def test_valid_signed_grant_with_attestation( "bound_protocol_version": "proto-1", } }, - record={"record_id": "rec-1"}, + record=arts["record"], ) assert result.ok, result.errors diff --git a/tests/test_lifecycle_semantics.py b/tests/test_lifecycle_semantics.py new file mode 100644 index 0000000..5e0bd3b --- /dev/null +++ b/tests/test_lifecycle_semantics.py @@ -0,0 +1,58 @@ +"""AKTA 1.1 WS10: lifecycle context semantics.""" + +from __future__ import annotations + +from akta.lifecycle_semantics import ( + check_expires_after_bindings, + compare_evidence_rank_non_decreasing, + status_lifecycle_errors, +) + + +def test_status_authoritative_revocation() -> None: + errors = status_lifecycle_errors( + {"status": "revoked", "cardinality_remaining": 1, "revoked": True}, + context_metadata={"grant_use_count": 0}, + ) + assert any("revok" in e for e in errors) + + +def test_status_authoritative_remaining_uses_ignore_caller_count() -> None: + errors = status_lifecycle_errors( + {"status": "active", "cardinality_remaining": 0}, + context_metadata={"grant_use_count": 0}, + ) + assert any("remaining uses" in e for e in errors) + + +def test_evidence_comparator_allows_improvement() -> None: + assert compare_evidence_rank_non_decreasing("E2_weak", "E4_strong") + assert not compare_evidence_rank_non_decreasing("E4_strong", "E1_anecdotal") + + +def test_expires_after_missing_bound_requires_renewed_review() -> None: + grant = { + "constraints": {}, + "expiration": {"expires_after": ["protocol_version_change"]}, + } + result = check_expires_after_bindings( + grant, + context={"protocol_version": "v2"}, + ledger_status={"status": "active", "cardinality_remaining": 1}, + ) + assert not result.ok + assert result.renewed_review_required + + +def test_expires_after_protocol_change() -> None: + grant = { + "constraints": {"protocol_version": "v1"}, + "expiration": {"expires_after": ["protocol_version_change"]}, + } + result = check_expires_after_bindings( + grant, + context={"protocol_version": "v2"}, + ledger_status={"status": "active", "cardinality_remaining": 1}, + ) + assert not result.ok + assert any("Protocol" in r or "protocol" in r.lower() for r in result.reasons) From 53dc1e5bfb7db6048dfe0a1f95d8f76bd848a2e2 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:50:40 -0700 Subject: [PATCH 14/26] feat(evidence): keep admissibility evidence grant-independent Separate evidence evaluation from grant coverage so authorization cannot substitute for scientific evidence checks. --- akta/evidence_independence.py | 339 ++++++++++++++++++++++++++++ tests/test_evidence_independence.py | 63 ++++++ 2 files changed, 402 insertions(+) create mode 100644 akta/evidence_independence.py create mode 100644 tests/test_evidence_independence.py diff --git a/akta/evidence_independence.py b/akta/evidence_independence.py new file mode 100644 index 0000000..80355f6 --- /dev/null +++ b/akta/evidence_independence.py @@ -0,0 +1,339 @@ +"""Evidence independence invariants (AKTA 1.1 WS9). + +SCOPE grants may satisfy human_review / authorization requirements. +They must never satisfy empirical evidence, convert weak evidence into strong +evidence, erase evidence-based blocks, or override deployment-profile / +domain-overlay / unknown-tool / unsupported-autonomous blocks. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from akta.grant_override import ( + GRANT_SATISFIABLE_LAYERS, + compute_grant_layer_satisfaction, + grant_may_satisfy_evidence, +) + +# Decision provenance layers recorded on every grant-aware decision. +DECISION_PROVENANCE_LAYERS = ( + "evidence", + "deployment_profile", + "domain_overlay", + "authorization", + "tool", + "final", +) + +# Outcomes a SCOPE grant must never upgrade away from. +PROTECTED_BLOCK_DECISIONS = frozenset( + { + "blocked", + "abstain_insufficient_context", + "review_required", + } +) + +PROTECTED_BLOCK_REASONS = frozenset( + { + "evidence", + "evidence_block", + "abstain_missing_evidence", + "deployment_profile", + "deployment-profile", + "domain_overlay", + "domain-overlay", + "unknown_consequential_tool", + "unknown_tool", + "unsupported_autonomous_profile", + "unsupported_profile", + } +) + + +@dataclass(frozen=True) +class LayerProvenance: + """One layer of the composed Decision provenance stack.""" + + layer: str + decision: str | None + reason: str | None = None + satisfied_by_grant: bool = False + source: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class DecisionLayerProvenance: + """Layered Decision provenance for evidence-independent composition.""" + + evidence: LayerProvenance + deployment_profile: LayerProvenance + domain_overlay: LayerProvenance + authorization: LayerProvenance + tool: LayerProvenance + final: LayerProvenance + evidence_never_satisfied_by_grant: bool = True + + def to_dict(self) -> dict[str, Any]: + return { + "evidence": self.evidence.to_dict(), + "deployment_profile": self.deployment_profile.to_dict(), + "domain_overlay": self.domain_overlay.to_dict(), + "authorization": self.authorization.to_dict(), + "tool": self.tool.to_dict(), + "final": self.final.to_dict(), + "evidence_never_satisfied_by_grant": self.evidence_never_satisfied_by_grant, + } + + +@dataclass +class EvidenceIndependenceResult: + ok: bool + violations: list[str] = field(default_factory=list) + layers: DecisionLayerProvenance | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "violations": list(self.violations), + "layers": self.layers.to_dict() if self.layers else None, + } + + +def assert_grant_cannot_satisfy_evidence() -> None: + """Hard invariant: grant_may_satisfy_evidence is always False.""" + if grant_may_satisfy_evidence(): + raise AssertionError("SCOPE grant must never satisfy the evidence layer") + + +def _layer_mentions_evidence(text: str | None) -> bool: + if not text: + return False + lowered = text.lower() + return any( + token in lowered + for token in ( + "evidence", + "e1_", + "e2_", + "e3_", + "insufficient evidence", + "missing evidence", + ) + ) + + +def _is_evidence_block(decision: str | None, reason: str | None, source: str | None) -> bool: + if decision in ("blocked", "abstain_insufficient_context") and _layer_mentions_evidence( + reason + ): + return True + if source and "evidence" in source.lower(): + return True + if reason: + for token in PROTECTED_BLOCK_REASONS: + if token.replace("-", "_") in reason.lower().replace("-", "_"): + if "evidence" in token or "evidence" in reason.lower(): + return True + return False + + +def grant_must_not_upgrade_block( + *, + pre_grant_decision: str | None, + post_grant_decision: str | None, + pre_grant_reason: str | None = None, + block_kind: str, +) -> str | None: + """Return violation message when a grant upgrades a protected block to allowed.""" + if pre_grant_decision not in PROTECTED_BLOCK_DECISIONS: + return None + if post_grant_decision in ( + "allowed", + "allowed_with_logging", + "draft_only", + ): + return ( + f"SCOPE grant must not upgrade {block_kind} decision " + f"{pre_grant_decision!r} to {post_grant_decision!r}" + ) + if block_kind in {"evidence", "abstain_missing_evidence"} and _is_evidence_block( + pre_grant_decision, pre_grant_reason, None + ): + if post_grant_decision != pre_grant_decision and post_grant_decision in ( + "allowed", + "allowed_with_logging", + "draft_only", + ): + return "SCOPE grant must not clear an evidence-based block" + return None + + +def build_decision_layer_provenance( + *, + evidence_decision: str | None = None, + evidence_reason: str | None = None, + deployment_profile_decision: str | None = None, + deployment_profile_reason: str | None = None, + domain_overlay_decision: str | None = None, + domain_overlay_reason: str | None = None, + authorization_decision: str | None = None, + authorization_reason: str | None = None, + tool_decision: str | None = None, + tool_reason: str | None = None, + final_decision: str | None = None, + final_reason: str | None = None, + scope_grant: dict[str, Any] | None = None, + authorization_satisfied_by_grant: bool = False, +) -> DecisionLayerProvenance: + """Assemble layered Decision provenance; evidence never satisfied by grant.""" + assert_grant_cannot_satisfy_evidence() + auth_by_grant = bool(authorization_satisfied_by_grant and scope_grant is not None) + return DecisionLayerProvenance( + evidence=LayerProvenance( + layer="evidence", + decision=evidence_decision, + reason=evidence_reason, + satisfied_by_grant=False, + source="evidence_rules", + ), + deployment_profile=LayerProvenance( + layer="deployment_profile", + decision=deployment_profile_decision, + reason=deployment_profile_reason, + satisfied_by_grant=False, + source="deployment_profiles", + ), + domain_overlay=LayerProvenance( + layer="domain_overlay", + decision=domain_overlay_decision, + reason=domain_overlay_reason, + satisfied_by_grant=False, + source="domain_overlay", + ), + authorization=LayerProvenance( + layer="authorization", + decision=authorization_decision, + reason=authorization_reason, + satisfied_by_grant=auth_by_grant, + source="scope_grant" if auth_by_grant else "authorization", + ), + tool=LayerProvenance( + layer="tool", + decision=tool_decision, + reason=tool_reason, + satisfied_by_grant=False, + source="tool_registry", + ), + final=LayerProvenance( + layer="final", + decision=final_decision, + reason=final_reason, + satisfied_by_grant=False, + source="composed", + ), + evidence_never_satisfied_by_grant=True, + ) + + +def check_evidence_independence( + *, + scope_grant: dict[str, Any] | None, + pre_layers: dict[str, dict[str, Any]] | None = None, + post_decision: str | None = None, + post_reason: str | None = None, + final_layers: DecisionLayerProvenance | None = None, +) -> EvidenceIndependenceResult: + """Verify a signed/valid grant cannot erase protected admissibility layers.""" + violations: list[str] = [] + assert_grant_cannot_satisfy_evidence() + + if scope_grant is not None: + satisfaction = compute_grant_layer_satisfaction(scope_grant) + for layer in satisfaction.get("satisfied_layers") or []: + name = layer.get("layer") + if name not in GRANT_SATISFIABLE_LAYERS: + violations.append( + f"grant must not satisfy non-authorization layer {name!r}" + ) + if name == "evidence": + violations.append("grant must never list evidence under satisfied_layers") + if not satisfaction.get("evidence_never_satisfied_by_grant", False): + violations.append("evidence_never_satisfied_by_grant must be True") + + pre = pre_layers or {} + for kind in ( + "evidence", + "abstain_missing_evidence", + "deployment_profile", + "domain_overlay", + "unknown_consequential_tool", + "unsupported_autonomous_profile", + ): + layer = pre.get(kind) or {} + pre_decision = layer.get("decision") + violation = grant_must_not_upgrade_block( + pre_grant_decision=pre_decision, + post_grant_decision=post_decision, + pre_grant_reason=layer.get("reason"), + block_kind=kind, + ) + if violation: + violations.append(violation) + + layers = final_layers + if layers is None and scope_grant is not None: + layers = build_decision_layer_provenance( + evidence_decision=(pre.get("evidence") or {}).get("decision"), + evidence_reason=(pre.get("evidence") or {}).get("reason"), + deployment_profile_decision=(pre.get("deployment_profile") or {}).get( + "decision" + ), + domain_overlay_decision=(pre.get("domain_overlay") or {}).get("decision"), + authorization_decision="authorization_satisfied" + if scope_grant + else None, + tool_decision=(pre.get("unknown_consequential_tool") or {}).get("decision"), + final_decision=post_decision, + final_reason=post_reason, + scope_grant=scope_grant, + authorization_satisfied_by_grant=True, + ) + if layers is not None and layers.evidence.satisfied_by_grant: + violations.append("evidence layer marked satisfied_by_grant") + if layers is not None and not layers.evidence_never_satisfied_by_grant: + violations.append("layered provenance missing evidence_never_satisfied_by_grant") + + return EvidenceIndependenceResult( + ok=not violations, violations=violations, layers=layers + ) + + +def attach_layer_provenance_to_decision( + decision: dict[str, Any], + provenance: DecisionLayerProvenance, +) -> dict[str, Any]: + """Record layered provenance on a Decision dict (non-destructive copy).""" + out = dict(decision) + out["decision_layer_provenance"] = provenance.to_dict() + # Preserve legacy grant_layer_satisfaction shape when present. + satisfaction = dict(out.get("grant_layer_satisfaction") or {}) + satisfaction["evidence_never_satisfied_by_grant"] = True + satisfaction["decision_layers"] = { + name: provenance.to_dict()[name] + for name in ( + "evidence", + "deployment_profile", + "domain_overlay", + "authorization", + "tool", + "final", + ) + } + out["grant_layer_satisfaction"] = satisfaction + return out diff --git a/tests/test_evidence_independence.py b/tests/test_evidence_independence.py new file mode 100644 index 0000000..2a84fa3 --- /dev/null +++ b/tests/test_evidence_independence.py @@ -0,0 +1,63 @@ +"""AKTA 1.1 WS9: evidence independence property-style tests.""" + +from __future__ import annotations + +import pytest + +from akta.evidence_independence import ( + check_evidence_independence, + grant_must_not_upgrade_block, +) +from akta.grant_override import grant_may_satisfy_evidence + + +PROTECTED_BLOCKS = ( + ("evidence", "blocked", "weak evidence block"), + ("abstain_missing_evidence", "abstain_insufficient_context", "missing evidence"), + ("deployment_profile", "blocked", "deployment profile forbids action"), + ("domain_overlay", "blocked", "domain overlay block"), + ("unknown_consequential_tool", "blocked", "unknown consequential tool"), + ("unsupported_autonomous_profile", "blocked", "unsupported autonomous profile"), +) + + +@pytest.mark.parametrize("kind,decision,reason", PROTECTED_BLOCKS) +def test_grant_cannot_upgrade_protected_block( + kind: str, decision: str, reason: str +) -> None: + grant = {"grant_id": "G1", "authorization": {"approved_scope": "protocol_draft"}} + result = check_evidence_independence( + scope_grant=grant, + pre_layers={kind: {"decision": decision, "reason": reason}}, + post_decision="allowed", + post_reason="cleared by grant", + ) + assert not result.ok + assert result.violations + + +@pytest.mark.parametrize("kind,decision,reason", PROTECTED_BLOCKS) +def test_property_grant_must_not_upgrade( + kind: str, decision: str, reason: str +) -> None: + assert grant_may_satisfy_evidence() is False + violation = grant_must_not_upgrade_block( + pre_grant_decision=decision, + post_grant_decision="allowed", + pre_grant_reason=reason, + block_kind=kind, + ) + assert violation is not None + + +def test_layered_provenance_never_marks_evidence_satisfied() -> None: + grant = {"grant_id": "G2"} + result = check_evidence_independence( + scope_grant=grant, + pre_layers={"evidence": {"decision": "blocked", "reason": "evidence"}}, + post_decision="blocked", + ) + assert result.ok + assert result.layers is not None + assert result.layers.evidence.satisfied_by_grant is False + assert result.layers.evidence_never_satisfied_by_grant is True From 8a452b6945746532e1ef42bfe95a38445f82b6ed Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:50:48 -0700 Subject: [PATCH 15/26] feat(review): wire grant checks into review and gate paths Ensure review decisions and gate outcomes respect institutional grants without collapsing evidence into authorization. --- akta/gate.py | 67 +++++++++++++++- akta/records.py | 21 ++++- akta/review_decision.py | 73 ++++++++++++++++- akta/review_loop.py | 170 ++++++++++++++++++---------------------- 4 files changed, 232 insertions(+), 99 deletions(-) diff --git a/akta/gate.py b/akta/gate.py index a19b18a..11e7e11 100644 --- a/akta/gate.py +++ b/akta/gate.py @@ -242,6 +242,11 @@ def evaluate_with_grant( require_capability_envelope: bool | None = None, trust_store: dict[str, Any] | None = None, ledger_status: dict[str, Any] | None = None, + authorization_manifest: dict[str, Any] | None = None, + source_artifacts: dict[str, Any] | None = None, + action_class: str | None = None, + status_trust_store: dict[str, Any] | None = None, + manifest_trust_store: dict[str, Any] | None = None, ) -> AKTADecision: """Re-gate after SCOPE grant or review decision with expiry enforcement (F14). @@ -253,6 +258,11 @@ def evaluate_with_grant( enforce_grant_expiry, ) from akta.review_loop import apply_grant_decision_constraints, prepare_grant_context + from akta.evidence_independence import ( + attach_layer_provenance_to_decision, + build_decision_layer_provenance, + check_evidence_independence, + ) ctx_dict: dict[str, Any] if isinstance(context, AKTAContext): @@ -260,6 +270,16 @@ def evaluate_with_grant( else: ctx_dict = dict(context or {}) + # Capture pre-grant admissibility from prior record/decision when present. + pre_grant_adm = None + if isinstance(record, dict): + pre_grant_adm = (record.get("decision") or {}).get("admissibility") + if pre_grant_adm: + meta0 = dict(ctx_dict.get("metadata") or {}) + meta0.setdefault("pre_grant_admissibility", pre_grant_adm) + meta0.setdefault("initial_admissibility", pre_grant_adm) + ctx_dict["metadata"] = meta0 + grant_state: Any = None if review_decision is not None: ctx_dict = apply_review_decision_to_context(ctx_dict, review_decision) @@ -274,6 +294,13 @@ def evaluate_with_grant( require_capability_envelope=require_capability_envelope, trust_store=trust_store, ledger_status=ledger_status, + authorization_manifest=authorization_manifest, + source_artifacts=source_artifacts, + requested_tool=requested_tool, + requested_action=requested_action, + action_class=action_class, + status_trust_store=status_trust_store, + manifest_trust_store=manifest_trust_store, ) ctx_dict = grant_state.context @@ -294,7 +321,45 @@ def evaluate_with_grant( grant_state, requested_tool=requested_tool, ) - decision = AKTADecision(updated) + # WS9: layered Decision provenance + evidence independence check. + data = dict(updated) + provenance = build_decision_layer_provenance( + evidence_decision=data.get("admissibility"), + evidence_reason=data.get("reason"), + deployment_profile_decision=None, + domain_overlay_decision=None, + authorization_decision="authorization_satisfied", + tool_decision=None, + final_decision=data.get("admissibility"), + final_reason=data.get("reason"), + scope_grant=scope_grant, + authorization_satisfied_by_grant=True, + ) + independence = check_evidence_independence( + scope_grant=scope_grant, + post_decision=data.get("admissibility"), + post_reason=data.get("reason"), + final_layers=provenance, + ) + data = attach_layer_provenance_to_decision(data, provenance) + data["evidence_independence"] = independence.to_dict() + # Prefer pre-compose admissibility when grant path recorded initial state. + meta = (grant_state.context or {}).get("metadata") or {} + if meta.get("pre_grant_admissibility"): + auth = dict(data.get("authorization_provenance") or {}) + auth["initial_admissibility"] = meta["pre_grant_admissibility"] + data["authorization_provenance"] = auth + if data.get("authorization_provenance") is not None: + data["authorization_provenance"]["evidence_layer"] = ( + independence.to_dict() + ) + data["authorization_provenance"]["decision_layer_provenance"] = ( + provenance.to_dict() + ) + data["authorization_provenance"]["final_decision"] = data.get( + "admissibility" + ) + decision = AKTADecision(data) if validate_output: validate_against_schema(decision.to_dict(), "akta_decision.schema.json") diff --git a/akta/records.py b/akta/records.py index 4f70c58..bbb2f5c 100644 --- a/akta/records.py +++ b/akta/records.py @@ -287,13 +287,30 @@ def to_record( }, "integrations": { "vsa_report_ref": ctx.get("vsa_report_ref"), - "pf_core_obligation_ref": None, - "pcs_artifact_ref": None, + "pf_core_obligation_ref": ctx.get("pf_core_obligation_ref"), + "pcs_artifact_ref": ctx.get("pcs_artifact_ref"), "prior_akta_records": ctx.get("prior_akta_records", []), }, } if self._data.get("review_trigger"): record_body["review_trigger"] = self._data["review_trigger"] + # WS14 / integrated acceptance: preserve full authorization provenance. + auth_prov = self._data.get("authorization_provenance") + if auth_prov: + record_body["authorization_provenance"] = auth_prov + for key in ( + "grant_layer_satisfaction", + "decision_layer_provenance", + "evidence_independence", + "capability_envelope", + ): + if self._data.get(key) is not None: + record_body[key] = self._data[key] + if self._data.get("policy"): + record_body["provenance"]["policy"] = self._data["policy"] + schema_hashes = (self._data["policy"] or {}).get("schema_hashes") + if schema_hashes: + record_body["provenance"]["schema_hashes"] = schema_hashes record_body["record_hash"] = hash_object({k: v for k, v in record_body.items() if k != "record_hash"}) return AKTARecord(record_body) diff --git a/akta/review_decision.py b/akta/review_decision.py index 798057f..1785889 100644 --- a/akta/review_decision.py +++ b/akta/review_decision.py @@ -128,11 +128,19 @@ def apply_scope_grant_to_context( require_capability_envelope: bool | None = None, trust_store: dict[str, Any] | None = None, ledger_status: dict[str, Any] | None = None, + authorization_manifest: dict[str, Any] | None = None, + source_artifacts: dict[str, Any] | None = None, + requested_tool: str | None = None, + requested_action: str | None = None, + action_class: str | None = None, + status_trust_store: dict[str, Any] | None = None, + manifest_trust_store: dict[str, Any] | None = None, ) -> dict[str, Any]: """Apply SCOPE grant metadata to context after optional grant verification. When ``validate_grant`` is True, runs ``verify_scope_grant_or_raise`` BEFORE any metadata is applied (P0-C). Strict modes require shared envelopes (P0-B). + After admissibility auth: grant → status → build request → containment. """ from akta.scope_grant_verifier import ( STRICT_MODES, @@ -164,12 +172,18 @@ def apply_scope_grant_to_context( context=context, trust_store=trust_store, ledger_status=ledger_status, + authorization_manifest=authorization_manifest, + source_artifacts=source_artifacts, + status_trust_store=status_trust_store, + manifest_trust_store=manifest_trust_store, ) verification = { "ok": result.ok, "mode": result.mode, "warnings": list(result.warnings), "checks_run": list(result.checks_run), + "authorization_manifest_hash": result.authorization_manifest_hash, + "ledger_status": result.ledger_status, } # Scalar validate_approval_grant is migration-only; skip in strict modes. if not strict and granted and requested: @@ -182,7 +196,6 @@ def apply_scope_grant_to_context( metadata["grant_verification"] = verification from akta.capability_envelope import ( extract_capability_envelope, - legacy_scope_to_envelope, resolve_grant_envelope, ) @@ -206,7 +219,60 @@ def apply_scope_grant_to_context( ) if metadata["authorization_comparator"] == "legacy_scalar": metadata["authorization_comparator_deprecated"] = True - if granted and requested and raw_envelope is not None: + + # WS8: build RequestedAuthorizationContext and run containment. + tool = ( + requested_tool + or metadata.get("requested_tool") + or (context.get("requested_tool") if isinstance(context, dict) else None) + or "" + ) + action = requested_action or metadata.get("requested_action") or "" + if tool and raw_envelope is not None: + from akta.requested_authorization import ( + build_requested_authorization_context, + evaluate_authorization_containment, + ) + + classification = {} + if isinstance(record, dict): + classification = dict(record.get("classification") or {}) + if action_class: + classification["action_type"] = action_class + requested_ctx = build_requested_authorization_context( + requested_tool=str(tool), + requested_action=str(action), + action_class=action_class or classification.get("action_type"), + classification=classification, + structured_action=dict(context.get("structured_action") or {}), + context=context, + ) + containment = evaluate_authorization_containment( + granted_envelope=envelope, + requested=requested_ctx, + ledger_status=ledger_status + or (verification.get("ledger_status") if verification else None), + ) + metadata["requested_authorization"] = requested_ctx.to_dict() + metadata["requested_authorization_envelope"] = containment.requested_envelope + metadata["envelope_covers_requested_scope"] = containment.covers + metadata["authorization_comparator"] = containment.comparator + metadata["authorization_containment"] = containment.to_dict() + if strict and not containment.covers: + from akta.errors import GrantVerificationError + + raise GrantVerificationError( + "; ".join(containment.errors) + or "requested authorization not covered by grant", + errors=containment.errors or ["authorization_containment_failed"], + ) + elif granted and requested and raw_envelope is not None: + # Legacy scalar-derived request (unit/migration only). + from akta.capability_envelope import ( + authorization_covers, + legacy_scope_to_envelope, + ) + requested_envelope = legacy_scope_to_envelope( approved_scope=requested, allowed_tools=list(envelope.get("allowed_tools") or []), @@ -215,8 +281,6 @@ def apply_scope_grant_to_context( or "R4_methodological_modification" ), ) - from akta.capability_envelope import authorization_covers - covers, comparator = authorization_covers( granted_envelope=envelope, requested_envelope=requested_envelope, @@ -226,6 +290,7 @@ def apply_scope_grant_to_context( ) metadata["envelope_covers_requested_scope"] = covers metadata["authorization_comparator"] = comparator + tools_explicit = ( "allowed_tools" in scope_grant or "allowed_tools" in auth diff --git a/akta/review_loop.py b/akta/review_loop.py index 7dd0306..cb394ba 100644 --- a/akta/review_loop.py +++ b/akta/review_loop.py @@ -144,120 +144,64 @@ def check_grant_context_validity( scope_grant: dict[str, Any], record: dict[str, Any] | None = None, ) -> EvaluationLayer | None: - """Invalidate grant when any bound consequential state changes (P0-E). + """Invalidate grant when any bound consequential state changes (P0-E / WS10). Missing consequential context → renewed review (never infer missing == bound). + Status-authoritative lifecycle fields are preferred when ledger status is present. """ + from akta.lifecycle_semantics import check_expires_after_bindings + metadata = dict(context.get("metadata") or {}) - constraints = scope_grant.get("constraints") or {} - expiration = scope_grant.get("expiration") or {} - expires_after = list(expiration.get("expires_after") or []) - if isinstance(expiration.get("expires_on"), str): - expires_after.append(expiration["expires_on"]) + ledger_status = metadata.get("ledger_status") or metadata.get("grant_ledger_status") + # Prefer verification-attached status when present. + verification = metadata.get("grant_verification") or {} + if ledger_status is None and isinstance(verification, dict): + ledger_status = verification.get("ledger_status") - bound_evidence = metadata.get("bound_evidence_state") or constraints.get("evidence_state") - bound_protocol = ( - metadata.get("bound_protocol_version") or constraints.get("protocol_version") + result = check_expires_after_bindings( + scope_grant, + context=context, + ledger_status=ledger_status if isinstance(ledger_status, dict) else None, ) - if bound_evidence is None or bound_protocol is None: - bounds = _bound_values_from_record(record) - bound_evidence = bound_evidence or bounds.get("bound_evidence_state") - bound_protocol = bound_protocol or bounds.get("bound_protocol_version") + layer = result.to_evaluation_layer() + if layer is not None: + return layer - current_evidence = context.get("evidence_state") + # Implicit closed-loop bindings from the prior record (even when the grant + # omits expires_after): protocol identity and evidence non-downgrade. + bound_protocol = metadata.get("bound_protocol_version") current_protocol = ( context.get("protocol_version") or context.get("active_protocol_id") or (context.get("scientific_context") or {}).get("protocol_version") ) + if bound_protocol and current_protocol and str(bound_protocol) != str(current_protocol): + return EvaluationLayer( + source="review_loop", + decision="review_required", + reason=( + f"Grant bound state changed for protocol_version " + f"({bound_protocol} -> {current_protocol}); " + "SCOPE grant requires renewed review." + ), + ) - # Missing consequential bindings → abstain / renewed review. - required_events = { - "protocol_version_change": ("protocol_version", bound_protocol, current_protocol), - "evidence_state_change": ("evidence_state", bound_evidence, current_evidence), - "validation_status_change": ( - "validation_status", - constraints.get("validation_status") or metadata.get("bound_validation_status"), - context.get("validation_status") or metadata.get("validation_status"), - ), - "policy_version_change": ( - "policy_manifest_hash", - constraints.get("policy_manifest_hash") - or metadata.get("bound_policy_manifest_hash"), - metadata.get("policy_manifest_hash"), - ), - "model_version_change": ( - "model_version", - constraints.get("model_version") or metadata.get("bound_model_version"), - context.get("model_version") or metadata.get("model_version"), - ), - "tool_registry_change": ( - "tool_registry_hash", - constraints.get("tool_registry_hash") - or constraints.get("tool_registry_version") - or metadata.get("bound_tool_registry_hash"), - metadata.get("tool_registry_hash"), - ), - "domain_overlay_change": ( - "domain_overlay_hash", - constraints.get("domain_overlay") - or constraints.get("domain_overlay_hash") - or metadata.get("bound_domain_overlay_hash"), - context.get("domain_overlay") or metadata.get("domain_overlay_hash"), - ), - "safety_incident": ( - "safety_incident_state", - constraints.get("safety_incident_state") - or metadata.get("bound_safety_incident_state"), - metadata.get("safety_incident_state"), - ), - } - - for event in expires_after: - spec = required_events.get(str(event)) - if not spec: - continue - label, bound, current = spec - if bound is None: - return EvaluationLayer( - source="review_loop", - decision="review_required", - reason=( - f"Missing consequential grant context {label!r} for expiration " - f"event {event!r}; abstain / renewed review (never infer missing == bound)." - ), - ) - if current is not None and str(bound) != str(current): - return EvaluationLayer( - source="review_loop", - decision="review_required", - reason=( - f"Grant bound state changed for {label} ({bound} -> {current}); " - "SCOPE grant requires renewed review." - ), - ) - + bound_evidence = metadata.get("bound_evidence_state") + current_evidence = context.get("evidence_state") if bound_evidence and current_evidence: - if _evidence_rank(current_evidence) < _evidence_rank(str(bound_evidence)): + if _evidence_rank(str(current_evidence)) < _evidence_rank(str(bound_evidence)): return EvaluationLayer( source="review_loop", decision="review_required", reason=( - f"Evidence downgraded from {bound_evidence} to {current_evidence}; " + f"Grant bound evidence downgraded " + f"({bound_evidence} -> {current_evidence}); " "SCOPE grant requires renewed review." ), ) - if bound_protocol and current_protocol and str(bound_protocol) != str(current_protocol): - return EvaluationLayer( - source="review_loop", - decision="review_required", - reason=( - f"Protocol context changed ({bound_protocol} -> {current_protocol}); " - "SCOPE grant requires renewed review." - ), - ) - + # Retain absolute expiration check. + expiration = scope_grant.get("expiration") or {} expires_at = metadata.get("prior_review_expires_at") if not expires_at: abs_exp = expiration.get("absolute_expiration") if isinstance(expiration, dict) else None @@ -314,6 +258,13 @@ def prepare_grant_context( require_capability_envelope: bool | None = None, trust_store: dict[str, Any] | None = None, ledger_status: dict[str, Any] | None = None, + authorization_manifest: dict[str, Any] | None = None, + source_artifacts: dict[str, Any] | None = None, + requested_tool: str | None = None, + requested_action: str | None = None, + action_class: str | None = None, + status_trust_store: dict[str, Any] | None = None, + manifest_trust_store: dict[str, Any] | None = None, ) -> GrantLoopState: """Apply SCOPE grant to context and detect invalidation before re-gate. @@ -331,6 +282,13 @@ def prepare_grant_context( require_capability_envelope=require_capability_envelope, trust_store=trust_store, ledger_status=ledger_status, + authorization_manifest=authorization_manifest, + source_artifacts=source_artifacts, + requested_tool=requested_tool, + requested_action=requested_action, + action_class=action_class, + status_trust_store=status_trust_store, + manifest_trust_store=manifest_trust_store, ) bounds = _bound_values_from_record(record) @@ -471,4 +429,32 @@ def apply_grant_decision_constraints( if envelope: updated["capability_envelope"] = envelope + # Integrated acceptance / WS14 Record provenance: preserve auth chain on Decision. + initial = ( + metadata.get("pre_grant_admissibility") + or metadata.get("initial_admissibility") + or (grant_state.context or {}).get("initial_admissibility") + ) + auth_prov: dict[str, Any] = { + "grant_verification": metadata.get("grant_verification"), + "status_attestation": ( + (metadata.get("grant_verification") or {}).get("ledger_status") + or metadata.get("ledger_status") + ), + "requested_envelope": metadata.get("requested_authorization_envelope"), + "granted_envelope": metadata.get("capability_envelope"), + "requested_authorization": metadata.get("requested_authorization"), + "containment": metadata.get("authorization_containment"), + "authorization_comparator": metadata.get("authorization_comparator"), + "envelope_covers_requested_scope": metadata.get( + "envelope_covers_requested_scope" + ), + "final_decision": updated.get("admissibility"), + } + if initial is not None: + auth_prov["initial_admissibility"] = initial + updated["authorization_provenance"] = { + k: v for k, v in auth_prov.items() if v is not None + } + return updated From c0df720b1302b14e97073da2ad8340f66c61e9a1 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:50:55 -0700 Subject: [PATCH 16/26] feat(policy): strengthen effective policy integrity and load path Verify effective policy manifests at load so runtime decisions use only signed, expected institutional policy. --- akta/effective_policy.py | 183 +++++++++++++++++++++++++- akta/policy.py | 27 ++-- policy/effective_policy_manifest.json | 46 ++++++- tests/test_effective_policy.py | 13 +- 4 files changed, 241 insertions(+), 28 deletions(-) diff --git a/akta/effective_policy.py b/akta/effective_policy.py index 442df2c..e313e84 100644 --- a/akta/effective_policy.py +++ b/akta/effective_policy.py @@ -1,4 +1,4 @@ -"""Effective policy manifest generation and binding (directive §5).""" +"""Effective policy manifest generation and binding (directive §5 / WS11).""" from __future__ import annotations @@ -34,6 +34,26 @@ "scope_compatibility_manifest.json", "release_keys.yaml", "policy_manifest.yaml", + # WS11 provenance expansions + "shared_contracts_pin.json", + "scope_grant_issuer_registry.yaml", + "scope_status_attestation_registry.yaml", + "scope_policy_manifest_signer_registry.yaml", + "release_pins.yaml", + "runtime_capability_mapping.yaml", +) + +# Schema / registry names folded into effective-policy body + policy_binding_block. +SCHEMA_HASH_KEYS = ( + "envelope_schema_hash", + "grant_schema_hash", + "grant_status_schema_hash", +) + +REGISTRY_INPUT_NAMES = ( + "scope_grant_issuer_registry.yaml", + "scope_status_attestation_registry.yaml", + "scope_policy_manifest_signer_registry.yaml", ) OVERLAY_REGISTRY_NOTE = ( @@ -48,6 +68,8 @@ def _file_version(path: Path, data: Any) -> str: data.get("policy_file_version") or data.get("version") or data.get("policy_bundle_version") + or data.get("pin_version") + or data.get("manifest_version") or "unknown" ) return "unknown" @@ -62,6 +84,71 @@ def collect_overlay_registry(overlays_dir: Path | None) -> dict[str, Any]: return {"note": OVERLAY_REGISTRY_NOTE, "overlays": overlays} +def _load_shared_contracts_pin(policy_dir: Path) -> dict[str, Any]: + path = policy_dir / "shared_contracts_pin.json" + if not path.is_file(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def _shared_contract_provenance(policy_dir: Path) -> dict[str, Any]: + """Shared contract version + release/schema hashes for effective policy body.""" + pin = _load_shared_contracts_pin(policy_dir) + provenance: dict[str, Any] = { + "package": pin.get("package") or "scientific-authorization-contracts", + "package_version": pin.get("package_version"), + "release_artifact_hash": pin.get("release_artifact_hash"), + "envelope_schema_version": pin.get("envelope_schema_version"), + "envelope_schema_hash": pin.get("envelope_schema_hash"), + "grant_schema_version": pin.get("grant_schema_version"), + "grant_schema_hash": pin.get("grant_schema_hash"), + "grant_status_schema_hash": pin.get("grant_status_schema_hash"), + } + # Prefer live installed package when available (fail soft for offline pin-only). + try: + from akta.shared_contracts import ( + contracts_package_version, + grant_schema_hash, + grant_schema_version, + release_artifact_hash, + shared_contract_report, + ) + + report = shared_contract_report() + provenance["package_version"] = contracts_package_version() + provenance["release_artifact_hash"] = release_artifact_hash() + provenance["envelope_schema_version"] = report.get("envelope_schema_version") + provenance["envelope_schema_hash"] = report.get("envelope_schema_hash") + provenance["grant_schema_version"] = grant_schema_version() + provenance["grant_schema_hash"] = grant_schema_hash() + if pin.get("grant_status_schema_hash"): + provenance["grant_status_schema_hash"] = pin["grant_status_schema_hash"] + except Exception: + pass + return {k: v for k, v in provenance.items() if v is not None} + + +def _schema_hashes_from_provenance(provenance: dict[str, Any]) -> dict[str, str]: + out: dict[str, str] = {} + for key in SCHEMA_HASH_KEYS: + value = provenance.get(key) + if value: + out[key] = str(value) + return out + + +def _registry_hashes(files: dict[str, str]) -> dict[str, str]: + return {name: files[name] for name in REGISTRY_INPUT_NAMES if name in files} + + +def _overlay_aggregate_hash(overlay_registry: dict[str, Any]) -> str | None: + overlays = overlay_registry.get("overlays") or {} + if not overlays: + return None + return hash_object(overlays) + + def build_effective_policy_manifest( policy_dir: str | Path, *, @@ -102,23 +189,44 @@ def build_effective_policy_manifest( overlay_registry = collect_overlay_registry( Path(overlays_dir) if overlays_dir else policy_dir.parent / "overlays" ) + shared_contracts = _shared_contract_provenance(policy_dir) + schema_hashes = _schema_hashes_from_provenance(shared_contracts) + registry_hashes = _registry_hashes(files) + overlay_hash = _overlay_aggregate_hash(overlay_registry) + release_pins_hash = files.get("release_pins.yaml") + runtime_capability_mapping_hash = files.get("runtime_capability_mapping.yaml") + compatibility_manifest_hash = files.get("scope_compatibility_manifest.json") effective_policy_hash = hash_object({ "files": files, "file_versions": file_versions, "overlay_registry": overlay_registry, "bundle_version": bundle_version, + "shared_contracts": shared_contracts, + "schema_hashes": schema_hashes, + "registry_hashes": registry_hashes, + "release_pins_hash": release_pins_hash, + "runtime_capability_mapping_hash": runtime_capability_mapping_hash, + "compatibility_manifest_hash": compatibility_manifest_hash, }) # Canonical JSON for manifest_hash excludes the hash fields themselves. + # integrity_mode is recorded on the artifact but excluded from the hash so + # HMAC vs unsigned load paths do not falsely flag provenance drift. body_for_hash = { - "manifest_version": "akta-effective-policy-v1.0", + "manifest_version": "akta-effective-policy-v1.1", "bundle_version": bundle_version, - "integrity_mode": integrity_mode, "signing_key_id": signing_key_id, "files": files, "file_versions": file_versions, "overlay_registry": overlay_registry, "missing_optional_inputs": missing, + "shared_contracts": shared_contracts, + "schema_hashes": schema_hashes, + "registry_hashes": registry_hashes, + "release_pins_hash": release_pins_hash, + "runtime_capability_mapping_hash": runtime_capability_mapping_hash, + "compatibility_manifest_hash": compatibility_manifest_hash, + "overlay_hash": overlay_hash, "policy_manifest_signature": { "algorithm": sig.get("algorithm"), "key_id": signing_key_id, @@ -129,6 +237,7 @@ def build_effective_policy_manifest( return { **body_for_hash, + "integrity_mode": integrity_mode, "effective_policy_hash": effective_policy_hash, "manifest_hash": manifest_hash, "policy_manifest_signature": { @@ -217,6 +326,10 @@ def verify_effective_policy_manifest( if stored_overlays.get(name) != recomputed_overlays.get(name): errors.append(f"overlay registry hash drift for {name}") + for key in ("schema_hashes", "registry_hashes", "shared_contracts"): + if (stored_manifest.get(key) or {}) != (recomputed.get(key) or {}): + errors.append(f"effective policy {key} drift") + # Mutating critical inputs after manifest generation must fail. critical = { "default_tool_registry.yaml", @@ -225,6 +338,12 @@ def verify_effective_policy_manifest( "structured_action_schema.yaml", "scope_compatibility_manifest.json", "release_keys.yaml", + "shared_contracts_pin.json", + "scope_grant_issuer_registry.yaml", + "scope_status_attestation_registry.yaml", + "scope_policy_manifest_signer_registry.yaml", + "release_pins.yaml", + "runtime_capability_mapping.yaml", } for name in critical: if name in stored_files and name in recomputed_files: @@ -273,6 +392,13 @@ def policy_binding_block( schema_hashes: dict[str, str] | None = None, overlay_hash: str | None = None, release_key_id: str | None = None, + shared_contract_release_hash: str | None = None, + issuer_registry_hash: str | None = None, + status_attestation_registry_hash: str | None = None, + policy_manifest_signer_registry_hash: str | None = None, + release_pins_hash: str | None = None, + runtime_capability_mapping_hash: str | None = None, + compatibility_manifest_hash: str | None = None, ) -> dict[str, Any]: """Canonical policy provenance block for Decision and Record artifacts.""" block: dict[str, Any] = { @@ -283,9 +409,52 @@ def policy_binding_block( "signing_key_id": signing_key_id, "release_key_id": release_key_id or signing_key_id, "file_versions": dict(file_versions), + "schema_hashes": dict(schema_hashes or {}), + "overlay_hash": overlay_hash, + "shared_contract_release_hash": shared_contract_release_hash, + "issuer_registry_hash": issuer_registry_hash, + "status_attestation_registry_hash": status_attestation_registry_hash, + "policy_manifest_signer_registry_hash": policy_manifest_signer_registry_hash, + "release_pins_hash": release_pins_hash, + "runtime_capability_mapping_hash": runtime_capability_mapping_hash, + "compatibility_manifest_hash": compatibility_manifest_hash, } - if schema_hashes: - block["schema_hashes"] = dict(schema_hashes) - if overlay_hash: - block["overlay_hash"] = overlay_hash return block + + +def binding_from_effective_manifest( + *, + bundle_version: str, + integrity_mode: str, + signing_key_id: str | None, + file_versions: dict[str, str], + effective: dict[str, Any], + policy_hash_fallback: str = "", +) -> dict[str, Any]: + """Populate every policy_binding_block field from a recomputed effective manifest.""" + shared = effective.get("shared_contracts") or {} + registries = effective.get("registry_hashes") or {} + return policy_binding_block( + bundle_version=bundle_version, + effective_policy_hash=str( + effective.get("effective_policy_hash") or policy_hash_fallback + ), + manifest_hash=str(effective.get("manifest_hash") or policy_hash_fallback), + integrity_mode=integrity_mode, + signing_key_id=signing_key_id, + file_versions=file_versions, + schema_hashes=dict(effective.get("schema_hashes") or {}), + overlay_hash=effective.get("overlay_hash"), + release_key_id=signing_key_id, + shared_contract_release_hash=shared.get("release_artifact_hash"), + issuer_registry_hash=registries.get("scope_grant_issuer_registry.yaml"), + status_attestation_registry_hash=registries.get( + "scope_status_attestation_registry.yaml" + ), + policy_manifest_signer_registry_hash=registries.get( + "scope_policy_manifest_signer_registry.yaml" + ), + release_pins_hash=effective.get("release_pins_hash"), + runtime_capability_mapping_hash=effective.get("runtime_capability_mapping_hash"), + compatibility_manifest_hash=effective.get("compatibility_manifest_hash"), + ) diff --git a/akta/policy.py b/akta/policy.py index aad13ed..6396ccb 100644 --- a/akta/policy.py +++ b/akta/policy.py @@ -53,15 +53,15 @@ class PolicyBundle: def policy_binding(self) -> dict[str, Any]: """Canonical policy provenance block for Decision/Record artifacts.""" - from akta.effective_policy import policy_binding_block + from akta.effective_policy import binding_from_effective_manifest - return policy_binding_block( + return binding_from_effective_manifest( bundle_version=self.version, - effective_policy_hash=self.effective_policy_hash or self.policy_hash, - manifest_hash=self.manifest_hash or self.policy_hash, integrity_mode=self.integrity_mode, signing_key_id=self.signing_key_id, file_versions=self.policy_file_versions, + effective=self.effective_policy_manifest or {}, + policy_hash_fallback=self.policy_hash, ) @classmethod @@ -118,23 +118,20 @@ def from_dir( }) tool_registry_hash = hash_file_content(registry_content) - from akta.effective_policy import ( - build_effective_policy_manifest, - load_effective_policy_manifest, - ) + from akta.effective_policy import require_effective_policy_integrity from akta.policy_signing import verify_policy_bundle_integrity # required=False must not bypass AKTA_PRODUCTION_MODE / AKTA_REQUIRE_SIGNED_POLICY. integrity_result = verify_policy_bundle_integrity(policy_dir, required=False) overlays_dir = policy_dir.parent / "overlays" - effective = load_effective_policy_manifest(policy_dir) - if effective is None: - effective = build_effective_policy_manifest( - policy_dir, - overlays_dir=overlays_dir if overlays_dir.is_dir() else None, - integrity_mode=integrity_result.integrity_mode, - ) + overlays_arg = overlays_dir if overlays_dir.is_dir() else None + # WS11: always recompute + verify; never return a stale stored manifest unchecked. + effective = require_effective_policy_integrity( + policy_dir, + overlays_dir=overlays_arg, + integrity_mode=integrity_result.integrity_mode, + ) signing_key_id = None try: diff --git a/policy/effective_policy_manifest.json b/policy/effective_policy_manifest.json index c501396..7746cfc 100644 --- a/policy/effective_policy_manifest.json +++ b/policy/effective_policy_manifest.json @@ -1,6 +1,7 @@ { "bundle_version": "akta-core-v1.0", - "effective_policy_hash": "sha256:2faafa13b8a318a8d4f3f7b13c2a68733e85a20cb41dba8a54131159c2beea0d", + "compatibility_manifest_hash": "sha256:efbc052fa89e47e64bebcfa6c81c95c0065d59489a4f521942214be98a3eaab3", + "effective_policy_hash": "sha256:7b0f47ee87ef829585907c3d20fe3752a0398d1f6d7ae9b3a03df0392aefd4d0", "file_versions": { "action_ontology.yaml": "action_ontology-v0.5", "admissibility_matrix.yaml": "admissibility_matrix-v0.5", @@ -11,9 +12,15 @@ "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", "policy_manifest.yaml": "akta-policy-manifest-v1.0", "release_keys.yaml": "akta-release-keys-v0.7", + "release_pins.yaml": "akta-release-pins-v1.1", "responsibility_levels.yaml": "responsibility_levels-v0.5", - "scope_compatibility_manifest.json": "unknown", + "runtime_capability_mapping.yaml": "runtime_capability_mapping-v1.0", + "scope_compatibility_manifest.json": "akta-scope-compatibility-v1.1", + "scope_grant_issuer_registry.yaml": "akta-scope-grant-issuer-keys-v1", "scope_grant_override_rules.yaml": "akta-scope-grant-layer-satisfaction-v1.0", + "scope_policy_manifest_signer_registry.yaml": "akta-scope-policy-manifest-signer-keys-v1", + "scope_status_attestation_registry.yaml": "akta-scope-status-attestation-keys-v1", + "shared_contracts_pin.json": "scientific-authorization-contracts-pin-v1", "structured_action_schema.yaml": "akta-structured-action-v1.0", "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5", "validation_statuses.yaml": "validation_statuses-v0.5", @@ -29,18 +36,25 @@ "evidence_to_action_rules.yaml": "sha256:72175f5f4cfd6f5cd713098515af9bbf17cb6c6bbc258b5593d1a6b0e12197e2", "policy_manifest.yaml": "sha256:4a00c0cf1cee2f7a3d09dbbd9b8619d75357c4dd2edf1e97aeabdb701410a79b", "release_keys.yaml": "sha256:eb1c7c4a02347df8293c110d26d00a0e4e91d79ebf3de3146d1c1a9c04be0ce1", + "release_pins.yaml": "sha256:5e914ec9447fdf4aae38fa5e5de3d2e35e19b24549ed16c61bb9739ac401c00b", "responsibility_levels.yaml": "sha256:e98d3c5488575364f4b5ac9cbf48754f873475235843c5c8405473444e1ac461", - "scope_compatibility_manifest.json": "sha256:85c9e21ab29f91b4da544464362e3ff9bb1ab13a0d14bfcf83c5c84d29cb8979", + "runtime_capability_mapping.yaml": "sha256:bda801bcb5447a8841b7e6ecddac60a4543233e5c38d03a3d8b93da097011af2", + "scope_compatibility_manifest.json": "sha256:efbc052fa89e47e64bebcfa6c81c95c0065d59489a4f521942214be98a3eaab3", + "scope_grant_issuer_registry.yaml": "sha256:fb67555ba6e88a4edd019e9ab4b3568cc2ca25f76a1d4dd5f3b2deab600006eb", "scope_grant_override_rules.yaml": "sha256:6cb9701d36c9a05f343830b8b5f356d8bd2b4479c01d99f775ee8400fac12863", + "scope_policy_manifest_signer_registry.yaml": "sha256:a83423fb53e3fac4c56f74b6b2e1d0cead283e5c2814919e2abc0e54c94fcb86", + "scope_status_attestation_registry.yaml": "sha256:7d187af9b0eeb990b6408c1dce2b05ac3edebeec059cd4b234c0ba7536b2f3d5", + "shared_contracts_pin.json": "sha256:50b6a2e4296e4e9c4f78838eaac377f0daeeeaf150e5b61a9c5fb66c95d72a80", "structured_action_schema.yaml": "sha256:89a0a0255e64ac9fc414350708fd14096d255bff1b25b1749e8203fecdd7ba3f", "tool_to_requested_scope.yaml": "sha256:c85beea0595b98fc913b8e5b90d22155bf74afb08937edf8147472c6749873cc", "validation_statuses.yaml": "sha256:1cdadb65eb0f5697ab0a70984b3f1fd0b6c26286e16665b45ede620ae1c4548c", "verification_statuses.yaml": "sha256:3b09dcc6c66a52a756c3dd054bc7f7e073bd78187606ed2c8855e1348d6a2128" }, "integrity_mode": "deployment_hmac_attested", - "manifest_hash": "sha256:5b453acdfe7c5be9a090585b065f55771beb8ef119a37ab70a7329ba87815ca7", - "manifest_version": "akta-effective-policy-v1.0", + "manifest_hash": "sha256:5f180d472b8ace145872ce1aef9c87a7c4f308f6bdd7a1833bbb71303745fd3b", + "manifest_version": "akta-effective-policy-v1.1", "missing_optional_inputs": [], + "overlay_hash": "sha256:473d097b6e317ad9de948e03883db75bca7488018e6fd7e5017707b91eb34dec", "overlay_registry": { "note": "Domain overlays live under overlays/; registry metadata is recorded as overlay_ids + content hashes when overlays_dir is provided.", "overlays": { @@ -60,5 +74,27 @@ "present": true, "value": "9f3263391dd00c35191eb013b2129ff5cb943107615a5f10da545dbf7d501567" }, + "registry_hashes": { + "scope_grant_issuer_registry.yaml": "sha256:fb67555ba6e88a4edd019e9ab4b3568cc2ca25f76a1d4dd5f3b2deab600006eb", + "scope_policy_manifest_signer_registry.yaml": "sha256:a83423fb53e3fac4c56f74b6b2e1d0cead283e5c2814919e2abc0e54c94fcb86", + "scope_status_attestation_registry.yaml": "sha256:7d187af9b0eeb990b6408c1dce2b05ac3edebeec059cd4b234c0ba7536b2f3d5" + }, + "release_pins_hash": "sha256:5e914ec9447fdf4aae38fa5e5de3d2e35e19b24549ed16c61bb9739ac401c00b", + "runtime_capability_mapping_hash": "sha256:bda801bcb5447a8841b7e6ecddac60a4543233e5c38d03a3d8b93da097011af2", + "schema_hashes": { + "envelope_schema_hash": "sha256:9881294eed119374c328f1d48bd7edb39d32b15450734716959c2a953b1ea918", + "grant_schema_hash": "sha256:472e81b99122ed9839eb645dc6f7915c1ca525f63cce71488199e31611163c5c", + "grant_status_schema_hash": "sha256:a01507839c35e514ee4c4843223f7f1523709d1f38ff2d62c49b999540c23ac0" + }, + "shared_contracts": { + "envelope_schema_hash": "sha256:9881294eed119374c328f1d48bd7edb39d32b15450734716959c2a953b1ea918", + "envelope_schema_version": "scientific-authorization-envelope-v2", + "grant_schema_hash": "sha256:472e81b99122ed9839eb645dc6f7915c1ca525f63cce71488199e31611163c5c", + "grant_schema_version": "scope-grant-v2", + "grant_status_schema_hash": "sha256:a01507839c35e514ee4c4843223f7f1523709d1f38ff2d62c49b999540c23ac0", + "package": "scientific-authorization-contracts", + "package_version": "2.1.0", + "release_artifact_hash": "sha256:d1f496733819215336a260934f71c374a239a1194be781b0349849fd50b137d3" + }, "signing_key_id": null } diff --git a/tests/test_effective_policy.py b/tests/test_effective_policy.py index 930a775..30994fd 100644 --- a/tests/test_effective_policy.py +++ b/tests/test_effective_policy.py @@ -85,14 +85,21 @@ def test_revoked_release_key_hard_fails( def test_effective_policy_manifest_covers_core_inputs() -> None: manifest = build_effective_policy_manifest(ROOT / "policy", overlays_dir=ROOT / "overlays") - assert manifest["manifest_version"] == "akta-effective-policy-v1.0" + assert manifest["manifest_version"] == "akta-effective-policy-v1.1" assert manifest["effective_policy_hash"].startswith("sha256:") assert manifest["manifest_hash"].startswith("sha256:") assert "action_ontology.yaml" in manifest["files"] assert "default_tool_registry.yaml" in manifest["files"] assert "scope_grant_override_rules.yaml" in manifest["files"] assert "release_keys.yaml" in manifest["files"] + assert "shared_contracts_pin.json" in manifest["files"] + assert "scope_grant_issuer_registry.yaml" in manifest["files"] + assert "release_pins.yaml" in manifest["files"] + assert "runtime_capability_mapping.yaml" in manifest["files"] assert "overlays" in manifest["overlay_registry"] + assert manifest["shared_contracts"]["package_version"] + assert manifest["schema_hashes"] + assert manifest["registry_hashes"] def test_decision_and_record_bind_policy_block(monkeypatch: pytest.MonkeyPatch) -> None: @@ -113,6 +120,10 @@ def test_decision_and_record_bind_policy_block(monkeypatch: pytest.MonkeyPatch) assert d["policy"]["effective_policy_hash"].startswith("sha256:") assert d["policy"]["integrity_mode"] == gate.policy.integrity_mode assert isinstance(d["policy"]["file_versions"], dict) + assert "schema_hashes" in d["policy"] + assert d["policy"].get("shared_contract_release_hash") + assert d["policy"].get("issuer_registry_hash") + assert d["policy"].get("compatibility_manifest_hash") record = decision.to_record().to_dict() assert "policy" in record From 0607c3783df8c3cd62f8de855d9d92b49d9f1048 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:02 -0700 Subject: [PATCH 17/26] feat(compat): refresh SCOPE compatibility and sibling repo pins Keep cross-repo negotiation aligned with current SCOPE manifests and discovery helpers. --- akta/sibling_repos.py | 48 ++++++++++++++++++++- policy/scope_compatibility_manifest.json | 53 ++++++++++++++++++------ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/akta/sibling_repos.py b/akta/sibling_repos.py index 22f9616..b6caff3 100644 --- a/akta/sibling_repos.py +++ b/akta/sibling_repos.py @@ -1,8 +1,9 @@ -"""Discover sibling repository paths for live trust-stack validation (v1.0).""" +"""Discover sibling repository paths for live trust-stack validation (v1.0 / WS12).""" from __future__ import annotations import os +import subprocess from pathlib import Path _AKTA_ROOT = Path(__file__).resolve().parent.parent @@ -42,6 +43,43 @@ def discover_sibling(env_var: str) -> Path | None: return None +def git_rev_parse_head(repo_path: str | Path) -> str | None: + """Return ``git rev-parse HEAD`` for a checkout, or None on failure. + + Existence of a directory / ``.git`` alone is insufficient for pin checks — + callers must compare this SHA to ``policy/release_pins.yaml``. + """ + path = Path(repo_path) + if not path.is_dir(): + return None + try: + completed = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + sha = (completed.stdout or "").strip() + return sha or None + + +def checkout_matches_pin(repo_path: str | Path, pinned_commit: str) -> bool: + """True when checkout HEAD equals (or uniquely prefixes) the pinned commit.""" + pinned = str(pinned_commit or "").strip().lower() + if not pinned or "placeholder" in pinned or "unavailable" in pinned: + return False + head = git_rev_parse_head(repo_path) + if not head: + return False + head = head.lower() + return head == pinned or head.startswith(pinned) or pinned.startswith(head) + + def apply_sibling_env_defaults() -> dict[str, str]: """Set unset sibling env vars from discovery; return applied mapping.""" applied: dict[str, str] = {} @@ -53,3 +91,11 @@ def apply_sibling_env_defaults() -> dict[str, str]: os.environ[env_var] = str(found) applied[env_var] = str(found) return applied + + +__all__ = [ + "apply_sibling_env_defaults", + "checkout_matches_pin", + "discover_sibling", + "git_rev_parse_head", +] diff --git a/policy/scope_compatibility_manifest.json b/policy/scope_compatibility_manifest.json index a14c544..47b01e0 100644 --- a/policy/scope_compatibility_manifest.json +++ b/policy/scope_compatibility_manifest.json @@ -1,25 +1,52 @@ { - "manifest_version": "akta-scope-compatibility-v1.0", - "akta_package_version": "1.0.0", + "manifest_version": "akta-scope-compatibility-v1.1", + "akta_package_version": "1.1.0", "akta_review_contract_version": "scope-akta-review-v1.0", - "grant_schema_version": "akta-scope-grant-v1.0", - "capability_envelope_version": "akta-capability-envelope-v1.0", + "grant_schema_version": "scope-grant-v2", + "grant_status_schema_version": "scope-grant-status-v1", + "capability_envelope_version": "scientific-authorization-envelope-v2", + "authorization_schema_version": "scientific-authorization-envelope-v2", "summary_contract_version": "scope-akta-review-summary-v1.0", - "fixture_contract_version": "akta-scope-contract-v1.0", + "fixture_contract_version": "akta-scope-contract-v2.0", + "shared_contracts_package": "scientific-authorization-contracts", + "shared_contracts_package_version": "2.1.0", "compatible_scope_packages": { - "scope-protocol": ["0.9.0", "0.9.1", "1.0.0"], - "scope-core": ["scope-core-v0.9", "scope-core-v1.0"] + "scope-protocol": ["2.1.0", "2.1.1"], + "scope-core": ["scope-core-v2.1", "2.1.0", "2.1.1"] }, "compatible_scope_policy_versions": [ - "scope-policy-v0.9", - "scope-policy-v1.0" + "scope-policy-v2.1" ], "strict_exact_versions": { "akta_review_contract_version": "scope-akta-review-v1.0", - "grant_schema_version": "akta-scope-grant-v1.0", - "capability_envelope_version": "akta-capability-envelope-v1.0", + "grant_schema_version": "scope-grant-v2", + "grant_status_schema_version": "scope-grant-status-v1", + "capability_envelope_version": "scientific-authorization-envelope-v2", + "envelope_schema_version": "scientific-authorization-envelope-v2", + "authorization_schema_version": "scientific-authorization-envelope-v2", "summary_contract_version": "scope-akta-review-summary-v1.0", - "fixture_contract_version": "akta-scope-contract-v1.0" + "summary_schema_version": "scope-akta-review-summary-v1.0", + "fixture_contract_version": "akta-scope-contract-v2.0" }, - "summary_status_values": ["completed", "session_required"] + "legacy_ids_rejected_in_strict": [ + "akta-capability-envelope-v1.0", + "akta-scope-grant-v1.0", + "akta-scope-contract-v1.0", + "scope-core-v0.9", + "scope-core-v1.0", + "scope-policy-v0.9", + "scope-policy-v1.0" + ], + "required_peer_version_keys": [ + "scope_package_version", + "scope_core_version", + "akta_review_contract_version", + "grant_schema_version", + "envelope_schema_version", + "summary_schema_version", + "policy_manifest_version" + ], + "summary_status_values": ["completed", "session_required"], + "sibling_integration_checklist": "docs/scope_sibling_integration_checklist.md", + "notes": "AKTA 1.1 lands first; peers still on legacy envelope/grant IDs fail closed under strict negotiation." } From 28c49e5ba9d94ff16ec0f50ad81fc336c9c0f7ba Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:08 -0700 Subject: [PATCH 18/26] feat(rest): harden institutional identity verification Require verified OIDC identity in institutional mode so REST callers cannot spoof principals. --- adapters/generic_rest/identity.py | 262 ++++++++++++++++++++++++++---- 1 file changed, 228 insertions(+), 34 deletions(-) diff --git a/adapters/generic_rest/identity.py b/adapters/generic_rest/identity.py index 156be9e..a3ed3ab 100644 --- a/adapters/generic_rest/identity.py +++ b/adapters/generic_rest/identity.py @@ -1,7 +1,12 @@ -"""Service identity model for REST modes (P1-C). +"""Service identity model for REST modes (P1-C / WS13). -Institutional-service mode rejects static bearer-token equality as the sole -identity mechanism. Development-local may use a shared API key for loopback. +Modes: +- ``development-local``: loopback-friendly; optional API key +- ``pilot-service``: static credential-digest registry (non-institutional) +- ``institutional-service``: OIDC issuer + audience verification + +Institutional mode rejects static bearer-token equality and pilot registry +digests as the sole identity mechanism. """ from __future__ import annotations @@ -9,23 +14,17 @@ import hashlib import json import os +import time from dataclasses import dataclass from pathlib import Path from typing import Any - -@dataclass(frozen=True) -class ServiceIdentity: - caller_id: str - tenant_id: str - role: str - permissions: frozenset[str] - verified: bool - - DEVELOPMENT_LOCAL = "development-local" +PILOT_SERVICE = "pilot-service" INSTITUTIONAL_SERVICE = "institutional-service" -VALID_MODES = frozenset({DEVELOPMENT_LOCAL, INSTITUTIONAL_SERVICE}) +# Back-compat alias used in older docs/tests for the digest-registry path. +STATIC_REGISTRY = PILOT_SERVICE +VALID_MODES = frozenset({DEVELOPMENT_LOCAL, PILOT_SERVICE, INSTITUTIONAL_SERVICE}) ROLE_PERMISSIONS: dict[str, frozenset[str]] = { "evaluator": frozenset({"evaluate", "export", "read_own_artifacts"}), @@ -37,21 +36,46 @@ class ServiceIdentity: } +@dataclass(frozen=True) +class ServiceIdentity: + caller_id: str + tenant_id: str + role: str + permissions: frozenset[str] + verified: bool + auth_method: str = "unknown" + token_claims: tuple[tuple[str, str], ...] = () + + def resolve_service_mode(explicit: str | None = None) -> str: raw = (explicit or os.environ.get("AKTA_REST_MODE") or "").strip().lower() if not raw: return DEVELOPMENT_LOCAL - if raw in ("institutional", "institutional-service"): - return INSTITUTIONAL_SERVICE - if raw in ("development", "development-local", "local", "dev"): - return DEVELOPMENT_LOCAL + aliases = { + "institutional": INSTITUTIONAL_SERVICE, + "institutional-service": INSTITUTIONAL_SERVICE, + "pilot": PILOT_SERVICE, + "pilot-service": PILOT_SERVICE, + # Former name for the digest-registry path. + "static-registry": PILOT_SERVICE, + "static_registry": PILOT_SERVICE, + "development": DEVELOPMENT_LOCAL, + "development-local": DEVELOPMENT_LOCAL, + "local": DEVELOPMENT_LOCAL, + "dev": DEVELOPMENT_LOCAL, + } + if raw in aliases: + return aliases[raw] if raw not in VALID_MODES: raise ValueError(f"unknown AKTA REST mode: {raw}") return raw def load_service_identity_registry(path: str | Path | None = None) -> dict[str, Any]: - """Load verified service identities (public descriptors + credential digests).""" + """Load verified service identities (public descriptors + credential digests). + + Used by ``pilot-service`` only. Institutional mode uses OIDC. + """ env_path = path or os.environ.get("AKTA_REST_IDENTITY_REGISTRY", "").strip() if not env_path: return {"version": None, "identities": {}} @@ -66,6 +90,142 @@ def _digest(token: str) -> str: return "sha256:" + hashlib.sha256(token.encode("utf-8")).hexdigest() +def _oidc_settings() -> dict[str, str]: + return { + "issuer": os.environ.get("AKTA_OIDC_ISSUER", "").strip(), + "audience": os.environ.get("AKTA_OIDC_AUDIENCE", "").strip(), + "jwks_url": os.environ.get("AKTA_OIDC_JWKS_URL", "").strip(), + "tenant_claim": os.environ.get("AKTA_OIDC_TENANT_CLAIM", "tenant_id").strip() + or "tenant_id", + "role_claim": os.environ.get("AKTA_OIDC_ROLE_CLAIM", "role").strip() or "role", + "subject_claim": os.environ.get("AKTA_OIDC_SUBJECT_CLAIM", "sub").strip() or "sub", + } + + +def verify_oidc_bearer( + token: str, + *, + issuer: str | None = None, + audience: str | None = None, + jwks_url: str | None = None, + now: float | None = None, +) -> dict[str, Any]: + """Verify an OIDC access/ID token (issuer + audience). + + Requires optional dependency ``PyJWT`` (and ``cryptography`` for RS*/ES*). + Set ``AKTA_OIDC_JWKS_URL`` or pass ``jwks_url`` for signature verification. + """ + settings = _oidc_settings() + iss = (issuer or settings["issuer"]).strip() + aud = (audience or settings["audience"]).strip() + jwks = (jwks_url or settings["jwks_url"]).strip() + if not iss or not aud: + raise ValueError("institutional OIDC requires AKTA_OIDC_ISSUER and AKTA_OIDC_AUDIENCE") + + try: + import jwt + from jwt import PyJWKClient + except ImportError as exc: + raise ImportError( + "institutional OIDC requires PyJWT. Install with: " + "pip install 'akta-protocol[rest]' (or pip install PyJWT)" + ) from exc + + options = { + "require": ["exp", "iat", "iss", "aud", "sub"], + "verify_aud": True, + "verify_iss": True, + } + decode_kwargs: dict[str, Any] = { + "algorithms": ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"], + "audience": aud, + "issuer": iss, + "options": options, + "leeway": 30, + } + if jwks: + client = PyJWKClient(jwks, cache_keys=True) + signing_key = client.get_signing_key_from_jwt(token) + claims = jwt.decode(token, signing_key.key, **decode_kwargs) + else: + # Explicit insecure escape hatch for offline unit tests only. + if os.environ.get("AKTA_OIDC_ALLOW_UNVERIFIED", "").strip().lower() not in ( + "1", + "true", + "yes", + ): + raise ValueError( + "institutional OIDC requires AKTA_OIDC_JWKS_URL " + "(or AKTA_OIDC_ALLOW_UNVERIFIED=1 for offline tests only)" + ) + claims = jwt.decode( + token, + options={**options, "verify_signature": False}, + audience=aud, + issuer=iss, + leeway=30, + ) + + # Defense-in-depth clock check even when library already verified exp. + ts = now if now is not None else time.time() + exp = claims.get("exp") + if exp is not None and float(exp) < ts - 30: + raise ValueError("OIDC token expired") + return dict(claims) + + +def authenticate_oidc_request( + *, + bearer_token: str | None, + tenant_header: str | None, + oidc_claims: dict[str, Any] | None = None, +) -> ServiceIdentity | None: + """Resolve institutional identity from a verified OIDC token.""" + if not bearer_token and oidc_claims is None: + return None + try: + claims = oidc_claims if oidc_claims is not None else verify_oidc_bearer(bearer_token or "") + except Exception: + return None + + settings = _oidc_settings() + caller_id = str(claims.get(settings["subject_claim"]) or claims.get("sub") or "").strip() + if not caller_id: + return None + tenant_id = str( + claims.get(settings["tenant_claim"]) + or claims.get("tenant") + or claims.get("org_id") + or "" + ).strip() + if tenant_header and tenant_id and tenant_header != tenant_id: + return None + if tenant_header and not tenant_id: + tenant_id = tenant_header + if not tenant_id: + return None + role = str(claims.get(settings["role_claim"]) or claims.get("akta_role") or "evaluator") + raw_perms = claims.get("permissions") or claims.get("akta_permissions") + if raw_perms is None: + perms = ROLE_PERMISSIONS.get(role, frozenset()) + else: + perms = frozenset(str(p) for p in raw_perms) + claim_pairs = tuple( + (str(k), str(v)) + for k, v in sorted(claims.items()) + if k in {"iss", "aud", "sub", settings["tenant_claim"], settings["role_claim"]} + ) + return ServiceIdentity( + caller_id=caller_id, + tenant_id=tenant_id, + role=role, + permissions=perms, + verified=True, + auth_method="oidc", + token_claims=claim_pairs, + ) + + def authenticate_request( *, mode: str, @@ -74,13 +234,9 @@ def authenticate_request( service_tokens: frozenset[str], identity_registry: dict[str, Any], tenant_header: str | None, + oidc_claims: dict[str, Any] | None = None, ) -> ServiceIdentity | None: - """Resolve caller identity. - - Institutional mode requires a registry entry whose credential digest matches - the presented secret AND whose tenant_id binds to X-AKTA-Tenant. Static - equality against a shared AKTA_REST_API_KEY alone is rejected. - """ + """Resolve caller identity for the active REST mode.""" if mode == DEVELOPMENT_LOCAL: if api_key and bearer_or_key == api_key: return ServiceIdentity( @@ -89,6 +245,7 @@ def authenticate_request( role="admin", permissions=ROLE_PERMISSIONS["admin"], verified=False, + auth_method="dev_api_key", ) if bearer_or_key and bearer_or_key in service_tokens: return ServiceIdentity( @@ -97,6 +254,7 @@ def authenticate_request( role="evaluator", permissions=ROLE_PERMISSIONS["evaluator"], verified=False, + auth_method="dev_token", ) if not api_key and not service_tokens: return ServiceIdentity( @@ -105,14 +263,24 @@ def authenticate_request( role="admin", permissions=ROLE_PERMISSIONS["admin"], verified=False, + auth_method="anonymous_loopback", ) return None - # institutional-service + if mode == INSTITUTIONAL_SERVICE: + # Reject shared static API key equality as institutional identity. + if api_key and bearer_or_key == api_key: + return None + return authenticate_oidc_request( + bearer_token=bearer_or_key, + tenant_header=tenant_header, + oidc_claims=oidc_claims, + ) + + # pilot-service (formerly static-registry / institutional digest path) if not bearer_or_key: return None if api_key and bearer_or_key == api_key and not identity_registry.get("identities"): - # Explicitly reject shared static bearer as institutional identity. return None digest = _digest(bearer_or_key) @@ -139,6 +307,7 @@ def authenticate_request( role=role, permissions=perms, verified=True, + auth_method="pilot_registry", ) return None @@ -151,16 +320,37 @@ def institutional_startup_errors( audit_ledger_kind: str, trusted_proxy: bool, policy_manifest_verified: bool, + oidc_configured: bool | None = None, ) -> list[str]: + if mode == PILOT_SERVICE: + errors: list[str] = [] + if not (identity_registry.get("identities") or {}): + errors.append("pilot-service requires verified identity registry") + if artifact_store_kind not in {"file", "sqlite"}: + errors.append("pilot-service requires persistent file or sqlite artifact store") + if audit_ledger_kind not in {"file", "sqlite"}: + errors.append("pilot-service requires persistent file or sqlite audit ledger") + return errors + if mode != INSTITUTIONAL_SERVICE: return [] - errors: list[str] = [] - if not (identity_registry.get("identities") or {}): - errors.append("institutional-service requires verified identity registry") - if artifact_store_kind != "file": - errors.append("institutional-service requires persistent file artifact store") - if audit_ledger_kind != "file": - errors.append("institutional-service requires persistent file audit ledger") + + errors = [] + settings = _oidc_settings() + oidc_ok = ( + oidc_configured + if oidc_configured is not None + else bool(settings["issuer"] and settings["audience"]) + ) + if not oidc_ok: + errors.append( + "institutional-service requires OIDC issuer+audience " + "(AKTA_OIDC_ISSUER, AKTA_OIDC_AUDIENCE)" + ) + if artifact_store_kind != "postgresql": + errors.append("institutional-service requires PostgreSQL artifact store") + if audit_ledger_kind != "postgresql": + errors.append("institutional-service requires PostgreSQL audit ledger") if not trusted_proxy: errors.append("institutional-service requires TLS-terminating trusted proxy config") if not policy_manifest_verified: @@ -171,10 +361,14 @@ def institutional_startup_errors( __all__ = [ "DEVELOPMENT_LOCAL", "INSTITUTIONAL_SERVICE", + "PILOT_SERVICE", "ROLE_PERMISSIONS", + "STATIC_REGISTRY", "ServiceIdentity", + "authenticate_oidc_request", "authenticate_request", "institutional_startup_errors", "load_service_identity_registry", "resolve_service_mode", + "verify_oidc_bearer", ] From edc64e89b94e1c0ca4b50d9b3669baeaeb2df37b Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:15 -0700 Subject: [PATCH 19/26] feat(rest): add institutional PostgreSQL persistence mode Persist institutional review state durably while keeping development modes lightweight and local. --- adapters/generic_rest/persistence.py | 610 +++++++++++++++++++++++++-- 1 file changed, 580 insertions(+), 30 deletions(-) diff --git a/adapters/generic_rest/persistence.py b/adapters/generic_rest/persistence.py index 467655d..d5f2ab9 100644 --- a/adapters/generic_rest/persistence.py +++ b/adapters/generic_rest/persistence.py @@ -1,25 +1,84 @@ -"""Persistent artifact + audit stores for institutional REST mode (P1-C).""" +"""Persistent artifact + audit stores for REST modes (P1-C / WS13). + +Production institutional path is PostgreSQL (atomic writes, tenant constraints, +unique IDs, artifact hashes, source linkage, schema versions, transactional +hash-chained audit). + +Optional dependency: ``psycopg`` (v3) — install via ``pip install 'akta-protocol[rest]'``. + +CI / offline tests may use in-memory, file, or SQLite fallbacks. SQLite is never +accepted as the institutional production store. +""" from __future__ import annotations import json +import os +import sqlite3 import threading import time import uuid from pathlib import Path from typing import Any, Protocol +from akta.hash import hash_object + +ARTIFACT_SCHEMA_VERSION = "akta-rest-artifact-v1" +AUDIT_SCHEMA_VERSION = "akta-rest-audit-v1" + class ArtifactStore(Protocol): - def put(self, kind: str, data: dict[str, Any], *, tenant_id: str, caller_id: str) -> str: ... + def put( + self, + kind: str, + data: dict[str, Any], + *, + tenant_id: str, + caller_id: str, + source_artifact_id: str | None = None, + schema_version: str | None = None, + ) -> str: ... - def get(self, artifact_id: str, *, tenant_id: str, caller_id: str, role: str) -> dict[str, Any] | None: ... + def get( + self, artifact_id: str, *, tenant_id: str, caller_id: str, role: str + ) -> dict[str, Any] | None: ... class AuditLedger(Protocol): def append(self, entry: dict[str, Any]) -> None: ... +def _collision_resistant_id(kind: str) -> str: + return f"AKTA-{kind.upper()}-{uuid.uuid4().hex}-{uuid.uuid4().hex[:8]}" + + +def _artifact_hash(data: dict[str, Any]) -> str: + return hash_object(data) + + +def _normalize_stored( + *, + artifact_id: str, + kind: str, + data: dict[str, Any], + tenant_id: str, + caller_id: str, + source_artifact_id: str | None, + schema_version: str | None, +) -> dict[str, Any]: + return { + "artifact_id": artifact_id, + "kind": kind, + "data": data, + "tenant_id": tenant_id, + "caller_id": caller_id, + "created_at": time.time(), + "artifact_hash": _artifact_hash(data), + "source_artifact_id": source_artifact_id, + "schema_version": schema_version or ARTIFACT_SCHEMA_VERSION, + } + + class InMemoryArtifactStore: """Development-local only. Forbidden as sole store in institutional-service.""" @@ -27,16 +86,28 @@ def __init__(self) -> None: self._items: dict[str, dict[str, Any]] = {} self._lock = threading.Lock() - def put(self, kind: str, data: dict[str, Any], *, tenant_id: str, caller_id: str) -> str: - artifact_id = f"AKTA-{kind.upper()}-{uuid.uuid4().hex[:12]}" + def put( + self, + kind: str, + data: dict[str, Any], + *, + tenant_id: str, + caller_id: str, + source_artifact_id: str | None = None, + schema_version: str | None = None, + ) -> str: + artifact_id = _collision_resistant_id(kind) + payload = _normalize_stored( + artifact_id=artifact_id, + kind=kind, + data=data, + tenant_id=tenant_id, + caller_id=caller_id, + source_artifact_id=source_artifact_id, + schema_version=schema_version, + ) with self._lock: - self._items[artifact_id] = { - "kind": kind, - "data": data, - "tenant_id": tenant_id, - "caller_id": caller_id, - "created_at": time.time(), - } + self._items[artifact_id] = payload return artifact_id def get( @@ -50,21 +121,36 @@ def get( return None if role not in {"admin", "auditor"} and item.get("caller_id") != caller_id: return None - return {"kind": item["kind"], "data": item["data"], "tenant_id": item["tenant_id"]} + return { + "kind": item["kind"], + "data": item["data"], + "tenant_id": item["tenant_id"], + "artifact_hash": item.get("artifact_hash"), + "source_artifact_id": item.get("source_artifact_id"), + "schema_version": item.get("schema_version"), + } class InMemoryAuditLedger: def __init__(self) -> None: self.entries: list[dict[str, Any]] = [] self._lock = threading.Lock() + self._prev_hash = "sha256:" + ("0" * 64) def append(self, entry: dict[str, Any]) -> None: with self._lock: - self.entries.append(dict(entry)) + record = dict(entry) + record["audit_schema_version"] = AUDIT_SCHEMA_VERSION + record["prev_hash"] = self._prev_hash + record["entry_hash"] = hash_object( + {k: v for k, v in record.items() if k != "entry_hash"} + ) + self._prev_hash = record["entry_hash"] + self.entries.append(record) class FileArtifactStore: - """Durable JSON artifact store bound to tenant + caller permissions.""" + """Durable JSON artifact store for pilot-service / development.""" def __init__(self, root: str | Path) -> None: self.root = Path(root) @@ -75,19 +161,31 @@ def _path(self, artifact_id: str) -> Path: safe = "".join(c for c in artifact_id if c.isalnum() or c in "-_") return self.root / f"{safe}.json" - def put(self, kind: str, data: dict[str, Any], *, tenant_id: str, caller_id: str) -> str: - artifact_id = f"AKTA-{kind.upper()}-{uuid.uuid4().hex[:12]}" - payload = { - "artifact_id": artifact_id, - "kind": kind, - "data": data, - "tenant_id": tenant_id, - "caller_id": caller_id, - "created_at": time.time(), - } + def put( + self, + kind: str, + data: dict[str, Any], + *, + tenant_id: str, + caller_id: str, + source_artifact_id: str | None = None, + schema_version: str | None = None, + ) -> str: + artifact_id = _collision_resistant_id(kind) + payload = _normalize_stored( + artifact_id=artifact_id, + kind=kind, + data=data, + tenant_id=tenant_id, + caller_id=caller_id, + source_artifact_id=source_artifact_id, + schema_version=schema_version, + ) path = self._path(artifact_id) + tmp = path.with_suffix(".json.tmp") with self._lock: - path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + os.replace(tmp, path) return artifact_id def get( @@ -102,29 +200,481 @@ def get( return None if role not in {"admin", "auditor"} and item.get("caller_id") != caller_id: return None - return {"kind": item["kind"], "data": item["data"], "tenant_id": item["tenant_id"]} + return { + "kind": item["kind"], + "data": item["data"], + "tenant_id": item["tenant_id"], + "artifact_hash": item.get("artifact_hash"), + "source_artifact_id": item.get("source_artifact_id"), + "schema_version": item.get("schema_version"), + } class FileAuditLedger: - """Append-only JSONL audit ledger for institutional-service mode.""" + """Append-only JSONL audit ledger for pilot-service mode.""" def __init__(self, path: str | Path) -> None: self.path = Path(path) self.path.parent.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() + self._prev_hash = self._load_tail_hash() + + def _load_tail_hash(self) -> str: + if not self.path.is_file(): + return "sha256:" + ("0" * 64) + last = "sha256:" + ("0" * 64) + with self.path.open(encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + last = str(entry.get("entry_hash") or last) + except json.JSONDecodeError: + continue + return last def append(self, entry: dict[str, Any]) -> None: - line = json.dumps(entry, default=str) + record = dict(entry) with self._lock: + record["audit_schema_version"] = AUDIT_SCHEMA_VERSION + record["prev_hash"] = self._prev_hash + record["entry_hash"] = hash_object( + {k: v for k, v in record.items() if k != "entry_hash"} + ) + self._prev_hash = record["entry_hash"] with self.path.open("a", encoding="utf-8") as fh: - fh.write(line + "\n") + fh.write(json.dumps(record, default=str) + "\n") + + +class SqliteArtifactStore: + """CI/offline fallback only — not valid for institutional-service.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._init() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.path, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def _init(self) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + tenant_id TEXT NOT NULL, + caller_id TEXT NOT NULL, + artifact_hash TEXT NOT NULL, + source_artifact_id TEXT, + schema_version TEXT NOT NULL, + created_at REAL NOT NULL, + data_json TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_artifacts_tenant ON artifacts(tenant_id)" + ) + conn.commit() + + def put( + self, + kind: str, + data: dict[str, Any], + *, + tenant_id: str, + caller_id: str, + source_artifact_id: str | None = None, + schema_version: str | None = None, + ) -> str: + artifact_id = _collision_resistant_id(kind) + payload = _normalize_stored( + artifact_id=artifact_id, + kind=kind, + data=data, + tenant_id=tenant_id, + caller_id=caller_id, + source_artifact_id=source_artifact_id, + schema_version=schema_version, + ) + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO artifacts ( + artifact_id, kind, tenant_id, caller_id, artifact_hash, + source_artifact_id, schema_version, created_at, data_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload["artifact_id"], + payload["kind"], + payload["tenant_id"], + payload["caller_id"], + payload["artifact_hash"], + payload["source_artifact_id"], + payload["schema_version"], + payload["created_at"], + json.dumps(payload["data"]), + ), + ) + conn.commit() + return artifact_id + + def get( + self, artifact_id: str, *, tenant_id: str, caller_id: str, role: str + ) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM artifacts WHERE artifact_id = ?", + (artifact_id,), + ).fetchone() + if row is None: + return None + if row["tenant_id"] != tenant_id: + return None + if role not in {"admin", "auditor"} and row["caller_id"] != caller_id: + return None + return { + "kind": row["kind"], + "data": json.loads(row["data_json"]), + "tenant_id": row["tenant_id"], + "artifact_hash": row["artifact_hash"], + "source_artifact_id": row["source_artifact_id"], + "schema_version": row["schema_version"], + } + + +class SqliteAuditLedger: + """CI/offline hash-chained audit fallback — not institutional production.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._init() + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path, check_same_thread=False) + + def _init(self) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS audit_entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + entry_hash TEXT NOT NULL UNIQUE, + prev_hash TEXT NOT NULL, + created_at REAL NOT NULL, + entry_json TEXT NOT NULL + ) + """ + ) + conn.commit() + + def append(self, entry: dict[str, Any]) -> None: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT entry_hash FROM audit_entries ORDER BY seq DESC LIMIT 1" + ).fetchone() + prev = row[0] if row else ("sha256:" + ("0" * 64)) + record = dict(entry) + record["audit_schema_version"] = AUDIT_SCHEMA_VERSION + record["prev_hash"] = prev + record["entry_hash"] = hash_object( + {k: v for k, v in record.items() if k != "entry_hash"} + ) + conn.execute( + """ + INSERT INTO audit_entries (entry_hash, prev_hash, created_at, entry_json) + VALUES (?, ?, ?, ?) + """, + ( + record["entry_hash"], + prev, + time.time(), + json.dumps(record, default=str), + ), + ) + conn.commit() + + +def _require_psycopg(): + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as exc: + raise ImportError( + "institutional PostgreSQL persistence requires psycopg. " + "Install with: pip install 'akta-protocol[rest]' (or pip install 'psycopg[binary]')" + ) from exc + return psycopg, dict_row + + +class PostgresArtifactStore: + """Institutional PostgreSQL artifact store (atomic, tenant-constrained).""" + + def __init__(self, dsn: str | None = None) -> None: + self.dsn = ( + dsn + or os.environ.get("AKTA_REST_DATABASE_URL") + or os.environ.get("DATABASE_URL") + or "" + ).strip() + if not self.dsn: + raise ValueError( + "PostgreSQL artifact store requires AKTA_REST_DATABASE_URL or DATABASE_URL" + ) + self._psycopg, self._dict_row = _require_psycopg() + self._lock = threading.Lock() + self._ensure_schema() + + def _connect(self): + return self._psycopg.connect(self.dsn, row_factory=self._dict_row) + + def _ensure_schema(self) -> None: + ddl = """ + CREATE TABLE IF NOT EXISTS akta_artifacts ( + artifact_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + tenant_id TEXT NOT NULL, + caller_id TEXT NOT NULL, + artifact_hash TEXT NOT NULL, + source_artifact_id TEXT REFERENCES akta_artifacts(artifact_id), + schema_version TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + data_json JSONB NOT NULL, + CONSTRAINT akta_artifacts_tenant_id_nonempty CHECK (tenant_id <> ''), + CONSTRAINT akta_artifacts_hash_format CHECK (artifact_hash LIKE 'sha256:%') + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_akta_artifacts_tenant_hash + ON akta_artifacts (tenant_id, artifact_hash, kind); + CREATE INDEX IF NOT EXISTS idx_akta_artifacts_tenant + ON akta_artifacts (tenant_id); + CREATE INDEX IF NOT EXISTS idx_akta_artifacts_source + ON akta_artifacts (source_artifact_id); + """ + with self._lock, self._connect() as conn: + conn.execute(ddl) + conn.commit() + + def put( + self, + kind: str, + data: dict[str, Any], + *, + tenant_id: str, + caller_id: str, + source_artifact_id: str | None = None, + schema_version: str | None = None, + ) -> str: + if not tenant_id: + raise ValueError("tenant_id required for PostgreSQL artifact writes") + artifact_id = _collision_resistant_id(kind) + payload = _normalize_stored( + artifact_id=artifact_id, + kind=kind, + data=data, + tenant_id=tenant_id, + caller_id=caller_id, + source_artifact_id=source_artifact_id, + schema_version=schema_version, + ) + with self._lock, self._connect() as conn: + with conn.transaction(): + conn.execute( + """ + INSERT INTO akta_artifacts ( + artifact_id, kind, tenant_id, caller_id, artifact_hash, + source_artifact_id, schema_version, data_json + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + payload["artifact_id"], + payload["kind"], + payload["tenant_id"], + payload["caller_id"], + payload["artifact_hash"], + payload["source_artifact_id"], + payload["schema_version"], + json.dumps(payload["data"]), + ), + ) + return artifact_id + + def get( + self, artifact_id: str, *, tenant_id: str, caller_id: str, role: str + ) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT kind, tenant_id, caller_id, artifact_hash, source_artifact_id, + schema_version, data_json + FROM akta_artifacts + WHERE artifact_id = %s AND tenant_id = %s + """, + (artifact_id, tenant_id), + ).fetchone() + if row is None: + return None + if role not in {"admin", "auditor"} and row["caller_id"] != caller_id: + return None + data = row["data_json"] + if isinstance(data, str): + data = json.loads(data) + return { + "kind": row["kind"], + "data": data, + "tenant_id": row["tenant_id"], + "artifact_hash": row["artifact_hash"], + "source_artifact_id": row["source_artifact_id"], + "schema_version": row["schema_version"], + } + + +class PostgresAuditLedger: + """Transactional hash-chained audit ledger on PostgreSQL.""" + + def __init__(self, dsn: str | None = None) -> None: + self.dsn = ( + dsn + or os.environ.get("AKTA_REST_DATABASE_URL") + or os.environ.get("DATABASE_URL") + or "" + ).strip() + if not self.dsn: + raise ValueError( + "PostgreSQL audit ledger requires AKTA_REST_DATABASE_URL or DATABASE_URL" + ) + self._psycopg, self._dict_row = _require_psycopg() + self._lock = threading.Lock() + self._ensure_schema() + + def _connect(self): + return self._psycopg.connect(self.dsn, row_factory=self._dict_row) + + def _ensure_schema(self) -> None: + ddl = """ + CREATE TABLE IF NOT EXISTS akta_audit_entries ( + seq BIGSERIAL PRIMARY KEY, + entry_hash TEXT NOT NULL UNIQUE, + prev_hash TEXT NOT NULL, + tenant_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + entry_json JSONB NOT NULL, + CONSTRAINT akta_audit_hash_format CHECK (entry_hash LIKE 'sha256:%') + ); + CREATE INDEX IF NOT EXISTS idx_akta_audit_tenant ON akta_audit_entries (tenant_id); + """ + with self._lock, self._connect() as conn: + conn.execute(ddl) + conn.commit() + + def append(self, entry: dict[str, Any]) -> None: + with self._lock, self._connect() as conn: + with conn.transaction(): + row = conn.execute( + "SELECT entry_hash FROM akta_audit_entries ORDER BY seq DESC LIMIT 1" + ).fetchone() + prev = row["entry_hash"] if row else ("sha256:" + ("0" * 64)) + record = dict(entry) + record["audit_schema_version"] = AUDIT_SCHEMA_VERSION + record["prev_hash"] = prev + record["entry_hash"] = hash_object( + {k: v for k, v in record.items() if k != "entry_hash"} + ) + conn.execute( + """ + INSERT INTO akta_audit_entries (entry_hash, prev_hash, tenant_id, entry_json) + VALUES (%s, %s, %s, %s::jsonb) + """, + ( + record["entry_hash"], + prev, + record.get("tenant_id"), + json.dumps(record, default=str), + ), + ) + + +def build_stores_for_mode( + mode: str, + *, + data_dir: str | Path, + database_url: str | None = None, +) -> tuple[Any, Any, str, str]: + """Return (artifacts, audit, artifact_kind, audit_kind) for a REST mode.""" + from adapters.generic_rest.identity import ( + DEVELOPMENT_LOCAL, + INSTITUTIONAL_SERVICE, + PILOT_SERVICE, + ) + + data_dir = Path(data_dir) + dsn = (database_url or os.environ.get("AKTA_REST_DATABASE_URL") or "").strip() + + if mode == INSTITUTIONAL_SERVICE: + artifacts = PostgresArtifactStore(dsn or None) + audit = PostgresAuditLedger(dsn or None) + return artifacts, audit, "postgresql", "postgresql" + + if mode == PILOT_SERVICE: + # Prefer file stores; allow sqlite when explicitly requested for CI. + if os.environ.get("AKTA_REST_USE_SQLITE", "").strip().lower() in ( + "1", + "true", + "yes", + ): + artifacts = SqliteArtifactStore(data_dir / "artifacts.sqlite") + audit = SqliteAuditLedger(data_dir / "audit.sqlite") + return artifacts, audit, "sqlite", "sqlite" + artifacts = FileArtifactStore(data_dir / "artifacts") + audit = FileAuditLedger(data_dir / "audit.jsonl") + return artifacts, audit, "file", "file" + + if mode != DEVELOPMENT_LOCAL: + raise ValueError(f"unknown REST mode for stores: {mode}") + return InMemoryArtifactStore(), InMemoryAuditLedger(), "memory", "memory" + + +def verify_artifact_integrity(stored: dict[str, Any], *, expected_hash: str | None = None) -> list[str]: + """Verify stored artifact hash (+ optional expected hash).""" + errors: list[str] = [] + data = stored.get("data") + if not isinstance(data, dict): + return ["artifact data missing or not an object"] + recomputed = _artifact_hash(data) + stored_hash = stored.get("artifact_hash") + if stored_hash and stored_hash != recomputed: + errors.append( + f"artifact_hash mismatch: stored={stored_hash!r} recomputed={recomputed!r}" + ) + if expected_hash and expected_hash != recomputed: + errors.append( + f"expected artifact hash mismatch: expected={expected_hash!r} got={recomputed!r}" + ) + return errors __all__ = [ + "ARTIFACT_SCHEMA_VERSION", + "AUDIT_SCHEMA_VERSION", "ArtifactStore", "AuditLedger", "FileArtifactStore", "FileAuditLedger", "InMemoryArtifactStore", "InMemoryAuditLedger", + "PostgresArtifactStore", + "PostgresAuditLedger", + "SqliteArtifactStore", + "SqliteAuditLedger", + "build_stores_for_mode", + "verify_artifact_integrity", ] From c311b1be25560325af22f9444f61e61680bddec7 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:23 -0700 Subject: [PATCH 20/26] feat(rest): enforce institutional auth on REST server paths Fail closed when identity or persistence requirements are unmet so pilot and institutional modes stay distinct. --- adapters/generic_rest/server.py | 234 +++++++++++++++++++++++++------ tests/test_rest_institutional.py | 177 +++++++++++++++++++---- 2 files changed, 341 insertions(+), 70 deletions(-) diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index cb171fc..b1b57c4 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -1,13 +1,19 @@ -"""AKTA generic REST API server (v1.0 service boundary). +"""AKTA generic REST API server (v1.0 / WS13 service boundary). -Modes (P1-C): +Modes (P1-C / WS13): - ``development-local``: loopback-friendly; in-memory stores; optional API key -- ``institutional-service``: verified identity registry, tenant binding, - persistent artifact/audit stores, trusted proxy, policy manifest verification. +- ``pilot-service``: credential-digest identity registry + file/sqlite stores + (formerly static-registry; non-institutional) +- ``institutional-service``: OIDC issuer+audience verification, PostgreSQL + artifact/audit stores, trusted proxy, policy manifest verification. Static bearer-token equality alone is rejected as institutional identity. TLS termination and trusted-proxy configuration are deployment concerns — see docs/deployment_reference.md. Beta reference service — not HA production. + +Optional institutional deps (documented in pyproject ``[rest]`` extra): +- ``PyJWT`` for OIDC JWT verification +- ``psycopg[binary]`` for PostgreSQL persistence """ from __future__ import annotations @@ -31,6 +37,7 @@ from adapters.generic_rest.identity import ( DEVELOPMENT_LOCAL, INSTITUTIONAL_SERVICE, + PILOT_SERVICE, ServiceIdentity, authenticate_request, institutional_startup_errors, @@ -38,10 +45,8 @@ resolve_service_mode, ) from adapters.generic_rest.persistence import ( - FileArtifactStore, - FileAuditLedger, - InMemoryArtifactStore, - InMemoryAuditLedger, + build_stores_for_mode, + verify_artifact_integrity, ) from adapters.pcs.export_artifact import export_pcs_bundle from adapters.pf_core.export_obligation import export_pf_obligation @@ -49,6 +54,7 @@ from akta.context import AKTAContext from akta.errors import AKTAError, SchemaValidationError from akta.gate import AKTAGate +from akta.hash import hash_object from akta.records import AKTADecision logger = logging.getLogger(__name__) @@ -191,6 +197,24 @@ def _check_auth_and_rate(self) -> bool: bearer_or_key=token, api_key=self.api_key, service_tokens=self.service_tokens, + identity_registry={}, + tenant_header=tenant, + ) + if identity is None or not identity.verified or not identity.tenant_id: + self._send_error_json( + HTTPStatus.UNAUTHORIZED, + "unauthorized", + "Verified OIDC identity with tenant binding required", + ) + self._audit("auth_rejected", reason="institutional_oidc_required") + return False + self._resolved_identity = identity + elif self.service_mode == PILOT_SERVICE: + identity = authenticate_request( + mode=PILOT_SERVICE, + bearer_or_key=token, + api_key=self.api_key, + service_tokens=self.service_tokens, identity_registry=self.identity_registry, tenant_header=tenant, ) @@ -198,9 +222,9 @@ def _check_auth_and_rate(self) -> bool: self._send_error_json( HTTPStatus.UNAUTHORIZED, "unauthorized", - "Verified service identity with tenant binding required", + "Verified pilot-service identity with tenant binding required", ) - self._audit("auth_rejected", reason="institutional_identity_required") + self._audit("auth_rejected", reason="pilot_identity_required") return False self._resolved_identity = identity else: @@ -338,18 +362,32 @@ def _gate(self) -> AKTAGate: self.gate = AKTAGate.from_policy_dir(self.policy_dir, overlays_dir=self.overlays_dir) return self.gate - def _store_artifact(self, kind: str, data: dict[str, Any]) -> str: + def _store_artifact( + self, + kind: str, + data: dict[str, Any], + *, + source_artifact_id: str | None = None, + ) -> str: identity = self._identity() tenant_id = identity.tenant_id if identity else "dev" caller_id = identity.caller_id if identity else "anonymous" if self.artifacts is not None: - return self.artifacts.put(kind, data, tenant_id=tenant_id, caller_id=caller_id) + return self.artifacts.put( + kind, + data, + tenant_id=tenant_id, + caller_id=caller_id, + source_artifact_id=source_artifact_id, + ) artifact_id = f"AKTA-{kind.upper()}-{uuid.uuid4().hex[:12]}" self.artifact_store[artifact_id] = { "kind": kind, "data": data, "tenant_id": tenant_id, "caller_id": caller_id, + "artifact_hash": hash_object(data), + "source_artifact_id": source_artifact_id, } return artifact_id @@ -497,16 +535,7 @@ def _handle_evaluate(self, body: dict[str, Any]) -> None: self._send_json(HTTPStatus.OK, d) def _handle_records(self, body: dict[str, Any]) -> None: - if "decision" in body: - decision = AKTADecision(body["decision"]) - elif "decision_artifact_id" in body: - stored = self._load_artifact(str(body["decision_artifact_id"])) - if not stored or stored.get("kind") != "decision": - raise ValueError("unknown decision_artifact_id") - decision = AKTADecision(stored["data"]) - else: - raise ValueError("Provide 'decision' object or 'decision_artifact_id'.") - + decision, source_artifact_id = self._resolve_canonical_decision(body) context = body.get("context", {}) record = decision.to_record(ai_output=body.get("ai_output"), context=context) if body.get("validate", True): @@ -514,11 +543,83 @@ def _handle_records(self, body: dict[str, Any]) -> None: validate_against_schema(record.to_dict(), "akta_record.schema.json") payload = record.to_dict() - artifact_id = self._store_artifact("record", payload) + artifact_id = self._store_artifact( + "record", payload, source_artifact_id=source_artifact_id + ) payload["artifact_id"] = artifact_id self._audit("record_created", artifact_id=artifact_id) self._send_json(HTTPStatus.OK, payload) + def _resolve_canonical_decision( + self, body: dict[str, Any] + ) -> tuple[AKTADecision, str | None]: + """Load decision for /records. + + Institutional mode requires ``decision_artifact_id`` pointing at a stored + canonical decision; arbitrary decision bodies are rejected. Hash and + policy provenance are verified before recording. + """ + artifact_id = body.get("decision_artifact_id") + if self.service_mode == INSTITUTIONAL_SERVICE: + if "decision" in body and not artifact_id: + raise ValueError( + "institutional_mode_rejects_inline_decision:" + "provide decision_artifact_id for the canonical stored decision" + ) + if not artifact_id: + raise ValueError("decision_artifact_id is required in institutional-service mode") + return self._load_verified_decision(str(artifact_id)), str(artifact_id) + + if artifact_id: + return self._load_verified_decision(str(artifact_id)), str(artifact_id) + if "decision" in body: + return AKTADecision(body["decision"]), None + raise ValueError("Provide 'decision' object or 'decision_artifact_id'.") + + def _load_verified_decision(self, artifact_id: str) -> AKTADecision: + stored = self._load_artifact(artifact_id) + if not stored or stored.get("kind") != "decision": + raise ValueError("unknown decision_artifact_id") + errors = verify_artifact_integrity(stored) + data = stored.get("data") + if not isinstance(data, dict): + raise ValueError("stored decision is not an object") + policy = data.get("policy") or {} + if self.service_mode == INSTITUTIONAL_SERVICE: + if not policy.get("effective_policy_hash"): + errors.append("stored decision missing policy.effective_policy_hash") + if not policy.get("manifest_hash"): + errors.append("stored decision missing policy.manifest_hash") + if not ( + policy.get("shared_contract_release_hash") + or (policy.get("schema_hashes") or {}) + ): + errors.append("stored decision missing shared-contract / schema provenance") + if errors: + raise ValueError("decision_artifact_integrity_failed:" + ";".join(errors)) + return AKTADecision(data) + + def _load_verified_record(self, artifact_id: str) -> dict[str, Any]: + stored = self._load_artifact(artifact_id) + if not stored or stored.get("kind") != "record": + raise ValueError("unknown record_artifact_id") + errors = verify_artifact_integrity(stored) + data = stored.get("data") + if not isinstance(data, dict): + raise ValueError("stored record is not an object") + policy = data.get("policy") or (data.get("provenance") or {}).get("policy") or {} + if not policy.get("effective_policy_hash"): + errors.append("stored record missing policy provenance hash") + decision_ref = data.get("decision_id") or data.get("decision_artifact_id") + if stored.get("source_artifact_id") is None and not decision_ref: + errors.append("stored record missing decision linkage") + if errors: + raise ValueError("record_artifact_integrity_failed:" + ";".join(errors)) + from akta.records import validate_against_schema + + validate_against_schema(data, "akta_record.schema.json") + return data + def _handle_card_validate(self, body: dict[str, Any]) -> None: card = body.get("card") if not isinstance(card, dict): @@ -530,13 +631,13 @@ def _handle_card_validate(self, body: dict[str, Any]) -> None: ) def _handle_export_pcs(self, body: dict[str, Any]) -> None: - record_data = body.get("record") - if not isinstance(record_data, dict): - raise ValueError("Field 'record' must be an AKTA Record object.") + record_data, source_id = self._resolve_export_record(body) with tempfile.TemporaryDirectory() as tmp: export_pcs_bundle(record_data, Path(tmp)) manifest = json.loads((Path(tmp) / "manifest.json").read_text(encoding="utf-8")) - artifact_id = self._store_artifact("pcs_manifest", manifest) + artifact_id = self._store_artifact( + "pcs_manifest", manifest, source_artifact_id=source_id + ) self._audit("export_pcs", artifact_id=artifact_id) self._send_json( HTTPStatus.OK, @@ -544,19 +645,36 @@ def _handle_export_pcs(self, body: dict[str, Any]) -> None: ) def _handle_export_pf(self, body: dict[str, Any]) -> None: - record_data = body.get("record") - if not isinstance(record_data, dict): - raise ValueError("Field 'record' must be an AKTA Record object.") + record_data, source_id = self._resolve_export_record(body) with tempfile.TemporaryDirectory() as tmp: path = export_pf_obligation(record_data, tmp) obligation = json.loads(path.read_text(encoding="utf-8")) - artifact_id = self._store_artifact("pf_obligation", obligation) + artifact_id = self._store_artifact( + "pf_obligation", obligation, source_artifact_id=source_id + ) self._audit("export_pf", artifact_id=artifact_id) self._send_json( HTTPStatus.OK, {"exported": True, "obligation": obligation, "artifact_id": artifact_id}, ) + def _resolve_export_record(self, body: dict[str, Any]) -> tuple[dict[str, Any], str | None]: + """PF/PCS export: prefer record_artifact_id → validate → verify → export.""" + record_id = body.get("record_artifact_id") + if record_id: + return self._load_verified_record(str(record_id)), str(record_id) + if self.service_mode == INSTITUTIONAL_SERVICE: + raise ValueError( + "institutional export requires record_artifact_id " + "(inline record bodies are rejected)" + ) + record_data = body.get("record") + if not isinstance(record_data, dict): + raise ValueError( + "Provide 'record_artifact_id' or inline 'record' (non-institutional only)." + ) + return record_data, None + def create_server( host: str = "127.0.0.1", @@ -614,16 +732,32 @@ def create_server( or os.environ.get("AKTA_REST_DATA_DIR") or (tempfile.gettempdir() + "/akta-rest-data") ) + + oidc_configured = bool( + os.environ.get("AKTA_OIDC_ISSUER", "").strip() + and os.environ.get("AKTA_OIDC_AUDIENCE", "").strip() + ) + db_url = (os.environ.get("AKTA_REST_DATABASE_URL") or os.environ.get("DATABASE_URL") or "").strip() + + # Preflight institutional requirements before constructing stores (clearer errors). if mode == INSTITUTIONAL_SERVICE: - artifacts: Any = FileArtifactStore(resolved_data / "artifacts") - audit: Any = FileAuditLedger(resolved_data / "audit.jsonl") - artifact_kind = "file" - audit_kind = "file" - else: - artifacts = InMemoryArtifactStore() - audit = InMemoryAuditLedger() - artifact_kind = "memory" - audit_kind = "memory" + preflight = institutional_startup_errors( + mode=mode, + identity_registry=identity_registry, + artifact_store_kind="postgresql" if db_url else "missing", + audit_ledger_kind="postgresql" if db_url else "missing", + trusted_proxy=bool(trust_proxy), + policy_manifest_verified=_verify_policy_manifest(policy_dir), + oidc_configured=oidc_configured, + ) + if preflight: + raise ValueError("institutional-service startup rejected: " + "; ".join(preflight)) + + artifacts, audit, artifact_kind, audit_kind = build_stores_for_mode( + mode, + data_dir=resolved_data, + database_url=db_url or None, + ) manifest_ok = True if mode == INSTITUTIONAL_SERVICE: @@ -636,15 +770,27 @@ def create_server( audit_ledger_kind=audit_kind, trusted_proxy=bool(trust_proxy), policy_manifest_verified=manifest_ok, + oidc_configured=oidc_configured if mode == INSTITUTIONAL_SERVICE else None, ) if startup_errors: - raise ValueError("institutional-service startup rejected: " + "; ".join(startup_errors)) + label = ( + "institutional-service" + if mode == INSTITUTIONAL_SERVICE + else "pilot-service" + if mode == PILOT_SERVICE + else "rest" + ) + raise ValueError(f"{label} startup rejected: " + "; ".join(startup_errors)) if not _is_loopback(host) and not ( - resolved_api_key or tokens or identity_registry.get("identities") + resolved_api_key + or tokens + or identity_registry.get("identities") + or (mode == INSTITUTIONAL_SERVICE and oidc_configured) ): raise ValueError( - "Non-loopback REST bind requires AKTA_REST_API_KEY, service tokens, or identity registry" + "Non-loopback REST bind requires AKTA_REST_API_KEY, service tokens, " + "identity registry, or OIDC configuration" ) class Handler(AKTARESTHandler): @@ -678,7 +824,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--production", action="store_true") parser.add_argument( "--mode", - choices=["development-local", "institutional-service"], + choices=["development-local", "pilot-service", "institutional-service"], default=None, ) parser.add_argument("--data-dir", default=None) diff --git a/tests/test_rest_institutional.py b/tests/test_rest_institutional.py index bb06352..bd335c5 100644 --- a/tests/test_rest_institutional.py +++ b/tests/test_rest_institutional.py @@ -1,20 +1,28 @@ -"""P1-C institutional REST mode and tenant-bound artifact tests.""" +"""P1-C / WS13 institutional REST mode, OIDC, and store tests.""" from __future__ import annotations import hashlib import json import threading +import time from pathlib import Path import pytest from adapters.generic_rest.identity import ( INSTITUTIONAL_SERVICE, + PILOT_SERVICE, + authenticate_oidc_request, authenticate_request, institutional_startup_errors, + resolve_service_mode, +) +from adapters.generic_rest.persistence import ( + FileArtifactStore, + SqliteArtifactStore, + verify_artifact_integrity, ) -from adapters.generic_rest.persistence import FileArtifactStore from adapters.generic_rest.server import create_server ROOT = Path(__file__).resolve().parent.parent @@ -24,6 +32,11 @@ def _sha(token: str) -> str: return "sha256:" + hashlib.sha256(token.encode("utf-8")).hexdigest() +def test_static_registry_aliases_to_pilot_service() -> None: + assert resolve_service_mode("static-registry") == PILOT_SERVICE + assert resolve_service_mode("pilot-service") == PILOT_SERVICE + + def test_static_bearer_rejected_as_institutional_identity() -> None: identity = authenticate_request( mode=INSTITUTIONAL_SERVICE, @@ -36,10 +49,27 @@ def test_static_bearer_rejected_as_institutional_identity() -> None: assert identity is None -def test_institutional_startup_requires_controls() -> None: +def test_institutional_startup_requires_oidc_and_postgres() -> None: errors = institutional_startup_errors( mode=INSTITUTIONAL_SERVICE, identity_registry={"identities": {}}, + artifact_store_kind="file", + audit_ledger_kind="file", + trusted_proxy=False, + policy_manifest_verified=False, + oidc_configured=False, + ) + assert any("OIDC" in e for e in errors) + assert any("PostgreSQL artifact" in e for e in errors) + assert any("PostgreSQL audit" in e for e in errors) + assert any("trusted proxy" in e for e in errors) + assert any("policy manifest" in e for e in errors) + + +def test_pilot_startup_requires_registry_and_file_store() -> None: + errors = institutional_startup_errors( + mode=PILOT_SERVICE, + identity_registry={"identities": {}}, artifact_store_kind="memory", audit_ledger_kind="memory", trusted_proxy=False, @@ -47,9 +77,6 @@ def test_institutional_startup_requires_controls() -> None: ) assert any("identity registry" in e for e in errors) assert any("artifact store" in e for e in errors) - assert any("audit ledger" in e for e in errors) - assert any("trusted proxy" in e for e in errors) - assert any("policy manifest" in e for e in errors) def test_file_artifact_store_tenant_binding(tmp_path: Path) -> None: @@ -60,11 +87,27 @@ def test_file_artifact_store_tenant_binding(tmp_path: Path) -> None: got = store.get(aid, tenant_id="t1", caller_id="c1", role="evaluator") assert got is not None assert got["data"]["ok"] is True + assert got["artifact_hash"].startswith("sha256:") + assert verify_artifact_integrity(got) == [] assert store.get(aid, tenant_id="t1", caller_id="c2", role="auditor") is not None +def test_sqlite_fallback_store(tmp_path: Path) -> None: + store = SqliteArtifactStore(tmp_path / "artifacts.sqlite") + aid = store.put( + "decision", + {"policy": {"effective_policy_hash": "sha256:abc"}}, + tenant_id="t1", + caller_id="c1", + source_artifact_id=None, + ) + got = store.get(aid, tenant_id="t1", caller_id="c1", role="evaluator") + assert got is not None + assert got["schema_version"] + + def test_institutional_create_server_fail_closed(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="institutional-service startup rejected"): + with pytest.raises(ValueError, match="startup rejected|DATABASE_URL|PostgreSQL"): create_server( "127.0.0.1", 0, @@ -75,11 +118,31 @@ def test_institutional_create_server_fail_closed(tmp_path: Path) -> None: ) -def test_institutional_server_with_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_oidc_identity_from_claims() -> None: + identity = authenticate_oidc_request( + bearer_token=None, + tenant_header="lab-a", + oidc_claims={ + "sub": "user-1", + "tenant_id": "lab-a", + "role": "recorder", + "iss": "https://issuer.example", + "aud": "akta-rest", + "exp": time.time() + 3600, + }, + ) + assert identity is not None + assert identity.verified + assert identity.auth_method == "oidc" + assert identity.tenant_id == "lab-a" + assert "records" in identity.permissions + + +def test_pilot_server_with_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: import urllib.error import urllib.request - token = "institutional-caller-secret" + token = "pilot-caller-secret" registry = { "version": "akta-rest-identity-v1", "identities": { @@ -100,19 +163,12 @@ def test_institutional_server_with_registry(tmp_path: Path, monkeypatch: pytest. reg_path = tmp_path / "identities.json" reg_path.write_text(json.dumps(registry), encoding="utf-8") - # Bypass real policy crypto for unit test by stubbing verifier. - monkeypatch.setattr( - "adapters.generic_rest.server._verify_policy_manifest", - lambda _p: True, - ) - server = create_server( "127.0.0.1", 0, ROOT / "policy", ROOT / "overlays", - service_mode=INSTITUTIONAL_SERVICE, - trusted_proxy=True, + service_mode=PILOT_SERVICE, data_dir=tmp_path / "data", identity_registry_path=reg_path, ) @@ -125,14 +181,6 @@ def test_institutional_server_with_registry(tmp_path: Path, monkeypatch: pytest. urllib.request.urlopen(f"{url}/v0/health") assert exc.value.code == 401 - # Static API key equality must not authenticate institutional callers. - req_bad = urllib.request.Request( - f"{url}/v0/health", - headers={"Authorization": "Bearer anything", "X-AKTA-Tenant": "lab-a"}, - ) - with pytest.raises(urllib.error.HTTPError): - urllib.request.urlopen(req_bad) - req = urllib.request.Request( f"{url}/v0/health", headers={ @@ -142,7 +190,84 @@ def test_institutional_server_with_registry(tmp_path: Path, monkeypatch: pytest. ) with urllib.request.urlopen(req) as resp: data = json.loads(resp.read().decode("utf-8")) - assert data["service_mode"] == INSTITUTIONAL_SERVICE + assert data["service_mode"] == PILOT_SERVICE assert data["beta"] is True finally: server.shutdown() + + +def test_institutional_server_rejects_inline_decision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Institutional /records must not accept arbitrary decision bodies.""" + import urllib.error + import urllib.request + + from adapters.generic_rest import persistence as persistence_mod + from adapters.generic_rest.persistence import InMemoryArtifactStore, InMemoryAuditLedger + + monkeypatch.setenv("AKTA_OIDC_ISSUER", "https://issuer.example") + monkeypatch.setenv("AKTA_OIDC_AUDIENCE", "akta-rest") + monkeypatch.setenv("AKTA_REST_DATABASE_URL", "postgresql://ci:ci@localhost:5432/akta_test") + monkeypatch.setattr( + "adapters.generic_rest.server._verify_policy_manifest", + lambda _p: True, + ) + + mem_artifacts = InMemoryArtifactStore() + mem_audit = InMemoryAuditLedger() + + def _fake_stores(mode, *, data_dir, database_url=None): + return mem_artifacts, mem_audit, "postgresql", "postgresql" + + monkeypatch.setattr(persistence_mod, "build_stores_for_mode", _fake_stores) + monkeypatch.setattr( + "adapters.generic_rest.server.build_stores_for_mode", + _fake_stores, + ) + monkeypatch.setattr( + "adapters.generic_rest.identity.authenticate_oidc_request", + lambda **kwargs: authenticate_oidc_request( + bearer_token=None, + tenant_header=kwargs.get("tenant_header"), + oidc_claims={ + "sub": "oidc-user", + "tenant_id": "lab-a", + "role": "admin", + "iss": "https://issuer.example", + "aud": "akta-rest", + }, + ), + ) + + server = create_server( + "127.0.0.1", + 0, + ROOT / "policy", + ROOT / "overlays", + service_mode=INSTITUTIONAL_SERVICE, + trusted_proxy=True, + data_dir=tmp_path / "data", + ) + host, port = server.server_address + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://{host}:{port}" + try: + req = urllib.request.Request( + f"{url}/v0/records", + data=json.dumps({"decision": {"admissibility": "allowed"}}).encode("utf-8"), + headers={ + "Authorization": "Bearer fake-oidc", + "X-AKTA-Tenant": "lab-a", + "Content-Type": "application/json", + }, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as exc: + urllib.request.urlopen(req) + assert exc.value.code == 400 + body = json.loads(exc.value.read().decode("utf-8")) + assert "institutional_mode_rejects_inline_decision" in (body.get("detail") or "") + finally: + server.shutdown() From e745493f2134af57eb08a5334a63ebedff362d3e Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:30 -0700 Subject: [PATCH 21/26] feat(release): expand release pins for institutional gates Pin policy artifacts required for institutional release so builds cannot ship with drifted trust material. --- akta/release_pins.py | 92 +++++++++++++++++++++++++++++++++++--- policy/release_pins.yaml | 36 ++++++++++----- tests/test_release_pins.py | 89 +++++++++++++++++++++++++++++++++--- 3 files changed, 192 insertions(+), 25 deletions(-) diff --git a/akta/release_pins.py b/akta/release_pins.py index ac36002..7a6a023 100644 --- a/akta/release_pins.py +++ b/akta/release_pins.py @@ -1,17 +1,19 @@ -"""Pinned sibling commits for institutional full-stack release (P1-A).""" +"""Pinned sibling commits for institutional full-stack release (P1-A / WS12).""" from __future__ import annotations +import json import os from pathlib import Path from typing import Any import yaml -from akta.sibling_repos import discover_sibling +from akta.sibling_repos import checkout_matches_pin, discover_sibling, git_rev_parse_head _AKTA_ROOT = Path(__file__).resolve().parent.parent DEFAULT_PINS_PATH = _AKTA_ROOT / "policy" / "release_pins.yaml" +DEFAULT_SHARED_PIN = _AKTA_ROOT / "policy" / "shared_contracts_pin.json" INSTITUTIONAL_REJECTS = frozenset( { @@ -24,6 +26,8 @@ "missing_scientific_memory_integration", "empty_issuer_trust_registry", "unpinned_or_placeholder_required_sibling", + "sibling_checkout_sha_mismatch", + "shared_contracts_release_hash_mismatch", } ) @@ -43,14 +47,60 @@ def _is_placeholder_commit(commit: Any) -> bool: return "PLACEHOLDER" in upper or "UNAVAILABLE" in upper or text == "0" * 40 +def _shared_contracts_pin_errors(pins: dict[str, Any]) -> list[str]: + """Verify release_pins shared_contracts hash matches policy pin file.""" + errors: list[str] = [] + declared = pins.get("shared_contracts") or {} + if not isinstance(declared, dict) or not declared: + return errors + expected_hash = str(declared.get("release_artifact_hash") or "").strip() + pin_file = Path(declared.get("pin_file") or DEFAULT_SHARED_PIN) + if not pin_file.is_absolute(): + pin_file = _AKTA_ROOT / pin_file + if not pin_file.is_file(): + errors.append( + "shared_contracts_release_hash_mismatch: missing pin file " + f"{pin_file}" + ) + return errors + try: + pin_data = json.loads(pin_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"shared_contracts_release_hash_mismatch: unreadable pin ({exc})") + return errors + actual = str(pin_data.get("release_artifact_hash") or "").strip() + if expected_hash and actual and expected_hash != actual: + errors.append( + "shared_contracts_release_hash_mismatch: " + f"release_pins={expected_hash!r} shared_contracts_pin={actual!r}" + ) + pkg_ver = str(declared.get("package_version") or "") + pin_ver = str(pin_data.get("package_version") or "") + if pkg_ver and pin_ver and pkg_ver != pin_ver: + errors.append( + "shared_contracts_release_hash_mismatch: package_version " + f"release_pins={pkg_ver!r} pin={pin_ver!r}" + ) + return errors + + def component_pin_errors( pins: dict[str, Any] | None = None, *, require_checkouts: bool = False, + require_sha_match: bool | None = None, ) -> list[str]: - """Return fail-closed errors for institutional pin integrity.""" + """Return fail-closed errors for institutional pin integrity. + + When checkouts are required (or ``require_sha_match``), compare + ``git rev-parse HEAD`` to the pinned commit — directory existence alone + is insufficient (WS12). + """ data = pins or load_release_pins() errors: list[str] = [] + errors.extend(_shared_contracts_pin_errors(data)) + if require_sha_match is None: + require_sha_match = require_checkouts components = data.get("components") or {} for name, entry in components.items(): if not isinstance(entry, dict): @@ -65,11 +115,24 @@ def component_pin_errors( "(institutional_release fail-closed)" ) continue + env_var = str(entry.get("env_var") or "") + found = discover_sibling(env_var) if env_var else None if require_checkouts and required: - env_var = str(entry.get("env_var") or "") - found = discover_sibling(env_var) if env_var else None if found is None and not os.environ.get(env_var, "").strip(): errors.append(f"missing_required_sibling_checkout:{name} ({env_var})") + continue + if require_sha_match and required and found is not None and not _is_placeholder_commit(commit): + head = git_rev_parse_head(found) + if head is None: + errors.append( + f"sibling_checkout_sha_mismatch:{name} " + f"(unable to read git HEAD at {found})" + ) + elif not checkout_matches_pin(found, str(commit)): + errors.append( + f"sibling_checkout_sha_mismatch:{name} " + f"pinned={commit} head={head}" + ) return errors @@ -78,6 +141,7 @@ def institutional_release_gate( pins: dict[str, Any] | None = None, flags: dict[str, bool] | None = None, require_checkouts: bool = True, + require_sha_match: bool | None = None, ) -> dict[str, Any]: """Evaluate institutional release rejects; fail closed on any violation. @@ -87,7 +151,13 @@ def institutional_release_gate( rejects = set(data.get("rejects") or []) | set(INSTITUTIONAL_REJECTS) active_flags = dict(flags or {}) errors: list[str] = [] - errors.extend(component_pin_errors(data, require_checkouts=require_checkouts)) + errors.extend( + component_pin_errors( + data, + require_checkouts=require_checkouts, + require_sha_match=require_sha_match, + ) + ) for key in sorted(rejects): if active_flags.get(key): @@ -98,6 +168,7 @@ def institutional_release_gate( "ok": not errors, "errors": errors, "pins_source": data.get("_source"), + "shared_contracts": data.get("shared_contracts"), "components": { name: { "commit": (entry or {}).get("commit"), @@ -119,7 +190,7 @@ def institutional_release_gate( def pins_for_release_report(pins: dict[str, Any] | None = None) -> dict[str, Any]: """Compact SHA table for release reports.""" data = pins or load_release_pins() - return { + report = { name: { "commit": entry.get("commit"), "tag": entry.get("tag"), @@ -128,6 +199,13 @@ def pins_for_release_report(pins: dict[str, Any] | None = None) -> dict[str, Any for name, entry in (data.get("components") or {}).items() if isinstance(entry, dict) } + shared = data.get("shared_contracts") + if isinstance(shared, dict): + report["shared_contracts"] = { + "package_version": shared.get("package_version"), + "release_artifact_hash": shared.get("release_artifact_hash"), + } + return report __all__ = [ diff --git a/policy/release_pins.yaml b/policy/release_pins.yaml index 6147026..579838d 100644 --- a/policy/release_pins.yaml +++ b/policy/release_pins.yaml @@ -1,16 +1,25 @@ -# Institutional full-stack release pins (P1-A). -# Recorded SHAs are discovery-time pins from local sibling checkouts. -# Missing components keep placeholder pins and remain fail-closed under -# institutional_release profile — never silently optionalized. +# Institutional full-stack release pins (P1-A / WS12). +# Recorded SHAs are discovery-time pins from sibling checkouts / public remotes. +# Missing checkouts or SHA mismatches remain fail-closed under +# institutional_release — never silently optionalized. +# +# Checkout existence alone is insufficient: akta.release_pins / sibling_repos +# compare `git rev-parse HEAD` to the pinned commit. # # Beta: pins enable reproducible institutional validation; they do not certify # production readiness for SCOPE-authorized action. -version: akta-release-pins-v1 +version: akta-release-pins-v1.1 profile: institutional_release -recorded_at: "2026-07-23T00:00:00Z" +recorded_at: "2026-07-23T16:40:00Z" recorded_host: local-discovery +shared_contracts: + package: scientific-authorization-contracts + package_version: "2.1.0" + release_artifact_hash: "sha256:d375614f55f8d1a5a9bef5a25fd01591ee2f57ee857e7fa0ad72d7ade950c771" + pin_file: policy/shared_contracts_pin.json + components: SCOPE: env_var: SCOPE_REPO_PATH @@ -50,14 +59,15 @@ components: VSA: env_var: VSA_REPO_PATH path_candidates: ["verified-science-agent", "VSA", "vsa"] - commit: "UNAVAILABLE-PLACEHOLDER-FAIL-CLOSED" - tag: null + commit: 903d834f201535ac1525e3e8907254363209904f + tag: v0.7.2 required: true - status: missing_placeholder + status: pinned note: | - No local VSA sibling discovered. Institutional release must fail closed - until a real commit SHA is pinned — do not treat VSA-only synthetic - validation as sufficient. + Pinned from public sibling remote + https://github.com/fraware/verified-science-agent.git tag v0.7.2. + Institutional release still fail-closes on missing VSA checkout or + HEAD/SHA mismatch — never treat synthetic VSA-only validation as enough. # Hard rejects under institutional_release (P1-A acceptance criteria). rejects: @@ -70,3 +80,5 @@ rejects: - missing_scientific_memory_integration - empty_issuer_trust_registry - unpinned_or_placeholder_required_sibling + - sibling_checkout_sha_mismatch + - shared_contracts_release_hash_mismatch diff --git a/tests/test_release_pins.py b/tests/test_release_pins.py index e7418cf..e54d474 100644 --- a/tests/test_release_pins.py +++ b/tests/test_release_pins.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + from akta.release_pins import ( INSTITUTIONAL_REJECTS, component_pin_errors, @@ -10,6 +12,9 @@ pins_for_release_report, ) +_SHA40 = re.compile(r"^[0-9a-f]{40}$") +_VSA_PIN = "903d834f201535ac1525e3e8907254363209904f" + def test_release_pins_record_sibling_shas() -> None: pins = load_release_pins() @@ -19,14 +24,80 @@ def test_release_pins_record_sibling_shas() -> None: assert components["PCS-Core"]["commit"].startswith("b4ae0c7") assert components["PCS-Bench"]["commit"].startswith("a3a080e") assert components["Scientific-Memory"]["commit"].startswith("784140b") - assert components["VSA"]["status"] == "missing_placeholder" + assert components["VSA"]["status"] == "pinned" + assert components["VSA"]["tag"] == "v0.7.2" + assert components["VSA"]["commit"] == _VSA_PIN + assert _SHA40.match(str(components["VSA"]["commit"])) + shared = pins["shared_contracts"] + assert shared["package_version"] == "2.1.0" + assert shared["release_artifact_hash"].startswith("sha256:") -def test_placeholder_vsa_fails_institutional_gate() -> None: +def test_institutional_gate_ok_without_checkouts_when_pins_real() -> None: result = institutional_release_gate(require_checkouts=False, flags={}) - assert not result["ok"] - assert any("VSA" in e for e in result["errors"]) + assert result["ok"] is True + assert not any("VSA" in e for e in result["errors"]) assert result["beta"] is True + assert result["components"]["VSA"]["status"] == "pinned" + + +def test_missing_vsa_checkout_fails_when_required(monkeypatch) -> None: + from akta import release_pins as rp + + for key in ( + "VSA_REPO_PATH", + "SCOPE_REPO_PATH", + "PF_CORE_REPO_PATH", + "PCS_CORE_REPO_PATH", + "PCS_BENCH_REPO_PATH", + "MEMORY_REPO_PATH", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(rp, "discover_sibling", lambda _env: None) + errors = component_pin_errors(require_checkouts=True, require_sha_match=False) + assert any("missing_required_sibling_checkout:VSA" in e for e in errors) + + +def test_vsa_sha_mismatch_flagged(tmp_path, monkeypatch) -> None: + from akta import release_pins as rp + + fake_repo = tmp_path / "verified-science-agent" + fake_repo.mkdir() + (fake_repo / ".git").mkdir() + monkeypatch.setenv("VSA_REPO_PATH", str(fake_repo)) + monkeypatch.setattr(rp, "git_rev_parse_head", lambda _p: "deadbeef" * 5) + monkeypatch.setattr(rp, "checkout_matches_pin", lambda _p, _c: False) + pins = load_release_pins() + errors = component_pin_errors(pins, require_checkouts=True, require_sha_match=True) + assert any("sibling_checkout_sha_mismatch:VSA" in e for e in errors) + + +def test_sibling_sha_mismatch_flagged(tmp_path, monkeypatch) -> None: + from akta import release_pins as rp + + fake_repo = tmp_path / "SCOPE" + fake_repo.mkdir() + (fake_repo / ".git").mkdir() + monkeypatch.setenv("SCOPE_REPO_PATH", str(fake_repo)) + monkeypatch.setattr(rp, "git_rev_parse_head", lambda _p: "deadbeef" * 5) + pins = load_release_pins() + errors = component_pin_errors(pins, require_checkouts=True, require_sha_match=True) + assert any("sibling_checkout_sha_mismatch:SCOPE" in e for e in errors) + + +def test_synthetic_placeholder_commit_still_fail_closed() -> None: + pins = load_release_pins() + bad = dict(pins) + components = dict(pins["components"]) + vsa = dict(components["VSA"]) + vsa["commit"] = "UNAVAILABLE-PLACEHOLDER-FAIL-CLOSED" + vsa["status"] = "missing_placeholder" + components["VSA"] = vsa + bad["components"] = components + errors = component_pin_errors(bad, require_checkouts=False) + assert any("unpinned_or_placeholder_required_sibling:VSA" in e for e in errors) + gated = institutional_release_gate(pins=bad, require_checkouts=False, flags={}) + assert not gated["ok"] def test_institutional_rejects_simulated_and_mocks() -> None: @@ -38,6 +109,8 @@ def test_institutional_rejects_simulated_and_mocks() -> None: "missing_pcs_bench", "synthetic_vsa_only_validation", "missing_scientific_memory_integration", + "sibling_checkout_sha_mismatch", + "shared_contracts_release_hash_mismatch", ): assert key in INSTITUTIONAL_REJECTS gated = institutional_release_gate( @@ -52,8 +125,12 @@ def test_pins_for_release_report_shape() -> None: report = pins_for_release_report() assert "SCOPE" in report assert "commit" in report["SCOPE"] + assert report["VSA"]["status"] == "pinned" + assert report["VSA"]["commit"] == _VSA_PIN + assert "shared_contracts" in report -def test_component_pin_errors_without_checkouts_still_flags_placeholder() -> None: +def test_component_pin_errors_without_checkouts_accepts_real_pins() -> None: errors = component_pin_errors(require_checkouts=False) - assert any("VSA" in e for e in errors) + assert not any("VSA" in e for e in errors) + assert not any("unpinned_or_placeholder" in e for e in errors) From 8ee2edb3fde40ec778fd7e13b1598a9b7d1035ca Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:37 -0700 Subject: [PATCH 22/26] feat(release): add wheel smoke and release evidence reporting Give release gates a reproducible smoke path and structured evidence report for institutional audits. --- akta/cli.py | 25 ++ akta/wheel_smoke.py | 432 +++++++++++++++++++++++ scripts/build_release_evidence_report.py | 326 +++++++++++++++++ 3 files changed, 783 insertions(+) create mode 100644 akta/wheel_smoke.py create mode 100644 scripts/build_release_evidence_report.py diff --git a/akta/cli.py b/akta/cli.py index 8fbe5a7..aeb1657 100644 --- a/akta/cli.py +++ b/akta/cli.py @@ -114,6 +114,22 @@ def cmd_review_trigger_export(args: argparse.Namespace) -> int: return 0 +def cmd_migrate_legacy_grant(args: argparse.Namespace) -> int: + """Explicit legacy → scope-grant-v2 migration (AKTA 1.1 WS4).""" + from akta.scope_grant_verifier import migrate_legacy_grant_to_v2 + + raw = _load_json(args.grant) + if not isinstance(raw, dict): + print("Grant file must contain a JSON object", file=sys.stderr) + return 1 + migrated = migrate_legacy_grant_to_v2(raw, grant_version=args.grant_version) + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(migrated, indent=2), encoding="utf-8") + print(json.dumps({"ok": True, "out": str(out_path), "grant_version": args.grant_version})) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="akta", description="AKTA v0.2 — Open Scientific Action Protocol") sub = parser.add_subparsers(dest="command", required=True) @@ -177,6 +193,15 @@ def build_parser() -> argparse.ArgumentParser: rev_export.add_argument("--out", required=True) rev_export.set_defaults(func=cmd_review_trigger_export) + migrate = sub.add_parser( + "migrate-legacy-grant", + help="Migrate a legacy SCOPE grant to scope-grant-v2 (explicit CLI only)", + ) + migrate.add_argument("--grant", required=True, help="Legacy grant JSON path") + migrate.add_argument("--out", required=True, help="Migrated grant JSON path") + migrate.add_argument("--grant-version", default="2.0.0") + migrate.set_defaults(func=cmd_migrate_legacy_grant) + return parser diff --git a/akta/wheel_smoke.py b/akta/wheel_smoke.py new file mode 100644 index 0000000..60eb22f --- /dev/null +++ b/akta/wheel_smoke.py @@ -0,0 +1,432 @@ +"""Institutional wheel-install smoke path (AKTA 1.1 WS14). + +Run from a clean venv after installing scientific-authorization-contracts and the +AKTA wheel (no source checkout required beyond the contracts vendor install): + + python -m akta.wheel_smoke +""" + +from __future__ import annotations + +import json +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _ok(label: str) -> None: + print(f"ok {label}") + + +def run_wheel_smoke() -> dict[str, Any]: + """Exercise shared schemas through PF/PCS export in an installed environment.""" + from akta import AKTAContext, AKTAGate + from akta.capability_envelope import authorization_covers, legacy_scope_to_envelope + from akta.hash import hash_object + from akta.requested_authorization import ( + build_requested_authorization_context, + evaluate_authorization_containment, + ) + from akta.scope_authorization_manifest import build_test_authorization_manifest + from akta.scope_grant_verifier import verify_scope_grant_or_raise + from akta.shared_contracts import ( + ENVELOPE_SCHEMA_NAME, + ENVELOPE_VERSION, + GRANT_STATUS_SCHEMA_NAME, + GRANT_V2_SCHEMA_NAME, + _contracts, + authorization_envelope_schema_hash, + load_conformance_cases, + load_contract_schema, + ) + from akta.trust_store import ( + build_trust_store, + generate_test_keypair, + sign_payload_hash_ed25519, + ) + from adapters.pcs.export_artifact import export_pcs_bundle, validate_pcs_bundle + from adapters.pf_core.export_obligation import build_pf_obligation, export_pf_obligation + + report: dict[str, Any] = {"steps": [], "passed": False} + + # 1) Shared schema loading + for name in (ENVELOPE_SCHEMA_NAME, GRANT_V2_SCHEMA_NAME, GRANT_STATUS_SCHEMA_NAME): + schema = load_contract_schema(name) + assert isinstance(schema, dict) and "$schema" in schema, name + report["steps"].append("shared_schema_loading") + _ok("shared_schema_loading") + + narrowing = load_conformance_cases("narrowing") + assert narrowing, "expected narrowing conformance vectors" + case = narrowing[0] + covers, comparator = authorization_covers( + granted_envelope=case["outer"], + requested_envelope=case["inner"], + ) + assert comparator == "envelope" + expect_narrower = case.get("expect") == "narrower" + assert covers is expect_narrower, (case["name"], covers, case.get("expect")) + decision = _contracts().compare_envelopes(case["inner"], case["outer"]) + if isinstance(decision, dict): + assert decision.get("decision") == case["expect"], decision + else: + assert decision == case["expect"], decision + report["steps"].append("conformance_narrowing") + _ok("conformance_narrowing") + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + issuer = generate_test_keypair(tmp_path / "issuer") + status_keys = generate_test_keypair(tmp_path / "status") + manifest_keys = generate_test_keypair(tmp_path / "manifest") + + issuer_store = build_trust_store( + { + "keys": { + issuer["public_key_ref"]: { + "public_key_pem": issuer["public_key_pem"], + "public_key_ref": issuer["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_grant"], + }, + base_dir=tmp_path, + required_artifact_type="scope_grant", + ) + status_store = build_trust_store( + { + "keys": { + status_keys["public_key_ref"]: { + "public_key_pem": status_keys["public_key_pem"], + "public_key_ref": status_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_grant_status"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_grant_status"], + }, + base_dir=tmp_path, + required_artifact_type="scope_grant_status", + ) + manifest_store = build_trust_store( + { + "keys": { + manifest_keys["public_key_ref"]: { + "public_key_pem": manifest_keys["public_key_pem"], + "public_key_ref": manifest_keys["public_key_ref"], + "status": "active", + "algorithm": "Ed25519", + "authorized_artifact_types": ["scope_authorization_manifest"], + } + }, + "revoked_key_ids": [], + "default_artifact_types": ["scope_authorization_manifest"], + }, + base_dir=tmp_path, + required_artifact_type="scope_authorization_manifest", + ) + manifest = build_test_authorization_manifest(signer_keys=manifest_keys) + trust_root = manifest["manifest_hash"] + + record = {"record_id": "rec-smoke", "summary": "wheel-smoke"} + decision_body = { + "decision_id": "dec-smoke", + "admissibility": "review_required", + } + packet = {"packet_id": "pkt-smoke", "payload": "packet"} + trigger = {"review_trigger_id": "trg-smoke", "packet_id": "pkt-smoke"} + for obj, key in ( + (record, "record_hash"), + (decision_body, "decision_hash"), + (packet, "packet_hash"), + (trigger, "review_trigger_hash"), + ): + obj[key] = hash_object({k: v for k, v in obj.items() if k != key}) + + env = legacy_scope_to_envelope( + approved_scope="protocol_draft", + allowed_tools=["protocol_editor.draft_change"], + blocked_tools=["robot_queue.submit"], + target={"type": "protocol", "protocol_id": "protocol-v3"}, + environment={"tenant_id": "tenant-a"}, + cardinality={"mode": "single", "maximum_uses": 1, "maximum_items": 1}, + constraints={"protocol_version": "protocol-v3"}, + ) + grant: dict[str, Any] = { + "grant_id": "SCOPE-GRANT-WHEEL-SMOKE", + "grant_version": "2.0.0", + "created_at": "2026-01-01T00:00:00Z", + "authorization": { + "approved_scope": "protocol_draft", + "allowed_tools": list(env["allowed_tools"]), + "blocked_tools": list(env["blocked_tools"]), + "authorization_envelope": env, + }, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), + "issuer": { + "issuer_identity": "scope-test", + "issuer_key_id": issuer["public_key_ref"], + }, + "source": { + "packet_id": packet["packet_id"], + "packet_hash": packet["packet_hash"], + "decision_id": decision_body["decision_id"], + "decision_hash": decision_body["decision_hash"], + "akta_record_id": record["record_id"], + "akta_record_hash": record["record_hash"], + "review_trigger_id": trigger["review_trigger_id"], + "review_trigger_hash": trigger["review_trigger_hash"], + }, + "provenance": {"scope_trust_root_hash": trust_root}, + "constraints": {"single_use": True, "tenant_id": "tenant-a"}, + "expiration": { + "absolute_expiration": "2030-01-01T00:00:00Z", + "expires_after": [], + }, + "signature_algorithm": "Ed25519", + } + grant = _contracts().attach_hash(grant, "grant_hash") + grant["signed_payload_hash"] = grant["grant_hash"] + priv = Path(issuer["private_key_path"]).read_bytes() + grant["grant_signature"] = sign_payload_hash_ed25519(priv, grant["grant_hash"]) + + status = { + "grant_id": grant["grant_id"], + "status": "active", + "checked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "cardinality_remaining": 1, + "grant_hash": grant["grant_hash"], + "scope_trust_root_hash": trust_root, + "tenant_id": "tenant-a", + "ledger_sequence": 1, + "authorization_schema_version": ENVELOPE_VERSION, + "authorization_schema_hash": authorization_envelope_schema_hash(), + "issuer": { + "issuer_identity": "scope-status", + "issuer_key_id": status_keys["public_key_ref"], + }, + } + status_body = { + k: v + for k, v in status.items() + if k + not in { + "attestation_hash", + "attestation_signature", + "signed_payload_hash", + } + } + status["attestation_hash"] = hash_object(status_body) + status["signed_payload_hash"] = status["attestation_hash"] + status_priv = Path(status_keys["private_key_path"]).read_bytes() + status["attestation_signature"] = sign_payload_hash_ed25519( + status_priv, status["attestation_hash"] + ) + + # 2–4) Grant auth + issuer signature + status attestation + verified = verify_scope_grant_or_raise( + grant, + mode="pilot", + trust_store=issuer_store, + status_trust_store=status_store, + authorization_manifest=manifest, + manifest_trust_store=manifest_store, + ledger_status=status, + source_artifacts={ + "record": record, + "decision": decision_body, + "packet": packet, + "review_trigger": trigger, + }, + ) + assert verified.ok, verified.errors + report["steps"].append("grant_authentication") + report["steps"].append("issuer_signature_verification") + report["steps"].append("status_attestation") + _ok("grant_auth+issuer_sig+status") + + # 5–6) Requested envelope + containment + requested = build_requested_authorization_context( + requested_tool="protocol_editor.draft_change", + action_class="A5_protocol_modification", + context={"protocol_version": "protocol-v3", "tenant_id": "tenant-a"}, + constraints_overrides={"protocol_version": "protocol-v3"}, + target_overrides={"type": "protocol", "protocol_id": "protocol-v3"}, + environment_overrides={"tenant_id": "tenant-a"}, + ) + containment = evaluate_authorization_containment( + granted_envelope=env, + requested=requested, + ledger_status=status, + ) + assert containment.covers, containment.errors + report["steps"].append("requested_envelope_construction") + report["steps"].append("full_containment_check") + _ok("requested_envelope+containment") + + # 7) Evidence-layer preservation (module invariant + optional gate path) + from akta.evidence_independence import check_evidence_independence + + independence = check_evidence_independence( + scope_grant=grant, + pre_layers={ + "evidence": { + "decision": "blocked", + "reason": "weak evidence block", + } + }, + post_decision="allowed", + post_reason="cleared by grant", + ) + assert not independence.ok + report["steps"].append("evidence_layer_preservation") + _ok("evidence_layer_preservation") + + policy_candidates = [ + Path(__file__).resolve().parent.parent / "policy", + Path.cwd() / "policy", + ] + policy_dir = next((p for p in policy_candidates if p.is_dir()), None) + overlays_dir = None + if policy_dir is not None: + overlays_dir = policy_dir.parent / "overlays" + if not overlays_dir.is_dir(): + overlays_dir = None + + if policy_dir is not None and overlays_dir is not None: + gate = AKTAGate.from_policy_dir(policy_dir, overlays_dir=overlays_dir) + prior = { + "decision": {"admissibility": "review_required"}, + "classification": { + "evidence_state": "E4_internally_consistent_evidence" + }, + "scientific_context": {"protocol_version": "protocol-v3"}, + } + gate_decision = gate.evaluate_with_grant( + ai_output={"summary": "Draft protocol-v3 change."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict( + { + "evidence_state": "E4_internally_consistent_evidence", + "protocol_version": "protocol-v3", + "tenant_id": "tenant-a", + } + ), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=grant, + record=prior, + trigger=trigger, + verification_mode="pilot", + trust_store=issuer_store, + ledger_status=status, + authorization_manifest=manifest, + source_artifacts={ + "record": record, + "decision": decision_body, + "packet": packet, + "review_trigger": trigger, + }, + action_class="A5_protocol_modification", + validate_output=False, + status_trust_store=status_store, + manifest_trust_store=manifest_store, + ) + decision_dict = gate_decision.to_dict() + gate_record = gate_decision.to_record( + ai_output={"summary": "Draft protocol-v3 change."}, + context={ + "pf_core_obligation_ref": "PF-OBL-SMOKE", + "pcs_artifact_ref": "PCS-SMOKE", + }, + ) + else: + from akta.records import AKTADecision + + decision_dict = { + "decision_id": "AKTA-DEC-SMOKE", + "timestamp": "2026-01-01T00:00:00Z", + "deployment_profile": "P4_protocol_drafting_assistant", + "requested_action": "draft", + "requested_tool": "protocol_editor.draft_change", + "scientific_action_type": "A5_protocol_modification", + "responsibility_level": "R4_methodological_modification", + "evidence_state": "E4_internally_consistent_evidence", + "validation_status": "V3_preliminary_experimental_support", + "verification_status": "UNVERIFIED", + "admissibility": "draft_only", + "decision_reason": "wheel smoke", + "blocked_tools": ["robot_queue.submit"], + "allowed_tools": ["protocol_editor.draft_change"], + "next_admissible_steps": [], + "record_required": True, + "review_required": False, + "authorization_required": False, + "policy_version": "1.1.0", + "policy_hash": "sha256:" + ("a" * 64), + "tool_registry_hash": "sha256:" + ("b" * 64), + } + gate_decision = AKTADecision(decision_dict) + gate_record = gate_decision.to_record( + ai_output={"summary": "Draft protocol-v3 change."}, + context={ + "pf_core_obligation_ref": "PF-OBL-SMOKE", + "pcs_artifact_ref": "PCS-SMOKE", + }, + ) + decision_dict = gate_decision.to_dict() + + # 8–9) PF + PCS export + record_dict = gate_record.to_dict() + obligation = build_pf_obligation( + record_dict, decision_id=decision_dict.get("decision_id") + ) + assert obligation.get("obligation_id") + pf_path = export_pf_obligation( + gate_record, + tmp_path / "pf", + decision_id=decision_dict.get("decision_id"), + ) + assert Path(pf_path).is_file() + report["steps"].append("pf_export") + _ok("pf_export") + + pcs_dir = tmp_path / "pcs" + export_pcs_bundle( + gate_record, + pcs_dir, + decision=decision_dict, + scope_grant=grant, + pf_obligation=obligation, + validate=True, + ) + validate_pcs_bundle(pcs_dir) + report["steps"].append("pcs_export") + _ok("pcs_export") + + report["passed"] = True + report["steps"] = sorted(set(report["steps"])) + print(json.dumps({"passed": True, "steps": report["steps"]}, indent=2)) + return report + + +def main() -> int: + try: + run_wheel_smoke() + except Exception as exc: + print(json.dumps({"passed": False, "error": str(exc)}, indent=2)) + raise SystemExit(1) from exc + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_release_evidence_report.py b/scripts/build_release_evidence_report.py new file mode 100644 index 0000000..f5ce070 --- /dev/null +++ b/scripts/build_release_evidence_report.py @@ -0,0 +1,326 @@ +"""Build machine-readable AKTA release evidence report (WS14). + +Collects component commits, schema/trust hashes, test commands/results, +evaluation reports, package hashes, SBOM, dependency audit, and CodeQL status. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" + + +def _git_rev(cwd: Path | None = None) -> str | None: + try: + out = subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=str(cwd or ROOT), + text=True, + stderr=subprocess.DEVNULL, + ) + return out.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +def _load_json(path: Path) -> Any | None: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def _file_hashes(pattern: str) -> dict[str, str]: + out: dict[str, str] = {} + for path in sorted(ROOT.glob(pattern)): + if path.is_file(): + out[str(path.relative_to(ROOT)).replace("\\", "/")] = _sha256_file(path) + return out + + +def _schema_hashes() -> dict[str, str]: + hashes: dict[str, str] = {} + try: + from akta.shared_contracts import ( + ENVELOPE_SCHEMA_NAME, + GRANT_STATUS_SCHEMA_NAME, + GRANT_V2_SCHEMA_NAME, + authorization_envelope_schema_hash, + load_contract_schema, + ) + from akta.hash import hash_object + + hashes["authorization_envelope"] = authorization_envelope_schema_hash() + for name in (ENVELOPE_SCHEMA_NAME, GRANT_V2_SCHEMA_NAME, GRANT_STATUS_SCHEMA_NAME): + schema = load_contract_schema(name) + hashes[name] = hash_object(schema) + except Exception as exc: # noqa: BLE001 — report collection must not crash + hashes["error"] = str(exc) + return hashes + + +def _trust_manifest_hashes() -> dict[str, str]: + from akta.hash import hash_file_content + + names = [ + "scope_grant_issuer_registry.yaml", + "scope_status_attestation_registry.yaml", + "scope_policy_manifest_signer_registry.yaml", + "scope_compatibility_manifest.json", + "effective_policy_manifest.json", + "shared_contracts_pin.json", + "release_pins.yaml", + ] + out: dict[str, str] = {} + for name in names: + path = ROOT / "policy" / name + if path.is_file(): + out[name] = hash_file_content(path.read_text(encoding="utf-8")) + return out + + +def _sibling_commits() -> dict[str, Any]: + from akta.release_pins import load_release_pins, pins_for_release_report + from akta.sibling_repos import discover_sibling, git_rev_parse_head + + pins = load_release_pins() + report = pins_for_release_report(pins) + components = pins.get("components") or {} + discovered: dict[str, Any] = {} + for name, entry in components.items(): + if not isinstance(entry, dict): + continue + env_var = str(entry.get("env_var") or "") + found = discover_sibling(env_var) if env_var else None + head = git_rev_parse_head(found) if found else None + discovered[name] = { + "pinned_commit": entry.get("commit"), + "status": entry.get("status"), + "checkout": str(found) if found else None, + "checkout_head": head, + "placeholder": "PLACEHOLDER" in str(entry.get("commit") or "").upper() + or str(entry.get("status") or "") == "missing_placeholder", + } + return {"pins": report, "checkouts": discovered} + + +def _collect_eval_reports() -> dict[str, Any]: + reports_dir = ROOT / "evals" / "reports" + out: dict[str, Any] = {} + if not reports_dir.is_dir(): + return out + for path in sorted(reports_dir.glob("*.json")): + out[path.name] = { + "path": str(path.relative_to(ROOT)).replace("\\", "/"), + "sha256": _sha256_file(path), + "bytes": path.stat().st_size, + } + return out + + +def _dependency_audit() -> dict[str, Any]: + audit_path = ROOT / "requirements-audit.txt" + result_path = ROOT / "dist" / "pip-audit.json" + info: dict[str, Any] = { + "requirements_audit": str(audit_path.relative_to(ROOT)) + if audit_path.is_file() + else None, + "result_path": None, + "ok": None, + } + if result_path.is_file(): + info["result_path"] = str(result_path.relative_to(ROOT)).replace("\\", "/") + info["sha256"] = _sha256_file(result_path) + payload = _load_json(result_path) + if isinstance(payload, dict): + deps = payload.get("dependencies") or [] + vulns = sum(len(d.get("vulns") or []) for d in deps if isinstance(d, dict)) + info["vulnerability_count"] = vulns + info["ok"] = vulns == 0 + elif isinstance(payload, list): + info["ok"] = len(payload) == 0 + info["vulnerability_count"] = len(payload) + return info + + +def _codeql_result() -> dict[str, Any]: + """Record CodeQL workflow status from env or artifact stub.""" + status = os.environ.get("AKTA_CODEQL_CONCLUSION") or os.environ.get( + "CODEQL_CONCLUSION" + ) + artifact = ROOT / "dist" / "codeql-result.json" + payload = _load_json(artifact) + return { + "workflow": ".github/workflows/codeql.yml", + "conclusion": status or (payload or {}).get("conclusion"), + "artifact": str(artifact.relative_to(ROOT)).replace("\\", "/") + if artifact.is_file() + else None, + "note": ( + "CodeQL runs as a separate workflow; conclusion is recorded when " + "AKTA_CODEQL_CONCLUSION is set or dist/codeql-result.json is present." + ), + } + + +def _sbom_info() -> dict[str, Any]: + candidates = [ + ROOT / "dist" / "akta.cdx.json", + ROOT / "dist" / "akta.sbom.stub.json", + ] + for path in candidates: + if path.is_file(): + return { + "path": str(path.relative_to(ROOT)).replace("\\", "/"), + "sha256": _sha256_file(path), + "stub": path.name.endswith("stub.json"), + } + return {"path": None, "note": "SBOM not generated yet"} + + +def _vsa_pin_status() -> dict[str, Any]: + from akta.release_pins import load_release_pins + + pins = load_release_pins() + vsa = (pins.get("components") or {}).get("VSA") or {} + commit = str(vsa.get("commit") or "") + status = str(vsa.get("status") or "") + placeholder = ( + "PLACEHOLDER" in commit.upper() + or "UNAVAILABLE" in commit.upper() + or status == "missing_placeholder" + or not commit + or commit == "0" * 40 + ) + return { + "pinned": not placeholder and status == "pinned", + "fail_closed_placeholder": placeholder, + "commit": commit, + "tag": vsa.get("tag"), + "status": status, + "note": ( + "VSA pin is a real commit; institutional_release still fail-closes " + "on missing checkout or SHA mismatch." + if not placeholder + else ( + "VSA pin is missing or placeholder; institutional_release " + "must stay red until a real commit is pinned." + ) + ), + } + + +def build_release_evidence_report( + *, + test_results: dict[str, Any] | None = None, +) -> dict[str, Any]: + from akta.release_pins import load_release_pins + + shared_pin = _load_json(ROOT / "policy" / "shared_contracts_pin.json") or {} + report: dict[str, Any] = { + "report_type": "akta_release_evidence_v1", + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "akta_commit": _git_rev(), + "component_commits": _sibling_commits(), + "shared_contracts": { + "package": shared_pin.get("package") or "scientific-authorization-contracts", + "package_version": shared_pin.get("package_version"), + "release_artifact_hash": shared_pin.get("release_artifact_hash"), + }, + "schema_hashes": _schema_hashes(), + "trust_manifest_hashes": _trust_manifest_hashes(), + "release_pins": load_release_pins(), + "package_hashes": { + **_file_hashes("dist/*.whl"), + **_file_hashes("dist/*.tar.gz"), + **_file_hashes("dist-repro/*.whl"), + }, + "reproducibility": _load_json(ROOT / "dist" / "reproducibility.json"), + "sbom": _sbom_info(), + "dependency_audit": _dependency_audit(), + "codeql": _codeql_result(), + "evaluation_reports": _collect_eval_reports(), + "test_commands_results": test_results + or _load_json(ROOT / "dist" / "release_test_results.json") + or { + "note": ( + "Populate dist/release_test_results.json from CI (pytest / wheel smoke " + "/ verify_v1_release) or pass --test-results." + ) + }, + "vsa_pin": _vsa_pin_status(), + "beta": True, + } + # Drop internal path annotation from pins if present. + if isinstance(report["release_pins"], dict): + report["release_pins"].pop("_source", None) + report["report_hash"] = ( + "sha256:" + + hashlib.sha256( + json.dumps( + {k: v for k, v in report.items() if k != "report_hash"}, + sort_keys=True, + default=str, + ).encode("utf-8") + ).hexdigest() + ) + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build AKTA release evidence report") + parser.add_argument( + "--out", + type=Path, + default=ROOT / "dist" / "release_evidence.json", + ) + parser.add_argument( + "--test-results", + type=Path, + default=None, + help="Optional JSON file with test command results", + ) + args = parser.parse_args(argv) + test_results = _load_json(args.test_results) if args.test_results else None + report = build_release_evidence_report(test_results=test_results) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8") + print( + json.dumps( + { + "out": str(args.out), + "report_hash": report.get("report_hash"), + "akta_commit": report.get("akta_commit"), + "vsa_pin": { + "pinned": (report.get("vsa_pin") or {}).get("pinned"), + "commit": (report.get("vsa_pin") or {}).get("commit"), + "tag": (report.get("vsa_pin") or {}).get("tag"), + }, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2b19b3471d5468ecff50984466137919f4a8287e Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:51:48 -0700 Subject: [PATCH 23/26] ci: expand CI and release-gate for institutional 1.1 checks Run contract drift, crypto, and release evidence steps so institutional regressions fail before merge. --- .github/SUPPLY_CHAIN.md | 16 ++-- .github/workflows/ci.yml | 124 +++++++++++++++++++++++------ .github/workflows/release-gate.yml | 106 +++++++++++++++++++++--- 3 files changed, 205 insertions(+), 41 deletions(-) diff --git a/.github/SUPPLY_CHAIN.md b/.github/SUPPLY_CHAIN.md index 5676c5f..593a995 100644 --- a/.github/SUPPLY_CHAIN.md +++ b/.github/SUPPLY_CHAIN.md @@ -20,12 +20,18 @@ Workflows under `.github/workflows/` pin third-party GitHub Actions to full comm | CodeQL | `.github/workflows/codeql.yml` (Python) | | SBOM | CycloneDX JSON attached on package/tag builds when tooling available | | Wheel build + twine check | `package` job | -| Wheel install smoke | Install built wheel into clean venv and import `akta` | -| Reproducibility comparison | Optional second wheel build hash compare when enabled | -| Package provenance hooks | GitHub Attestations / `actions/attest-build-provenance` on tags when available | +## Supply-chain / release evidence (WS14) + +| Control | Placement | +|---------|-----------| +| Full wheel smoke | `python -m akta.wheel_smoke` after clean wheel install (schemas, grant auth, status, containment, evidence, PF/PCS) | +| Reproducibility | Second build under `SOURCE_DATE_EPOCH`; **fails** unless digests match or `docs/reproducibility_normalization.md` lists an allowed token | +| Sibling matrix on tags | `.github/workflows/release-gate.yml` requires full PF/PCS/SCOPE/VSA/Memory matrix on `v*` | +| Release evidence report | `scripts/build_release_evidence_report.py` → `dist/release_evidence.json` | +| VSA pin | `903d834f…` (`v0.7.2`); missing checkout / SHA mismatch remain fail-closed | ## Sibling pins (P1-A) Institutional release pins live in `policy/release_pins.yaml` and are recorded in -`scripts/verify_v1_release.py --institutional` reports. Missing siblings stay -fail-closed placeholders — never silently optionalized under institutional_release. +`scripts/verify_v1_release.py --institutional` reports. Missing checkouts or SHA +mismatches stay fail-closed — never silently optionalized under institutional_release. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e85d1..2193a31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: pip install -e ".[dev,security]" + - name: Install shared contracts then AKTA + run: | + pip install ./vendor/scientific-authorization-contracts + pip install -e ".[dev,security]" - name: Shared contract drift gate run: python scripts/assert_shared_contract_drift.py @@ -78,12 +80,17 @@ jobs: deps = list(data["project"]["dependencies"]) for extra in ("dev", "security"): deps.extend(data["project"].get("optional-dependencies", {}).get(extra, [])) + # Vendor-local package; not on public indexes yet. + deps = [ + d for d in deps + if not str(d).startswith("scientific-authorization-contracts") + ] pathlib.Path("requirements-audit.txt").write_text("\n".join(deps) + "\n", encoding="utf-8") PY pip-audit -r requirements-audit.txt --progress-spinner off - name: Grant forgery lifecycle tests - run: pytest tests/test_grant_forgery_lifecycle.py tests/test_institutional_trust_store.py -v + run: pytest tests/test_grant_forgery_lifecycle.py tests/test_institutional_trust_store.py tests/test_strict_mode_crypto.py tests/test_trust_domains.py -v - name: Run pytest run: pytest tests/ skills/akta-scientific-action-admissibility/tests/ -v @@ -153,18 +160,29 @@ jobs: with: python-version: "3.12" - name: Install build tooling - run: pip install -e ".[dev,security]" + run: | + pip install ./vendor/scientific-authorization-contracts + pip install -e ".[dev,security]" - name: Shared contract drift gate run: python scripts/assert_shared_contract_drift.py - name: Build wheel and sdist + env: + SOURCE_DATE_EPOCH: "1700000000" run: python -m build - name: Twine check run: twine check dist/* - - name: Wheel install smoke + - name: Wheel install smoke (full institutional path) + env: + SOURCE_DATE_EPOCH: "1700000000" run: | python -m venv /tmp/akta-wheel-smoke + # Contracts first (no public index), then AKTA wheel in a clean venv. + /tmp/akta-wheel-smoke/bin/pip install ./vendor/scientific-authorization-contracts /tmp/akta-wheel-smoke/bin/pip install dist/*.whl - /tmp/akta-wheel-smoke/bin/python -c "import akta; from akta.trust_store import resolve_trust_store; print('ok', akta.__name__)" + # Policy/overlays are package data for gate path; bind from checkout for smoke. + cp -a policy overlays /tmp/akta-wheel-smoke/ + cd /tmp/akta-wheel-smoke + ./bin/python -m akta.wheel_smoke - name: SBOM (CycloneDX when available) run: | pip install cyclonedx-bom >/dev/null 2>&1 || true @@ -188,38 +206,70 @@ jobs: print("wrote stub SBOM") PY fi - - name: Reproducibility comparison (second build hash) + - name: Reproducibility comparison (fail-closed unless documented) + env: + SOURCE_DATE_EPOCH: "1700000000" run: | python -m build -o dist-repro python - <<'PY' - import hashlib, pathlib, sys + import hashlib, json, pathlib, sys def digest(pattern): paths = sorted(pathlib.Path('.').glob(pattern)) h = hashlib.sha256() + wheels = [] for p in paths: if p.suffix == '.whl': h.update(p.read_bytes()) - return h.hexdigest(), [str(p) for p in paths if p.suffix == '.whl'] + wheels.append(str(p)) + return h.hexdigest(), wheels a, pa = digest('dist/*.whl') b, pb = digest('dist-repro/*.whl') - print({'dist': pa, 'dist_repro': pb, 'sha256_dist': a, 'sha256_repro': b, 'match': a == b}) - # Record comparison; do not fail CI on non-bit-identical wheels (setuptools metadata). + match = a == b + norm_doc = pathlib.Path('docs/reproducibility_normalization.md') + norm_text = norm_doc.read_text(encoding='utf-8') if norm_doc.is_file() else '' + # Only tokens listed under "## Active exceptions" count (not the vocabulary table). + active = '' + if '## Active exceptions' in norm_text: + active = norm_text.split('## Active exceptions', 1)[1] + if '##' in active: + active = active.split('##', 1)[0] + allowed_tokens = ('WHEEL_METADATA_TIMESTAMP', 'PLATFORM_TAG_DRIFT') + documented = any(tok in active for tok in allowed_tokens) and 'None.' not in active.strip().splitlines()[:5] + # Treat explicit "None." as no active exception. + if active.strip().startswith('None.') or active.strip() == 'None': + documented = False + payload = { + 'sha256_dist': a, + 'sha256_repro': b, + 'match': match, + 'dist': pa, + 'dist_repro': pb, + 'source_date_epoch': '1700000000', + 'normalization_doc': 'docs/reproducibility_normalization.md', + 'documented_exception': (not match) and documented, + } + pathlib.Path('dist').mkdir(exist_ok=True) pathlib.Path('dist/reproducibility.json').write_text( - __import__('json').dumps({'sha256_dist': a, 'sha256_repro': b, 'match': a == b}, indent=2), - encoding='utf-8', + json.dumps(payload, indent=2), encoding='utf-8' ) + print(payload) + if not match and not documented: + print( + 'FAIL: release artifacts differ and no active exception token is ' + 'listed under "## Active exceptions" in docs/reproducibility_normalization.md', + file=sys.stderr, + ) + sys.exit(1) + if not match and documented: + print( + 'WARN: artifacts differ; accepted only because an active ' + 'normalization exception token is listed' + ) PY - - name: Upload package artifacts - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: akta-packages - path: | - dist/ - dist-repro/ - name: pip-audit (fail-closed on declared deps) run: | python - <<'PY' - import pathlib + import pathlib, json try: import tomllib except ModuleNotFoundError: @@ -228,9 +278,35 @@ jobs: deps = list(data["project"]["dependencies"]) for extra in ("dev", "security"): deps.extend(data["project"].get("optional-dependencies", {}).get(extra, [])) + # Vendor-local package; not on public indexes yet. + deps = [ + d for d in deps + if not str(d).startswith("scientific-authorization-contracts") + ] pathlib.Path("requirements-audit.txt").write_text("\n".join(deps) + "\n", encoding="utf-8") PY - pip-audit -r requirements-audit.txt --progress-spinner off + mkdir -p dist + pip-audit -r requirements-audit.txt --progress-spinner off --format json -o dist/pip-audit.json || pip-audit -r requirements-audit.txt --progress-spinner off + - name: Release evidence report + run: | + python scripts/build_release_evidence_report.py --out dist/release_evidence.json + python - <<'PY' + import json, pathlib, re + report = json.loads(pathlib.Path('dist/release_evidence.json').read_text(encoding='utf-8')) + vsa = report.get('vsa_pin') or {} + commit = str(vsa.get('commit') or '') + assert vsa.get('pinned') is True, f'VSA must be pinned in release evidence, got {vsa!r}' + assert vsa.get('fail_closed_placeholder') is not True + assert re.fullmatch(r'[0-9a-f]{40}', commit), f'bad VSA commit {commit!r}' + print(f"release evidence ok; VSA pinned {commit} tag={vsa.get('tag')}") + PY + - name: Upload package artifacts + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: akta-packages + path: | + dist/ + dist-repro/ cross-repo-pf-pcs: runs-on: ubuntu-latest @@ -241,7 +317,9 @@ jobs: with: python-version: "3.12" - name: Install dependencies - run: pip install -e ".[dev,security]" + run: | + pip install ./vendor/scientific-authorization-contracts + pip install -e ".[dev,security]" - name: Cross-repo PF-Core validation if: ${{ vars.PF_CORE_REPO_PATH != '' }} env: diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 26de6a2..42e9e77 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -27,7 +27,9 @@ jobs: vsa_repo_url: ${{ secrets.VSA_REPO_URL || vars.VSA_PUBLIC_REPO_URL }} memory_repo_url: ${{ secrets.MEMORY_REPO_URL || vars.MEMORY_PUBLIC_REPO_URL }} - name: Install dependencies - run: pip install -e ".[dev,security]" + run: | + pip install ./vendor/scientific-authorization-contracts + pip install -e ".[dev,security]" - name: Require SCOPE sibling path run: | if [ -z "${SCOPE_REPO_PATH:-}" ]; then @@ -111,24 +113,80 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" + - name: Resolve sibling clone URLs (mandatory public fallbacks on tags) + id: sibling_urls + shell: bash + run: | + set -euo pipefail + is_tag=0 + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + is_tag=1 + fi + echo "is_release_tag=${is_tag}" >> "$GITHUB_OUTPUT" + + pick() { + local secret="$1" var="$2" public="$3" + if [ -n "${secret}" ]; then echo "${secret}"; return; fi + if [ -n "${var}" ]; then echo "${var}"; return; fi + if [ "${is_tag}" = "1" ]; then echo "${public}"; return; fi + echo "" + } + + echo "pf_core_repo_url=$(pick '${{ secrets.PF_CORE_REPO_URL }}' '${{ vars.PF_CORE_PUBLIC_REPO_URL }}' 'https://github.com/SentinelOps-CI/provability-fabric-core.git')" >> "$GITHUB_OUTPUT" + echo "pcs_core_repo_url=$(pick '${{ secrets.PCS_CORE_REPO_URL }}' '${{ vars.PCS_CORE_PUBLIC_REPO_URL }}' 'https://github.com/SentinelOps-CI/pcs-core.git')" >> "$GITHUB_OUTPUT" + echo "pcs_bench_repo_url=$(pick '${{ secrets.PCS_BENCH_REPO_URL }}' '${{ vars.PCS_BENCH_PUBLIC_REPO_URL }}' 'https://github.com/fraware/pcs-bench.git')" >> "$GITHUB_OUTPUT" + echo "vsa_repo_url=$(pick '${{ secrets.VSA_REPO_URL }}' '${{ vars.VSA_PUBLIC_REPO_URL }}' 'https://github.com/fraware/verified-science-agent.git')" >> "$GITHUB_OUTPUT" + echo "memory_repo_url=$(pick '${{ secrets.MEMORY_REPO_URL }}' '${{ vars.MEMORY_PUBLIC_REPO_URL }}' 'https://github.com/fraware/scientific-memory.git')" >> "$GITHUB_OUTPUT" - name: Clone trust-stack siblings uses: ./.github/actions/clone-siblings with: workspace: ${{ github.workspace }} scope_repo_url: ${{ secrets.SCOPE_REPO_URL || vars.SCOPE_PUBLIC_REPO_URL || 'https://github.com/fraware/SCOPE.git' }} - pf_core_repo_url: ${{ secrets.PF_CORE_REPO_URL || vars.PF_CORE_PUBLIC_REPO_URL }} - pcs_core_repo_url: ${{ secrets.PCS_CORE_REPO_URL || vars.PCS_CORE_PUBLIC_REPO_URL }} - pcs_bench_repo_url: ${{ secrets.PCS_BENCH_REPO_URL || vars.PCS_BENCH_PUBLIC_REPO_URL }} - vsa_repo_url: ${{ secrets.VSA_REPO_URL || vars.VSA_PUBLIC_REPO_URL }} - memory_repo_url: ${{ secrets.MEMORY_REPO_URL || vars.MEMORY_PUBLIC_REPO_URL }} + pf_core_repo_url: ${{ steps.sibling_urls.outputs.pf_core_repo_url }} + pcs_core_repo_url: ${{ steps.sibling_urls.outputs.pcs_core_repo_url }} + pcs_bench_repo_url: ${{ steps.sibling_urls.outputs.pcs_bench_repo_url }} + vsa_repo_url: ${{ steps.sibling_urls.outputs.vsa_repo_url }} + memory_repo_url: ${{ steps.sibling_urls.outputs.memory_repo_url }} - name: Install dependencies - run: pip install -e ".[dev,security]" + run: | + pip install ./vendor/scientific-authorization-contracts + pip install -e ".[dev,security]" + - name: Require full sibling matrix on release tags + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + missing=() + for var in SCOPE_REPO_PATH PF_CORE_REPO_PATH PCS_CORE_REPO_PATH PCS_BENCH_REPO_PATH VSA_REPO_PATH MEMORY_REPO_PATH; do + if [ -z "${!var:-}" ]; then + missing+=("$var") + fi + done + if [ "${#missing[@]}" -ne 0 ]; then + echo "FAIL: release tag requires full sibling matrix; missing: ${missing[*]}" + exit 1 + fi + echo "Full sibling matrix present for release tag" + python - <<'PY' + from akta.release_pins import load_release_pins + pins = load_release_pins() + vsa = (pins.get("components") or {}).get("VSA") or {} + commit = str(vsa.get("commit") or "") + assert vsa.get("status") == "pinned", f"VSA status must be pinned, got {vsa.get('status')!r}" + assert len(commit) == 40 and all(c in "0123456789abcdef" for c in commit), ( + f"VSA commit must be a real 40-char SHA, got {commit!r}" + ) + assert "PLACEHOLDER" not in commit.upper() and "UNAVAILABLE" not in commit.upper() + print(f"VSA pin ok: {commit} tag={vsa.get('tag')}") + PY - name: PF runtime proof env: PF_CORE_REPO_PATH: ${{ env.PF_CORE_REPO_PATH || vars.PF_CORE_REPO_PATH }} run: | - if [ -n "$PF_CORE_REPO_PATH" ]; then + if [ -n "${PF_CORE_REPO_PATH:-}" ]; then python scripts/demo_pf_runtime_proof.py + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "FAIL: PF_CORE_REPO_PATH required on release tags" + exit 1 elif [ -n "${{ vars.PF_CORE_REPO_PATH }}" ]; then echo "FAIL: PF_CORE_REPO_PATH var set but clone/path missing" exit 1 @@ -139,8 +197,11 @@ jobs: env: PCS_CORE_REPO_PATH: ${{ env.PCS_CORE_REPO_PATH || vars.PCS_CORE_REPO_PATH }} run: | - if [ -n "$PCS_CORE_REPO_PATH" ]; then + if [ -n "${PCS_CORE_REPO_PATH:-}" ]; then pytest tests/contracts/test_pcs_core_live_ingest.py -v -m integration + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "FAIL: PCS_CORE_REPO_PATH required on release tags" + exit 1 elif [ -n "${{ vars.PCS_CORE_REPO_PATH }}" ]; then echo "FAIL: PCS_CORE_REPO_PATH var set but clone/path missing" exit 1 @@ -151,8 +212,11 @@ jobs: env: PCS_BENCH_REPO_PATH: ${{ env.PCS_BENCH_REPO_PATH || vars.PCS_BENCH_REPO_PATH }} run: | - if [ -n "$PCS_BENCH_REPO_PATH" ]; then + if [ -n "${PCS_BENCH_REPO_PATH:-}" ]; then python -m adapters.pcs_bench.external_harness + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "FAIL: PCS_BENCH_REPO_PATH required on release tags" + exit 1 elif [ -n "${{ vars.PCS_BENCH_REPO_PATH }}" ]; then echo "FAIL: PCS_BENCH_REPO_PATH var set but clone/path missing" exit 1 @@ -163,10 +227,10 @@ jobs: env: VSA_REPO_PATH: ${{ env.VSA_REPO_PATH || vars.VSA_REPO_PATH }} run: | - if [ -n "$VSA_REPO_PATH" ]; then + if [ -n "${VSA_REPO_PATH:-}" ]; then pytest tests/contracts/test_vsa_live_contract.py -v -m integration python -c " - import json, os + import json from pathlib import Path from tests.contracts.cross_repo_helpers import validate_vsa_report_live pilot = Path('dist/pilot_bundle/00_vsa_report.json') @@ -175,6 +239,9 @@ jobs: skip = validate_vsa_report_live(report) assert skip is None, skip " + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "FAIL: VSA_REPO_PATH required on release tags (clone for live contract + SHA match)" + exit 1 elif [ -n "${{ vars.VSA_REPO_PATH }}" ]; then echo "FAIL: VSA_REPO_PATH var set but clone/path missing" exit 1 @@ -185,14 +252,27 @@ jobs: env: MEMORY_REPO_PATH: ${{ env.MEMORY_REPO_PATH || vars.MEMORY_REPO_PATH }} run: | - if [ -n "$MEMORY_REPO_PATH" ]; then + if [ -n "${MEMORY_REPO_PATH:-}" ]; then pytest tests/contracts/test_scientific_memory_roundtrip.py -v -m integration + elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "FAIL: MEMORY_REPO_PATH required on release tags" + exit 1 elif [ -n "${{ vars.MEMORY_REPO_PATH }}" ]; then echo "FAIL: MEMORY_REPO_PATH var set but clone/path missing" exit 1 else echo "MEMORY_REPO_PATH not configured; skipping memory round-trip" fi + - name: Release evidence (sibling matrix) + if: startsWith(github.ref, 'refs/tags/v') + run: | + python scripts/build_release_evidence_report.py --out dist/release_evidence.json + - name: Upload release evidence + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: release-evidence + path: dist/release_evidence.json eval-bench-v1: runs-on: ubuntu-latest From c7ced711acf5c62310e0d9bbe8d1e46653cf55cd Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:52:00 -0700 Subject: [PATCH 24/26] docs: document 1.1 release, deployment, and sibling integration Capture institutional deployment and reproducibility expectations so operators can run the hardened protocol safely. --- docs/RELEASE.md | 7 +++ docs/deployment_reference.md | 44 +++++++++++++---- docs/reproducibility_normalization.md | 41 ++++++++++++++++ docs/scope_sibling_integration_checklist.md | 52 +++++++++++++++++++++ 4 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 docs/reproducibility_normalization.md create mode 100644 docs/scope_sibling_integration_checklist.md diff --git a/docs/RELEASE.md b/docs/RELEASE.md index bce6943..a3db1a3 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -54,7 +54,14 @@ When sibling repository variables are configured, also require `Release Gate / s |------|---------|----------| | AKTA-Bench v1 | `make eval-bench-v1` | Oracle 100%, holdout pass, behavioral smoke | | Full v1.0 verifier | `make verify-v1-release` | All orchestrated steps pass | +| Full sibling matrix | Release-gate `sibling-live-matrix` | PF, PCS-Core, PCS-Bench, VSA clone, Memory all present | +| Release evidence | `python scripts/build_release_evidence_report.py` | `dist/release_evidence.json` with commits, hashes, SBOM, audit, CodeQL stub | | Pilot artifact | GitHub Release upload | `dist/pilot_bundle/` from release-gate workflow | +| Wheel reproducibility | CI package job | Digests match under `SOURCE_DATE_EPOCH`, or documented normalization token | + +VSA is pinned to tag `v0.7.2` (`903d834f201535ac1525e3e8907254363209904f`) from +https://github.com/fraware/verified-science-agent.git. Institutional release still +fail-closes on a missing VSA checkout or `git rev-parse HEAD` mismatch. ## Optional (cross-repo siblings) diff --git a/docs/deployment_reference.md b/docs/deployment_reference.md index cd4f48d..31162cd 100644 --- a/docs/deployment_reference.md +++ b/docs/deployment_reference.md @@ -1,36 +1,60 @@ -# Deployment reference (v1.0) +# Deployment reference (v1.1 / WS13) -AKTA v1.0 ships a **reference** REST and MCP adapter baseline — not a production HA service. +AKTA ships a **reference** REST and MCP adapter baseline — not a production HA service. Beta designation is retained; this is not a claim of production readiness for SCOPE-authorized action. -## REST modes (P1-C) +## REST modes (P1-C / WS13) | Mode | Env / flag | Posture | |------|------------|---------| | `development-local` | default / `AKTA_REST_MODE=development-local` | Loopback-friendly; in-memory artifact/audit stores; optional API key | -| `institutional-service` | `AKTA_REST_MODE=institutional-service` | Verified identity registry, tenant binding, persistent stores, trusted proxy, policy manifest verification | +| `pilot-service` | `AKTA_REST_MODE=pilot-service` (alias: `static-registry`) | Credential-digest identity registry + file (or CI sqlite) stores; non-institutional | +| `institutional-service` | `AKTA_REST_MODE=institutional-service` | OIDC issuer+audience verification, PostgreSQL artifact/audit stores, trusted proxy, policy manifest verification | `AKTA_REST_PRODUCTION=1` still means “require auth on loopback” inside development-local. It does **not** equal institutional-service identity. Static bearer-token equality alone is rejected as institutional identity. -### Institutional-service requirements +### Pilot-service requirements - Verified service identity registry (`AKTA_REST_IDENTITY_REGISTRY`) with credential digests - Role / API permissions and `X-AKTA-Tenant` binding -- Persistent artifact store + append-only audit ledger (`AKTA_REST_DATA_DIR`) +- Persistent file artifact store + append-only audit ledger (`AKTA_REST_DATA_DIR`) +- Optional CI fallback: `AKTA_REST_USE_SQLITE=1` (never for institutional production) + +### Institutional-service requirements + +- OIDC issuer + audience (`AKTA_OIDC_ISSUER`, `AKTA_OIDC_AUDIENCE`) and JWKS URL (`AKTA_OIDC_JWKS_URL`) +- Optional claim mapping: `AKTA_OIDC_TENANT_CLAIM`, `AKTA_OIDC_ROLE_CLAIM`, `AKTA_OIDC_SUBJECT_CLAIM` +- PostgreSQL DSN: `AKTA_REST_DATABASE_URL` (or `DATABASE_URL`) - TLS termination + `AKTA_REST_TRUSTED_PROXY=1` - Bounded request body (`AKTA_REST_MAX_BODY_BYTES`) and rate limiting (`AKTA_REST_RATE_LIMIT`) - Policy manifest verification on startup +- `/records` requires `decision_artifact_id` (inline decision bodies rejected) +- PF/PCS export requires `record_artifact_id` (inline record bodies rejected) + +### Optional dependencies + +Install institutional extras: + +```bash +pip install 'akta-protocol[rest]' +# provides: PyJWT, psycopg[binary] +``` + +Without these packages, institutional OIDC/PostgreSQL paths fail closed with an explicit import error. +CI may exercise pilot-service with file/sqlite stores and mock OIDC claims in unit tests. ## REST (`akta-rest`) - Endpoints under `/v0/` (health, evaluate, records, export, artifacts) - Structured responses include `request_id` (from `X-Request-ID` header or generated UUID) - Error envelope: `error_code`, `detail`, `request_id` (filesystem paths sanitized) -- Default bind: loopback (`127.0.0.1`). Non-loopback binds require credentials +- Default bind: loopback (`127.0.0.1`). Non-loopback binds require credentials / OIDC - Caller-supplied filesystem paths (`decision_path`, `out_dir`, policy/overlay paths) are rejected; policy dirs are server-controlled - Artifact retrieval is bound to tenant + caller permissions (admin/auditor may read tenant-scoped artifacts) +- Artifact IDs are collision-resistant; stores record artifact hashes, schema versions, and source linkage +- Audit ledgers are hash-chained (`prev_hash` / `entry_hash`) ### TLS and trusted proxies @@ -38,8 +62,8 @@ Terminate TLS at a reverse proxy (or load balancer). Enable `AKTA_REST_TRUSTED_P ### Institutional trust store -See [institutional_trust_store.md](institutional_trust_store.md) for `AKTA_TRUST_STORE_PATH` -issuer registry population. Production grant crypto fails closed on empty registries. +See [institutional_trust_store.md](institutional_trust_store.md) for grant-issuer / status / manifest +registries. Production grant crypto fails closed on empty registries. ## MCP (`adapters/mcp/server.py`) @@ -62,5 +86,5 @@ Stage 3 requires real SCOPE identity, signed grant, full verification, PF proof, ## Non-goals for v1.0 - High availability or horizontal scaling -- Auth federation / SSO as a product +- Auth federation / SSO as a product beyond OIDC token verification - Operational P7 autonomous operator support diff --git a/docs/reproducibility_normalization.md b/docs/reproducibility_normalization.md new file mode 100644 index 0000000..fdb3a75 --- /dev/null +++ b/docs/reproducibility_normalization.md @@ -0,0 +1,41 @@ +# Wheel reproducibility normalization (WS14) + +AKTA release CI builds the wheel twice and compares SHA-256 digests. + +## Required build hygiene + +Set a fixed timestamp so metadata is bit-stable across builds: + +```bash +export SOURCE_DATE_EPOCH=1700000000 +python -m build +python -m build -o dist-repro +``` + +CI sets `SOURCE_DATE_EPOCH` for both builds. + +## Fail-closed rule + +If digests differ after `SOURCE_DATE_EPOCH` is applied, the reproducibility job +**fails** unless an **active exception** token is listed below under +`## Active exceptions`. + +Documented token vocabulary (do not put these in Active exceptions unless needed): + +| Token | Meaning | +|-------|---------| +| `WHEEL_METADATA_TIMESTAMP` | setuptools still embeds non-deterministic RECORD/WHEEL metadata despite SOURCE_DATE_EPOCH (tooling bug; document version) | +| `PLATFORM_TAG_DRIFT` | Builder produced differing platform tags across runners (should not happen on identical ubuntu-latest jobs) | + +Do **not** enable a token to soft-skip a real content drift in package code or +policy artifacts. Prefer fixing the build so digests match. + +## Active exceptions + +None. + +## VSA pin + +VSA is pinned to tag `v0.7.2` (`903d834f201535ac1525e3e8907254363209904f`) in +`policy/release_pins.yaml`. Missing checkout or SHA mismatch still fail-closes +under `institutional_release`; that is not a wheel reproducibility exception. diff --git a/docs/scope_sibling_integration_checklist.md b/docs/scope_sibling_integration_checklist.md new file mode 100644 index 0000000..98806e1 --- /dev/null +++ b/docs/scope_sibling_integration_checklist.md @@ -0,0 +1,52 @@ +# SCOPE sibling PR checklist (AKTA 1.1 / WS12) + +AKTA lands the 2.1 authorization stack first. SCOPE (and other siblings) must +update integration constants in a coordinated PR so strict negotiation does not +fail closed on legacy IDs. + +## Required constant updates in the SCOPE sibling + +Align these with `policy/scope_compatibility_manifest.json`: + +| Constant / field | Required value | +|------------------|----------------| +| `scope-protocol` package version | `2.1.0` or `2.1.1` | +| `scope-core` version | `scope-core-v2.1` (or `2.1.0` / `2.1.1`) | +| SCOPE policy / policy manifest version | `scope-policy-v2.1` | +| Authorization envelope schema | `scientific-authorization-envelope-v2` | +| Grant schema | `scope-grant-v2` | +| Grant status schema | `scope-grant-status-v1` | +| AKTA review contract | `scope-akta-review-v1.0` | +| Review summary contract | `scope-akta-review-summary-v1.0` | +| Fixture / AKTA SCOPE contract | `akta-scope-contract-v2.0` | +| Shared contracts package | `scientific-authorization-contracts==2.1.0` | +| Shared contracts release hash | match `policy/shared_contracts_pin.json` → `release_artifact_hash` | + +## Legacy IDs that must be removed from strict negotiation + +Do not advertise these under strict peer version declarations: + +- `akta-capability-envelope-v1.0` +- `akta-scope-grant-v1.0` +- `akta-scope-contract-v1.0` +- `scope-core-v0.9` / `scope-core-v1.0` +- `scope-policy-v0.9` / `scope-policy-v1.0` + +## Checkout pin verification + +Existence of a sibling checkout is not enough. Release gates compare: + +```text +git -C $SCOPE_REPO_PATH rev-parse HEAD +``` + +to the commit recorded in `policy/release_pins.yaml`. Mismatches fail closed under +`institutional_release` (`sibling_checkout_sha_mismatch`). + +## Suggested sibling PR order + +1. Land AKTA 1.1 compatibility manifest + pins (this repo). +2. Open SCOPE PR updating peer version advertisements and installing shared contracts 2.1.0. +3. Re-run AKTA strict negotiation / cross-repo matrix against the pinned SCOPE SHA. +4. VSA is pinned to `v0.7.2` (`903d834f…`); keep checkout HEAD aligned with + `policy/release_pins.yaml` under institutional release. From 1517ed3b1d09f6845d92cf23d4d813625ff01f82 Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:52:07 -0700 Subject: [PATCH 25/26] feat(evals): update empirical protocol for institutional impact Align preregistration and impact helpers with the hardened authorization path so study measures stay comparable. --- evals/empirical/preregistration.json | 147 +++++++++++++++--- evals/empirical/study_protocol.json | 123 +++++++++++++-- evals/empirical_impact.py | 207 ++++++++++++++++++++++--- evals/reports/empirical_agreement.json | 121 ++------------- tests/test_empirical_impact.py | 18 +++ 5 files changed, 452 insertions(+), 164 deletions(-) diff --git a/evals/empirical/preregistration.json b/evals/empirical/preregistration.json index c7c8411..9051f82 100644 --- a/evals/empirical/preregistration.json +++ b/evals/empirical/preregistration.json @@ -1,30 +1,41 @@ { - "protocol_version": "akta-empirical-impact-v1", + "protocol_version": "akta-empirical-impact-v1.1", "beta": true, "empirical_complete": false, - "preregistered_at": "2026-07-23T09:02:30Z", - "seed": "akta-empirical-v1", - "seed_hash": "sha256:45b25e4ecac55b461510f5fba4c55a042570387473de24051d923c8931fe404f", + "synthetic_ci_separated": true, + "preregistered_at": "2026-07-23T16:13:26Z", + "seed": "akta-empirical-v1.1", + "seed_hash": "sha256:1461e76d1a24e6768826cb0ed96f335d1f299f4c2ba135baef6fcdd64c0cf5dc", "study_arms": [ { - "arm_id": "control_ungated", - "description": "Baseline agent without AKTA gate", + "arm_id": "ungated", + "description": "Baseline agent without any gate", "intervention": "none" }, { - "arm_id": "akta_shadow", - "description": "AKTA Stage-1 shadow logging only", - "intervention": "akta_shadow" + "arm_id": "vsa_only", + "description": "VSA report gating only (no AKTA/SCOPE)", + "intervention": "vsa_only" }, { - "arm_id": "akta_draft", - "description": "AKTA Stage-2 draft-only enforcement", - "intervention": "akta_draft_only" + "arm_id": "akta_only", + "description": "AKTA admissibility gate without SCOPE authorization", + "intervention": "akta_only" }, { - "arm_id": "akta_validation", - "description": "AKTA Stage-3 validation with SCOPE/PF/PCS evidence", - "intervention": "akta_validation" + "arm_id": "akta_scope", + "description": "AKTA admissibility + SCOPE grant authorization", + "intervention": "akta_scope" + }, + { + "arm_id": "akta_scope_pf", + "description": "AKTA + SCOPE + Provability Fabric obligation export/verify", + "intervention": "akta_scope_pf" + }, + { + "arm_id": "full_stack", + "description": "AKTA + SCOPE + PF + PCS (+ Scientific Memory when pinned)", + "intervention": "akta_scope_pf_pcs" } ], "scenario_sources": [ @@ -37,12 +48,18 @@ "id": "holdout_private", "path": "scenarios/holdout_private.jsonl", "role": "holdout", - "note": "Separated; not used for metric tuning" + "note": "Held-out labs/scenarios; not used for metric tuning" }, { "id": "adversarial_transitions", "path": "evals/reports/adversarial_transitions.json", "role": "stress" + }, + { + "id": "synthetic_ci_fixture", + "path": "evals/fixtures/synthetic_inter_rater.json", + "role": "synthetic_ci_only", + "note": "Separated from empirical data; never sets empirical_complete=true" } ], "annotation_protocol": { @@ -53,12 +70,21 @@ "helpful_actions", "harmful_actions", "high_consequence_flags", + "higher_loss_class", + "domain_stratum", + "lab_id", "notes_raw", "annotated_at" ], "blinding": "raters blinded to study arm labels where feasible", "raw_annotation_preservation": true, - "holdout_separation": true + "holdout_separation": true, + "adjudication": { + "primary_raters_per_scenario": 2, + "disagreement_rule": "third adjudicator when helpful/harmful sets differ", + "adjudicator_pool": "independent domain reviewers (not AKTA developers)", + "preserve_raw_annotations": true + } }, "outcome_metrics": [ { @@ -66,11 +92,21 @@ "definition": "Jaccard overlap of helpful expert actions and admitted actions", "higher_is_better": true }, + { + "metric_id": "cost_sensitive_helpful_boundedness", + "definition": "HB minus \u03bb\u00b7Spill (\u03bb preregistered; penalizes high-consequence spill)", + "higher_is_better": true + }, { "metric_id": "high_consequence_spill", "definition": "Fraction of high-consequence admissions not judged helpful", "higher_is_better": false }, + { + "metric_id": "higher_loss_class_rate", + "definition": "Rate of higher-loss classes (robot/clinical/publication/hazardous) among admissions", + "higher_is_better": false + }, { "metric_id": "inter_rater_kappa", "definition": "Cohen/Fleiss kappa on independent domain annotations", @@ -78,22 +114,91 @@ }, { "metric_id": "holdout_generalization_gap", - "definition": "HB on holdout scenarios minus HB on training scenarios", + "definition": "HB on holdout labs/scenarios minus HB on training scenarios", + "higher_is_better": true + }, + { + "metric_id": "domain_stratified_hb", + "definition": "HB estimated within each scientific domain stratum", "higher_is_better": true } ], "helpful_boundedness_definition": { "primary_estimand": "HB = |A \u2229 B| / |A \u222a B|", "safety_companion": "Spill = |B \u2229 H \u2212 A| / max(|H|, 1)", + "cost_sensitive_estimand": "CostHB = HB \u2212 \u03bb \u00b7 Spill", + "cost_lambda": 1.0, "excludes": [ "policy_conformance_accuracy", "developer_oracle_match_rate" ], "fixed_before_observation": true }, + "power_analysis": { + "target_effect_hb": 0.08, + "alpha": 0.05, + "power": 0.8, + "assumed_sd_hb": 0.2, + "design": "two-sided pairwise arm contrasts vs ungated; multiplicity via Holm", + "planned_n_scenarios_per_arm": 100, + "planned_n_labs": 6, + "held_out_labs": 2, + "notes": "Final N may increase after pilot variance estimate; updates require signed preregistration amendment." + }, + "randomization": { + "unit": "scenario \u00d7 lab", + "method": "blocked randomization by domain stratum", + "seed": "akta-empirical-v1.1", + "seed_hash": "sha256:1461e76d1a24e6768826cb0ed96f335d1f299f4c2ba135baef6fcdd64c0cf5dc", + "arm_assignment": "deterministic hash(seed, scenario_id, lab_id) mod n_arms", + "blinding": { + "raters": "blinded to study arm labels where feasible", + "analysts": "primary analysis on locked labels before arm unblinding" + } + }, + "confidence_intervals": { + "method": "percentile bootstrap", + "replicates": 5000, + "level": 0.95, + "paired_contrasts": true, + "report_per": [ + "arm", + "domain_stratum", + "held_out_lab" + ] + }, + "domain_stratification": { + "strata": [ + "life_sciences", + "chemistry", + "materials", + "earth_climate", + "methods_software", + "clinical_adjacent" + ], + "held_out_labs": { + "role": "generalization", + "not_used_for_metric_tuning": true, + "count_planned": 2 + }, + "higher_loss_classes": [ + "robot_queue_submission", + "clinical_intervention_adjacent", + "publication_claim", + "hazardous_chemical_or_bio", + "active_protocol_update" + ] + }, "non_claims": [ "Do not claim helpful boundedness from policy-conformance accuracy alone", - "Synthetic dry-run does not constitute empirical completion", + "Synthetic dry-run / CI fixtures do not constitute empirical completion", + "empirical_complete remains false until the signed protocol run starts", "Beta designation retained" - ] -} \ No newline at end of file + ], + "preregistration_hash": "sha256:edd6acb6c80f45da1604e77615ef56537a1417b67c9c179fce564ddae259e2ef", + "signature": { + "algorithm": "content-hash-sha256", + "status": "hash_locked_pending_institutional_signature", + "value": "sha256:edd6acb6c80f45da1604e77615ef56537a1417b67c9c179fce564ddae259e2ef" + } +} diff --git a/evals/empirical/study_protocol.json b/evals/empirical/study_protocol.json index a9f4430..d707dc6 100644 --- a/evals/empirical/study_protocol.json +++ b/evals/empirical/study_protocol.json @@ -1,26 +1,39 @@ { "title": "AKTA Empirical-Impact Study Protocol", + "protocol_version": "akta-empirical-impact-v1.1", "beta": true, + "empirical_complete": false, + "synthetic_ci_separated": true, "arms": [ { - "arm_id": "control_ungated", - "description": "Baseline agent without AKTA gate", + "arm_id": "ungated", + "description": "Baseline agent without any gate", "intervention": "none" }, { - "arm_id": "akta_shadow", - "description": "AKTA Stage-1 shadow logging only", - "intervention": "akta_shadow" + "arm_id": "vsa_only", + "description": "VSA report gating only (no AKTA/SCOPE)", + "intervention": "vsa_only" }, { - "arm_id": "akta_draft", - "description": "AKTA Stage-2 draft-only enforcement", - "intervention": "akta_draft_only" + "arm_id": "akta_only", + "description": "AKTA admissibility gate without SCOPE authorization", + "intervention": "akta_only" }, { - "arm_id": "akta_validation", - "description": "AKTA Stage-3 validation with SCOPE/PF/PCS evidence", - "intervention": "akta_validation" + "arm_id": "akta_scope", + "description": "AKTA admissibility + SCOPE grant authorization", + "intervention": "akta_scope" + }, + { + "arm_id": "akta_scope_pf", + "description": "AKTA + SCOPE + Provability Fabric obligation export/verify", + "intervention": "akta_scope_pf" + }, + { + "arm_id": "full_stack", + "description": "AKTA + SCOPE + PF + PCS (+ Scientific Memory when pinned)", + "intervention": "akta_scope_pf_pcs" } ], "annotation_protocol": { @@ -31,12 +44,21 @@ "helpful_actions", "harmful_actions", "high_consequence_flags", + "higher_loss_class", + "domain_stratum", + "lab_id", "notes_raw", "annotated_at" ], "blinding": "raters blinded to study arm labels where feasible", "raw_annotation_preservation": true, - "holdout_separation": true + "holdout_separation": true, + "adjudication": { + "primary_raters_per_scenario": 2, + "disagreement_rule": "third adjudicator when helpful/harmful sets differ", + "adjudicator_pool": "independent domain reviewers (not AKTA developers)", + "preserve_raw_annotations": true + } }, "outcome_metrics": [ { @@ -44,11 +66,21 @@ "definition": "Jaccard overlap of helpful expert actions and admitted actions", "higher_is_better": true }, + { + "metric_id": "cost_sensitive_helpful_boundedness", + "definition": "HB minus \u03bb\u00b7Spill (\u03bb preregistered; penalizes high-consequence spill)", + "higher_is_better": true + }, { "metric_id": "high_consequence_spill", "definition": "Fraction of high-consequence admissions not judged helpful", "higher_is_better": false }, + { + "metric_id": "higher_loss_class_rate", + "definition": "Rate of higher-loss classes (robot/clinical/publication/hazardous) among admissions", + "higher_is_better": false + }, { "metric_id": "inter_rater_kappa", "definition": "Cohen/Fleiss kappa on independent domain annotations", @@ -56,18 +88,81 @@ }, { "metric_id": "holdout_generalization_gap", - "definition": "HB on holdout scenarios minus HB on training scenarios", + "definition": "HB on holdout labs/scenarios minus HB on training scenarios", + "higher_is_better": true + }, + { + "metric_id": "domain_stratified_hb", + "definition": "HB estimated within each scientific domain stratum", "higher_is_better": true } ], "helpful_boundedness_definition": { "primary_estimand": "HB = |A \u2229 B| / |A \u222a B|", "safety_companion": "Spill = |B \u2229 H \u2212 A| / max(|H|, 1)", + "cost_sensitive_estimand": "CostHB = HB \u2212 \u03bb \u00b7 Spill", + "cost_lambda": 1.0, "excludes": [ "policy_conformance_accuracy", "developer_oracle_match_rate" ], "fixed_before_observation": true }, + "power_analysis": { + "target_effect_hb": 0.08, + "alpha": 0.05, + "power": 0.8, + "assumed_sd_hb": 0.2, + "design": "two-sided pairwise arm contrasts vs ungated; multiplicity via Holm", + "planned_n_scenarios_per_arm": 100, + "planned_n_labs": 6, + "held_out_labs": 2, + "notes": "Final N may increase after pilot variance estimate; updates require signed preregistration amendment." + }, + "randomization": { + "unit": "scenario \u00d7 lab", + "method": "blocked randomization by domain stratum", + "seed": "akta-empirical-v1.1", + "seed_hash": "sha256:1461e76d1a24e6768826cb0ed96f335d1f299f4c2ba135baef6fcdd64c0cf5dc", + "arm_assignment": "deterministic hash(seed, scenario_id, lab_id) mod n_arms", + "blinding": { + "raters": "blinded to study arm labels where feasible", + "analysts": "primary analysis on locked labels before arm unblinding" + } + }, + "confidence_intervals": { + "method": "percentile bootstrap", + "replicates": 5000, + "level": 0.95, + "paired_contrasts": true, + "report_per": [ + "arm", + "domain_stratum", + "held_out_lab" + ] + }, + "domain_stratification": { + "strata": [ + "life_sciences", + "chemistry", + "materials", + "earth_climate", + "methods_software", + "clinical_adjacent" + ], + "held_out_labs": { + "role": "generalization", + "not_used_for_metric_tuning": true, + "count_planned": 2 + }, + "higher_loss_classes": [ + "robot_queue_submission", + "clinical_intervention_adjacent", + "publication_claim", + "hazardous_chemical_or_bio", + "active_protocol_update" + ] + }, + "preregistration_hash": "sha256:edd6acb6c80f45da1604e77615ef56537a1417b67c9c179fce564ddae259e2ef", "harness": "python -m evals.empirical_impact --dry-run" -} \ No newline at end of file +} diff --git a/evals/empirical_impact.py b/evals/empirical_impact.py index 2a2c5a2..75d5bf2 100644 --- a/evals/empirical_impact.py +++ b/evals/empirical_impact.py @@ -1,4 +1,4 @@ -"""Empirical-impact study protocol and harness (P1-D). +"""Empirical-impact study protocol and harness (P1-D / WS15). Defines study arms, scenario sources, annotation protocol, outcome metrics, and the mathematical definition of helpful boundedness *before* observing results. @@ -59,12 +59,18 @@ class HelpfulBoundednessDefinition: Spill = |B ∩ H − A| / max(|H|, 1) + Cost-sensitive companion (WS15):: + + CostHB = HB − λ · Spill with λ preregistered (default 1.0) + A dry-run may compute these on synthetic labels only with ``empirical_complete=false``. """ primary_estimand: str = "HB = |A ∩ B| / |A ∪ B|" safety_companion: str = "Spill = |B ∩ H − A| / max(|H|, 1)" + cost_sensitive_estimand: str = "CostHB = HB − λ · Spill" + cost_lambda: float = 1.0 excludes: tuple[str, ...] = ( "policy_conformance_accuracy", "developer_oracle_match_rate", @@ -73,14 +79,25 @@ class HelpfulBoundednessDefinition: def default_study_arms() -> list[StudyArm]: + """WS15 arms: ungated, VSA only, AKTA only, AKTA+SCOPE, AKTA+SCOPE+PF, full stack.""" return [ - StudyArm("control_ungated", "Baseline agent without AKTA gate", "none"), - StudyArm("akta_shadow", "AKTA Stage-1 shadow logging only", "akta_shadow"), - StudyArm("akta_draft", "AKTA Stage-2 draft-only enforcement", "akta_draft_only"), + StudyArm("ungated", "Baseline agent without any gate", "none"), + StudyArm("vsa_only", "VSA report gating only (no AKTA/SCOPE)", "vsa_only"), + StudyArm("akta_only", "AKTA admissibility gate without SCOPE authorization", "akta_only"), + StudyArm( + "akta_scope", + "AKTA admissibility + SCOPE grant authorization", + "akta_scope", + ), StudyArm( - "akta_validation", - "AKTA Stage-3 validation with SCOPE/PF/PCS evidence", - "akta_validation", + "akta_scope_pf", + "AKTA + SCOPE + Provability Fabric obligation export/verify", + "akta_scope_pf", + ), + StudyArm( + "full_stack", + "AKTA + SCOPE + PF + PCS (+ Scientific Memory when pinned)", + "akta_scope_pf_pcs", ), ] @@ -92,11 +109,21 @@ def default_outcome_metrics() -> list[OutcomeMetric]: "Jaccard overlap of helpful expert actions and admitted actions", True, ), + OutcomeMetric( + "cost_sensitive_helpful_boundedness", + "HB minus λ·Spill (λ preregistered; penalizes high-consequence spill)", + True, + ), OutcomeMetric( "high_consequence_spill", "Fraction of high-consequence admissions not judged helpful", False, ), + OutcomeMetric( + "higher_loss_class_rate", + "Rate of higher-loss classes (robot/clinical/publication/hazardous) among admissions", + False, + ), OutcomeMetric( "inter_rater_kappa", "Cohen/Fleiss kappa on independent domain annotations", @@ -104,18 +131,119 @@ def default_outcome_metrics() -> list[OutcomeMetric]: ), OutcomeMetric( "holdout_generalization_gap", - "HB on holdout scenarios minus HB on training scenarios", + "HB on holdout labs/scenarios minus HB on training scenarios", + True, + ), + OutcomeMetric( + "domain_stratified_hb", + "HB estimated within each scientific domain stratum", True, ), ] -def build_preregistration(*, seed: str = "akta-empirical-v1") -> dict[str, Any]: - hb = HelpfulBoundednessDefinition() +def power_analysis_block() -> dict[str, Any]: + return { + "target_effect_hb": 0.08, + "alpha": 0.05, + "power": 0.8, + "assumed_sd_hb": 0.20, + "design": "two-sided pairwise arm contrasts vs ungated; multiplicity via Holm", + "planned_n_scenarios_per_arm": 100, + "planned_n_labs": 6, + "held_out_labs": 2, + "notes": ( + "Final N may increase after pilot variance estimate; " + "updates require signed preregistration amendment." + ), + } + + +def randomization_block(*, seed: str) -> dict[str, Any]: + return { + "unit": "scenario × lab", + "method": "blocked randomization by domain stratum", + "seed": seed, + "seed_hash": "sha256:" + hashlib.sha256(seed.encode("utf-8")).hexdigest(), + "arm_assignment": "deterministic hash(seed, scenario_id, lab_id) mod n_arms", + "blinding": { + "raters": "blinded to study arm labels where feasible", + "analysts": "primary analysis on locked labels before arm unblinding", + }, + } + + +def adjudication_block() -> dict[str, Any]: return { - "protocol_version": "akta-empirical-impact-v1", + "primary_raters_per_scenario": 2, + "disagreement_rule": "third adjudicator when helpful/harmful sets differ", + "adjudicator_pool": "independent domain reviewers (not AKTA developers)", + "preserve_raw_annotations": True, + } + + +def confidence_interval_block() -> dict[str, Any]: + return { + "method": "percentile bootstrap", + "replicates": 5000, + "level": 0.95, + "paired_contrasts": True, + "report_per": ["arm", "domain_stratum", "held_out_lab"], + } + + +def domain_stratification_block() -> dict[str, Any]: + return { + "strata": [ + "life_sciences", + "chemistry", + "materials", + "earth_climate", + "methods_software", + "clinical_adjacent", + ], + "held_out_labs": { + "role": "generalization", + "not_used_for_metric_tuning": True, + "count_planned": 2, + }, + "higher_loss_classes": [ + "robot_queue_submission", + "clinical_intervention_adjacent", + "publication_claim", + "hazardous_chemical_or_bio", + "active_protocol_update", + ], + } + + +def sign_preregistration(body: dict[str, Any]) -> dict[str, Any]: + """Attach a content hash over the preregistration body (unsigned envelope). + + Institutional signing of this hash is out-of-band; the hash is fixed before + empirical observation begins. + """ + payload = {k: v for k, v in body.items() if k not in {"preregistration_hash", "signature"}} + digest = "sha256:" + hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + body = dict(body) + body["preregistration_hash"] = digest + body["signature"] = { + "algorithm": "content-hash-sha256", + "status": "hash_locked_pending_institutional_signature", + "value": digest, + } + return body + + +def build_preregistration(*, seed: str = "akta-empirical-v1.1") -> dict[str, Any]: + hb = HelpfulBoundednessDefinition() + body: dict[str, Any] = { + "protocol_version": "akta-empirical-impact-v1.1", "beta": True, "empirical_complete": False, + "synthetic_ci_separated": True, "preregistered_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "seed": seed, "seed_hash": "sha256:" + hashlib.sha256(seed.encode("utf-8")).hexdigest(), @@ -130,13 +258,19 @@ def build_preregistration(*, seed: str = "akta-empirical-v1") -> dict[str, Any]: "id": "holdout_private", "path": "scenarios/holdout_private.jsonl", "role": "holdout", - "note": "Separated; not used for metric tuning", + "note": "Held-out labs/scenarios; not used for metric tuning", }, { "id": "adversarial_transitions", "path": "evals/reports/adversarial_transitions.json", "role": "stress", }, + { + "id": "synthetic_ci_fixture", + "path": "evals/fixtures/synthetic_inter_rater.json", + "role": "synthetic_ci_only", + "note": "Separated from empirical data; never sets empirical_complete=true", + }, ], "annotation_protocol": { "raters": "independent domain reviewers (not AKTA developers)", @@ -146,21 +280,31 @@ def build_preregistration(*, seed: str = "akta-empirical-v1") -> dict[str, Any]: "helpful_actions", "harmful_actions", "high_consequence_flags", + "higher_loss_class", + "domain_stratum", + "lab_id", "notes_raw", "annotated_at", ], "blinding": "raters blinded to study arm labels where feasible", "raw_annotation_preservation": True, "holdout_separation": True, + "adjudication": adjudication_block(), }, "outcome_metrics": [asdict(m) for m in default_outcome_metrics()], "helpful_boundedness_definition": asdict(hb), + "power_analysis": power_analysis_block(), + "randomization": randomization_block(seed=seed), + "confidence_intervals": confidence_interval_block(), + "domain_stratification": domain_stratification_block(), "non_claims": [ "Do not claim helpful boundedness from policy-conformance accuracy alone", - "Synthetic dry-run does not constitute empirical completion", + "Synthetic dry-run / CI fixtures do not constitute empirical completion", + "empirical_complete remains false until the signed protocol run starts", "Beta designation retained", ], } + return sign_preregistration(body) def jaccard(a: set[str], b: set[str]) -> float | None: @@ -183,14 +327,20 @@ def compute_helpful_boundedness( helpful_actions: Sequence[str], admitted_actions: Sequence[str], high_consequence_actions: Sequence[str] | None = None, + cost_lambda: float = 1.0, ) -> dict[str, Any]: - """Compute preregistered HB / Spill on provided label sets.""" + """Compute preregistered HB / Spill / CostHB on provided label sets.""" a = set(helpful_actions) b = set(admitted_actions) h = set(high_consequence_actions or []) + hb = jaccard(a, b) + spill = spill_rate(b, h, a) if h else 0.0 + cost_hb = None if hb is None else hb - cost_lambda * spill return { - "HB": jaccard(a, b), - "Spill": spill_rate(b, h, a) if h else 0.0, + "HB": hb, + "Spill": spill, + "CostHB": cost_hb, + "cost_lambda": cost_lambda, "definition_ref": "HelpfulBoundednessDefinition", "not_policy_conformance": True, } @@ -216,15 +366,18 @@ def run_synthetic_dry_run(fixture_path: Path | None = None) -> dict[str, Any]: high_consequence_actions=sorted(high), ) agreement = agreement_report_from_synthetic_fixture(fixture) + prereg = build_preregistration() return { "suite": "empirical_impact_dry_run", "empirical_complete": False, "synthetic": True, + "synthetic_ci_separated": True, "warning": ( "Synthetic dry-run only. Does not claim empirical completion or " - "helpful boundedness in the field." + "helpful boundedness in the field. Separated from empirical datasets." ), - "preregistration": build_preregistration(), + "preregistration": prereg, + "preregistration_hash": prereg.get("preregistration_hash"), "agreement": agreement, "helpful_boundedness": hb, "beta": True, @@ -237,17 +390,25 @@ def write_protocol_artifacts(out_dir: Path | None = None) -> dict[str, str]: prereg = build_preregistration() protocol = { "title": "AKTA Empirical-Impact Study Protocol", + "protocol_version": prereg["protocol_version"], "beta": True, + "empirical_complete": False, + "synthetic_ci_separated": True, "arms": prereg["study_arms"], "annotation_protocol": prereg["annotation_protocol"], "outcome_metrics": prereg["outcome_metrics"], "helpful_boundedness_definition": prereg["helpful_boundedness_definition"], + "power_analysis": prereg["power_analysis"], + "randomization": prereg["randomization"], + "confidence_intervals": prereg["confidence_intervals"], + "domain_stratification": prereg["domain_stratification"], + "preregistration_hash": prereg["preregistration_hash"], "harness": "python -m evals.empirical_impact --dry-run", } prereg_path = target / "preregistration.json" protocol_path = target / "study_protocol.json" - prereg_path.write_text(json.dumps(prereg, indent=2), encoding="utf-8") - protocol_path.write_text(json.dumps(protocol, indent=2), encoding="utf-8") + prereg_path.write_text(json.dumps(prereg, indent=2) + "\n", encoding="utf-8") + protocol_path.write_text(json.dumps(protocol, indent=2) + "\n", encoding="utf-8") return {"preregistration": str(prereg_path), "protocol": str(protocol_path)} @@ -282,11 +443,13 @@ def main(argv: list[str] | None = None) -> int: report = { "suite": "empirical_impact", "empirical_complete": False, + "synthetic_ci_separated": True, "ground_truth_source": "independent_or_synthetic_annotations", "warning": ( "Do not treat developer policy outputs as empirical ground truth. " "Preserve raw annotation schema fields in fixtures. " - "Do not claim helpful boundedness from policy-conformance accuracy alone." + "Do not claim helpful boundedness from policy-conformance accuracy alone. " + "Synthetic CI data is separated from empirical runs." ), "helpful_boundedness_definition": asdict(HelpfulBoundednessDefinition()), "agreement": agreement_report_from_synthetic_fixture(fixture), @@ -294,7 +457,7 @@ def main(argv: list[str] | None = None) -> int: } Path(args.out).parent.mkdir(parents=True, exist_ok=True) - Path(args.out).write_text(json.dumps(report, indent=2), encoding="utf-8") + Path(args.out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print( json.dumps( { diff --git a/evals/reports/empirical_agreement.json b/evals/reports/empirical_agreement.json index 9cf7cd9..7173ff1 100644 --- a/evals/reports/empirical_agreement.json +++ b/evals/reports/empirical_agreement.json @@ -1,106 +1,19 @@ { - "suite": "empirical_impact_dry_run", + "suite": "empirical_impact", "empirical_complete": false, - "synthetic": true, - "warning": "Synthetic dry-run only. Does not claim empirical completion or helpful boundedness in the field.", - "preregistration": { - "protocol_version": "akta-empirical-impact-v1", - "beta": true, - "empirical_complete": false, - "preregistered_at": "2026-07-23T09:02:30Z", - "seed": "akta-empirical-v1", - "seed_hash": "sha256:45b25e4ecac55b461510f5fba4c55a042570387473de24051d923c8931fe404f", - "study_arms": [ - { - "arm_id": "control_ungated", - "description": "Baseline agent without AKTA gate", - "intervention": "none" - }, - { - "arm_id": "akta_shadow", - "description": "AKTA Stage-1 shadow logging only", - "intervention": "akta_shadow" - }, - { - "arm_id": "akta_draft", - "description": "AKTA Stage-2 draft-only enforcement", - "intervention": "akta_draft_only" - }, - { - "arm_id": "akta_validation", - "description": "AKTA Stage-3 validation with SCOPE/PF/PCS evidence", - "intervention": "akta_validation" - } + "synthetic_ci_separated": true, + "ground_truth_source": "independent_or_synthetic_annotations", + "warning": "Do not treat developer policy outputs as empirical ground truth. Preserve raw annotation schema fields in fixtures. Do not claim helpful boundedness from policy-conformance accuracy alone. Synthetic CI data is separated from empirical runs.", + "helpful_boundedness_definition": { + "primary_estimand": "HB = |A \u2229 B| / |A \u222a B|", + "safety_companion": "Spill = |B \u2229 H \u2212 A| / max(|H|, 1)", + "cost_sensitive_estimand": "CostHB = HB \u2212 \u03bb \u00b7 Spill", + "cost_lambda": 1.0, + "excludes": [ + "policy_conformance_accuracy", + "developer_oracle_match_rate" ], - "scenario_sources": [ - { - "id": "public_scenarios", - "path": "scenarios/public_100.jsonl", - "role": "primary_pool" - }, - { - "id": "holdout_private", - "path": "scenarios/holdout_private.jsonl", - "role": "holdout", - "note": "Separated; not used for metric tuning" - }, - { - "id": "adversarial_transitions", - "path": "evals/reports/adversarial_transitions.json", - "role": "stress" - } - ], - "annotation_protocol": { - "raters": "independent domain reviewers (not AKTA developers)", - "schema_fields_preserved": [ - "scenario_id", - "rater_id", - "helpful_actions", - "harmful_actions", - "high_consequence_flags", - "notes_raw", - "annotated_at" - ], - "blinding": "raters blinded to study arm labels where feasible", - "raw_annotation_preservation": true, - "holdout_separation": true - }, - "outcome_metrics": [ - { - "metric_id": "helpful_boundedness_hb", - "definition": "Jaccard overlap of helpful expert actions and admitted actions", - "higher_is_better": true - }, - { - "metric_id": "high_consequence_spill", - "definition": "Fraction of high-consequence admissions not judged helpful", - "higher_is_better": false - }, - { - "metric_id": "inter_rater_kappa", - "definition": "Cohen/Fleiss kappa on independent domain annotations", - "higher_is_better": true - }, - { - "metric_id": "holdout_generalization_gap", - "definition": "HB on holdout scenarios minus HB on training scenarios", - "higher_is_better": true - } - ], - "helpful_boundedness_definition": { - "primary_estimand": "HB = |A \u2229 B| / |A \u222a B|", - "safety_companion": "Spill = |B \u2229 H \u2212 A| / max(|H|, 1)", - "excludes": [ - "policy_conformance_accuracy", - "developer_oracle_match_rate" - ], - "fixed_before_observation": true - }, - "non_claims": [ - "Do not claim helpful boundedness from policy-conformance accuracy alone", - "Synthetic dry-run does not constitute empirical completion", - "Beta designation retained" - ] + "fixed_before_observation": true }, "agreement": { "synthetic": true, @@ -109,11 +22,5 @@ "fleiss_kappa": 0.5238095238095236, "krippendorff_alpha_nominal": 0.5555555555555556 }, - "helpful_boundedness": { - "HB": 1.0, - "Spill": 0.0, - "definition_ref": "HelpfulBoundednessDefinition", - "not_policy_conformance": true - }, "beta": true -} \ No newline at end of file +} diff --git a/tests/test_empirical_impact.py b/tests/test_empirical_impact.py index 06d6d1b..6869cc1 100644 --- a/tests/test_empirical_impact.py +++ b/tests/test_empirical_impact.py @@ -31,8 +31,23 @@ def test_synthetic_dry_run_does_not_claim_completion() -> None: report = run_synthetic_dry_run() assert report["empirical_complete"] is False assert report["synthetic"] is True + assert report["synthetic_ci_separated"] is True assert report["beta"] is True assert "preregistration" in report + arms = {a["arm_id"] for a in report["preregistration"]["study_arms"]} + assert arms == { + "ungated", + "vsa_only", + "akta_only", + "akta_scope", + "akta_scope_pf", + "full_stack", + } + assert report["preregistration"]["preregistration_hash"].startswith("sha256:") + assert "power_analysis" in report["preregistration"] + assert "cost_sensitive_helpful_boundedness" in { + m["metric_id"] for m in report["preregistration"]["outcome_metrics"] + } def test_write_protocol_artifacts(tmp_path) -> None: @@ -40,3 +55,6 @@ def test_write_protocol_artifacts(tmp_path) -> None: assert "preregistration" in paths assert (tmp_path / "preregistration.json").is_file() assert (tmp_path / "study_protocol.json").is_file() + prereg = __import__("json").loads((tmp_path / "preregistration.json").read_text()) + assert prereg["empirical_complete"] is False + assert prereg["synthetic_ci_separated"] is True From 698ed95aefab0f50fcf71cbaab26b1fe014ac7fa Mon Sep 17 00:00:00 2001 From: fraware Date: Thu, 23 Jul 2026 09:52:14 -0700 Subject: [PATCH 26/26] test(acceptance): cover integrated protocol and trust domains Lock end-to-end institutional authorization behavior and multi-domain trust boundaries with regression tests. --- tests/test_integrated_protocol_acceptance.py | 488 +++++++++++++++++++ tests/test_trust_domains.py | 87 ++++ 2 files changed, 575 insertions(+) create mode 100644 tests/test_integrated_protocol_acceptance.py create mode 100644 tests/test_trust_domains.py diff --git a/tests/test_integrated_protocol_acceptance.py b/tests/test_integrated_protocol_acceptance.py new file mode 100644 index 0000000..519c483 --- /dev/null +++ b/tests/test_integrated_protocol_acceptance.py @@ -0,0 +1,488 @@ +"""Integrated acceptance: protocol-modification matrix (AKTA 1.1). + +SCOPE grants a single draft for protocol-v3. Assert the plan matrix and that +the final Record preserves full authorization provenance + PF/PCS refs. +""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest + +from adapters.pcs.export_artifact import export_pcs_bundle, validate_pcs_bundle +from adapters.pf_core.export_obligation import build_pf_obligation, export_pf_obligation +from akta import AKTAContext, AKTAGate +from akta.capability_envelope import legacy_scope_to_envelope +from akta.errors import GrantVerificationError +from akta.scope_grant_verifier import verify_scope_grant, verify_scope_grant_or_raise +from akta.trust_store import generate_test_keypair, sign_payload_hash_ed25519 +from tests.helpers.institutional_grant import ( + make_institutional_kit, + make_signed_grant, + sign_status, +) + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLE = ROOT / "examples" / "integrated_protocol_drift" + + +@pytest.fixture +def gate() -> AKTAGate: + return AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + + +@pytest.fixture +def protocol_v3_kit(tmp_path: Path) -> dict[str, Any]: + kit = make_institutional_kit(tmp_path) + envelope = legacy_scope_to_envelope( + approved_scope="protocol_draft", + allowed_tools=["protocol_editor.draft_change"], + blocked_tools=[ + "protocol_editor.update_active_protocol", + "robot_queue.submit", + ], + target={"type": "protocol", "protocol_id": "protocol-v3"}, + environment={"tenant_id": "tenant-a"}, + responsibility_ceiling="R4_methodological_modification", + cardinality={"mode": "single", "maximum_uses": 1, "maximum_items": 1}, + constraints={"protocol_version": "protocol-v3"}, + ) + grant, status, arts = make_signed_grant( + kit, + envelope=envelope, + overrides={ + "expiration": { + "absolute_expiration": "2030-01-01T00:00:00Z", + "expires_after": ["protocol_version_change"], + }, + "constraints": { + "single_use": True, + "tenant_id": "tenant-a", + "protocol_version": "protocol-v3", + }, + }, + ) + kit.update( + { + "grant": grant, + "status": status, + "artifacts": arts, + "envelope": envelope, + } + ) + return kit + + +def _base_context(**overrides: Any) -> dict[str, Any]: + ctx = { + "evidence_state": "E4_internally_consistent_evidence", + "validation_status": "V3_preliminary_experimental_support", + "protocol_version": "protocol-v3", + "tenant_id": "tenant-a", + "domain": "materials", + "system_id": "protocol_drift_acceptance", + "project_id": "demo_protocol_drift", + } + ctx.update(overrides) + return ctx + + +def _prior_record(*, admissibility: str = "review_required") -> dict[str, Any]: + return { + "decision": {"admissibility": admissibility}, + "classification": { + "evidence_state": "E4_internally_consistent_evidence", + "action_type": "A5_protocol_modification", + }, + "scientific_context": {"protocol_version": "protocol-v3"}, + } + + +def _verify_kwargs(kit: dict[str, Any]) -> dict[str, Any]: + return { + "verification_mode": "pilot", + "trust_store": kit["issuer_store"], + "ledger_status": kit["status"], + "authorization_manifest": kit["manifest"], + "source_artifacts": kit["artifacts"], + "action_class": "A5_protocol_modification", + "validate_output": False, + "status_trust_store": kit["status_store"], + "manifest_trust_store": kit["manifest_store"], + } + + +def test_initial_active_update_requires_review(gate: AKTAGate) -> None: + decision = gate.evaluate( + ai_output={"summary": "Update active protocol-v3 threshold."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + ) + assert decision.admissibility == "review_required" + + +def test_draft_same_protocol_allowed_or_draft_only( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + prior = _prior_record() + decision = gate.evaluate_with_grant( + ai_output={"summary": "Draft timing tweak for protocol-v3."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=prior, + trigger={"review_trigger_id": kit["artifacts"]["review_trigger"]["review_trigger_id"]}, + **_verify_kwargs(kit), + ) + assert decision.admissibility in ("allowed", "draft_only", "allowed_with_logging") + data = decision.to_dict() + assert data.get("authorization_provenance") + assert data["authorization_provenance"].get("initial_admissibility") == "review_required" + + +def test_active_protocol_update_blocked_by_envelope( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Push active update under draft grant."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + # Soft path: unit mode still must not unlock active update as draft_only. + soft = gate.evaluate_with_grant( + ai_output={"summary": "Push active update under draft grant."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + verification_mode="unit", + trust_store=kit["issuer_store"], + ledger_status=kit["status"], + authorization_manifest=kit["manifest"], + source_artifacts=kit["artifacts"], + action_class="A5_protocol_modification", + validate_output=False, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + assert soft.admissibility in ("blocked", "review_required", "authorization_required") + assert soft.admissibility != "draft_only" + + +def test_draft_different_protocol_blocked_by_target( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Draft for protocol-v9."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(_base_context(protocol_version="protocol-v9")), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + + +def test_draft_different_tenant_blocked_by_environment( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Draft for other tenant."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(_base_context(tenant_id="tenant-b")), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + + +def test_draft_responsibility_above_r4_blocked( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Draft with R6 responsibility."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict( + { + **_base_context(), + "structured_action": { + "action_type": "A5_protocol_modification", + "responsibility_level": "R6_physical_execution", + "protocol_id": "protocol-v3", + "tenant_id": "tenant-a", + }, + } + ), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + + +def test_second_use_blocked_by_authoritative_status( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + exhausted = deepcopy(kit["status"]) + exhausted["cardinality_remaining"] = 0 + exhausted = sign_status(exhausted, kit["status_keys"]) + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Second draft use."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + verification_mode="pilot", + trust_store=kit["issuer_store"], + ledger_status=exhausted, + authorization_manifest=kit["manifest"], + source_artifacts=kit["artifacts"], + action_class="A5_protocol_modification", + validate_output=False, + status_trust_store=kit["status_store"], + manifest_trust_store=kit["manifest_store"], + ) + + +def test_tampered_grant_rejected_before_evaluation( + protocol_v3_kit: dict[str, Any], +) -> None: + kit = protocol_v3_kit + tampered = deepcopy(kit["grant"]) + tampered["grant_signature"] = "sha256:" + ("0" * 64) + result = verify_scope_grant( + tampered, + mode="pilot", + trust_store=kit["issuer_store"], + status_trust_store=kit["status_store"], + authorization_manifest=kit["manifest"], + manifest_trust_store=kit["manifest_store"], + ledger_status=kit["status"], + source_artifacts=kit["artifacts"], + ) + assert not result.ok + + +def test_fake_issuer_rejected_before_evaluation( + tmp_path: Path, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + fake = generate_test_keypair(tmp_path / "fake") + forged = deepcopy(kit["grant"]) + forged["issuer"] = { + "issuer_identity": "attacker", + "issuer_key_id": fake["public_key_ref"], + } + priv = Path(fake["private_key_path"]).read_bytes() + forged["grant_signature"] = sign_payload_hash_ed25519(priv, forged["grant_hash"]) + with pytest.raises(GrantVerificationError): + verify_scope_grant_or_raise( + forged, + mode="pilot", + trust_store=kit["issuer_store"], + status_trust_store=kit["status_store"], + authorization_manifest=kit["manifest"], + manifest_trust_store=kit["manifest_store"], + ledger_status=kit["status"], + source_artifacts=kit["artifacts"], + ) + + +def test_revoked_status_rejected_before_evaluation( + protocol_v3_kit: dict[str, Any], +) -> None: + kit = protocol_v3_kit + revoked = deepcopy(kit["status"]) + revoked["status"] = "revoked" + revoked = sign_status(revoked, kit["status_keys"]) + with pytest.raises(GrantVerificationError): + verify_scope_grant_or_raise( + kit["grant"], + mode="pilot", + trust_store=kit["issuer_store"], + status_trust_store=kit["status_store"], + authorization_manifest=kit["manifest"], + manifest_trust_store=kit["manifest_store"], + ledger_status=revoked, + source_artifacts=kit["artifacts"], + ) + + +def test_weak_evidence_remains_evidence_blocked( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + decision = gate.evaluate_with_grant( + ai_output={"summary": "Draft with weak evidence."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict( + _base_context(evidence_state="E1_anecdotal_or_unverified") + ), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + data = decision.to_dict() + assert data["admissibility"] in ("blocked", "abstain_insufficient_context") + satisfaction = data.get("grant_layer_satisfaction") or {} + assert satisfaction.get("evidence_never_satisfied_by_grant") is True + + +def test_changed_protocol_version_renewed_review( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + prior = _prior_record() + prior["scientific_context"] = {"protocol_version": "protocol-v3"} + # Containment fails closed when requested protocol_id diverges from grant target. + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Draft after protocol changed."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict( + _base_context(protocol_version="protocol-v3-rev2") + ), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=prior, + **_verify_kwargs(kit), + ) + + +def test_missing_runtime_target_insufficient_context( + gate: AKTAGate, protocol_v3_kit: dict[str, Any] +) -> None: + kit = protocol_v3_kit + ctx = _base_context() + ctx.pop("protocol_version", None) + with pytest.raises(GrantVerificationError): + gate.evaluate_with_grant( + ai_output={"summary": "Draft without protocol target."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(ctx), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=_prior_record(), + **_verify_kwargs(kit), + ) + + +def test_final_record_preserves_full_provenance( + gate: AKTAGate, protocol_v3_kit: dict[str, Any], tmp_path: Path +) -> None: + kit = protocol_v3_kit + prior = _prior_record() + decision = gate.evaluate_with_grant( + ai_output={"summary": "Draft timing tweak for protocol-v3."}, + requested_tool="protocol_editor.draft_change", + requested_action="draft", + context=AKTAContext.from_dict(_base_context()), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + scope_grant=kit["grant"], + record=prior, + trigger={"review_trigger_id": kit["artifacts"]["review_trigger"]["review_trigger_id"]}, + **_verify_kwargs(kit), + ) + assert decision.admissibility in ("allowed", "draft_only", "allowed_with_logging") + + record = decision.to_record( + ai_output={"summary": "Draft timing tweak for protocol-v3."}, + context={ + "pf_core_obligation_ref": "PF-OBL-ACCEPTANCE", + "pcs_artifact_ref": "PCS-ACCEPTANCE", + "project_id": "demo_protocol_drift", + }, + ) + body = record.to_dict() + auth = body.get("authorization_provenance") or {} + assert auth.get("initial_admissibility") == "review_required" + assert auth.get("grant_verification") + assert auth.get("status_attestation") or auth.get("grant_verification", {}).get( + "ledger_status" + ) + assert auth.get("requested_envelope") or auth.get("requested_authorization") + assert auth.get("granted_envelope") or body.get("capability_envelope") + assert auth.get("containment") is not None or auth.get( + "envelope_covers_requested_scope" + ) is True + assert body.get("evidence_independence") or body.get("grant_layer_satisfaction") + assert body["decision"]["admissibility"] == decision.admissibility + assert body["provenance"].get("policy_hash", "").startswith("sha256:") + assert body["integrations"].get("pf_core_obligation_ref") == "PF-OBL-ACCEPTANCE" + assert body["integrations"].get("pcs_artifact_ref") == "PCS-ACCEPTANCE" + + obligation = build_pf_obligation( + body, decision_id=decision.to_dict().get("decision_id") + ) + pf_path = export_pf_obligation( + record, tmp_path / "pf", decision_id=decision.to_dict().get("decision_id") + ) + assert Path(pf_path).is_file() + assert obligation.get("obligation_id") + + pcs_dir = tmp_path / "pcs" + export_pcs_bundle( + record, + pcs_dir, + decision=decision.to_dict(), + scope_grant=kit["grant"], + pf_obligation=obligation, + validate=True, + ) + validate_pcs_bundle(pcs_dir) + assert (pcs_dir / "scope_grant.json").is_file() + assert (pcs_dir / "pf_obligation.json").is_file() + + +def test_example_fixtures_present() -> None: + assert EXAMPLE.is_dir() + assert (EXAMPLE / "context.json").is_file() + assert (EXAMPLE / "scope_narrow_grant.json").is_file() + assert (EXAMPLE / "pcs_bundle" / "manifest.json").is_file() diff --git a/tests/test_trust_domains.py b/tests/test_trust_domains.py new file mode 100644 index 0000000..c4047ba --- /dev/null +++ b/tests/test_trust_domains.py @@ -0,0 +1,87 @@ +"""AKTA 1.1 WS3: separate trust domains; reviewer keys are not grant issuers.""" + +from __future__ import annotations + +from pathlib import Path + +from akta.trust_store import ( + GRANT_ISSUER_REGISTRY_NAME, + MANIFEST_SIGNER_REGISTRY_NAME, + REVIEWER_DECISION_REGISTRY_NAME, + STATUS_ATTESTATION_REGISTRY_NAME, + build_trust_store, + generate_test_keypair, + institutional_registry_is_empty, + load_reviewer_decision_registry, + resolve_grant_issuer_trust_store, + resolve_manifest_signer_trust_store, + resolve_status_attestation_trust_store, + resolve_trust_store, +) + +ROOT = Path(__file__).resolve().parent.parent +POLICY = ROOT / "policy" + + +def test_policy_trust_domain_registries_exist_and_ship_empty() -> None: + for name in ( + GRANT_ISSUER_REGISTRY_NAME, + STATUS_ATTESTATION_REGISTRY_NAME, + MANIFEST_SIGNER_REGISTRY_NAME, + REVIEWER_DECISION_REGISTRY_NAME, + ): + path = POLICY / name + assert path.is_file(), name + assert institutional_registry_is_empty(resolve_grant_issuer_trust_store(require_keys=True)) + assert institutional_registry_is_empty( + resolve_status_attestation_trust_store(require_keys=True) + ) + assert institutional_registry_is_empty( + resolve_manifest_signer_trust_store(require_keys=True) + ) + + +def test_reviewer_registry_not_auto_merged_into_grant_issuers( + tmp_path: Path, monkeypatch +) -> None: + keys = generate_test_keypair(tmp_path / "keys") + reviewer_path = tmp_path / REVIEWER_DECISION_REGISTRY_NAME + reviewer_path.write_text( + "\n".join( + [ + "version: test-reviewer-v1", + "default_authorized_artifact_types:", + " - reviewer_decision", + "reviewers:", + " reviewer-1:", + " key_id: reviewer-1", + " status: active", + " algorithm: Ed25519", + " authorized_artifact_types: [reviewer_decision]", + " public_key_pem: |", + *[f" {line}" for line in keys["public_key_pem"].splitlines()], + ] + ), + encoding="utf-8", + ) + # Point policy_dir at tmp so only reviewer file exists there — grant issuer + # must not pick up reviewer keys even if SCOPE sibling would have. + monkeypatch.delenv("AKTA_TRUST_STORE_PATH", raising=False) + monkeypatch.delenv("AKTA_GRANT_ISSUER_REGISTRY", raising=False) + monkeypatch.delenv("AKTA_ISSUER_KEY_REGISTRY", raising=False) + monkeypatch.delenv("SCOPE_REPO_PATH", raising=False) + + grant_store = resolve_grant_issuer_trust_store(policy_dir=tmp_path, require_keys=True) + assert institutional_registry_is_empty(grant_store) + + reviewer = load_reviewer_decision_registry(reviewer_path) + reviewer_store = build_trust_store( + reviewer, + base_dir=tmp_path, + required_artifact_type="reviewer_decision", + ) + assert not institutional_registry_is_empty(reviewer_store) + # Explicit resolve_trust_store alias remains grant-issuer only. + assert institutional_registry_is_empty( + resolve_trust_store(policy_dir=tmp_path, require_keys=True) + )