diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2398c9..7603606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,22 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Run CI (install, lint, typecheck, tests, evals) - run: bash scripts/ci.sh \ No newline at end of file + run: bash scripts/ci.sh + + live-ecosystem: + runs-on: ubuntu-latest + if: vars.PF_CORE_REPO_PATH != '' || vars.PCS_CORE_REPO_PATH != '' || vars.AKTA_REPO_PATH != '' + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install SCOPE + run: pip install -e ".[dev]" + - name: Live contract tests + env: + PF_CORE_REPO_PATH: ${{ vars.PF_CORE_REPO_PATH }} + PCS_CORE_REPO_PATH: ${{ vars.PCS_CORE_REPO_PATH }} + AKTA_REPO_PATH: ${{ vars.AKTA_REPO_PATH }} + run: pytest tests/test_live_contracts.py -m live_contract -v \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 6f83005..5d63c12 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,40 +4,43 @@ | Version | Supported | Notes | | ------- | --------- | ----- | -| 1.0.x | Yes | Stable contract; security advisories for 1.0.x | -| 0.11.x | Yes | Workflow release; migrate to 1.0 for contract freeze | -| 0.10.x | Yes | Production-trust adapters | -| 0.9.x | Yes | AKTA session-complete integration | -| 0.8.x | Yes | Split AKTA summary contracts, pilot fixtures | -| 0.7.x | Limited | Critical fixes only | -| < 0.7 | No | Upgrade required | +| 0.8.x | Yes | Current release line; security fixes backported here | +| 0.7.x | Best effort | Prior institutional pilot line; upgrade to 0.8.x recommended | +| 0.6.x and earlier | No | Unsupported; no security patches | + +Report issues against the latest **0.8.x** release on [main](https://github.com/fraware/SCOPE). ## Reporting a vulnerability -Report security issues privately to the repository maintainers via GitHub Security Advisories on [fraware/SCOPE](https://github.com/fraware/SCOPE). +Report security issues privately via GitHub Security Advisories on [fraware/SCOPE](https://github.com/fraware/SCOPE/security/advisories/new). Do not open public issues for undisclosed vulnerabilities. + +## v0.8 security model + +SCOPE v0.8.x builds on schema validation, canonical hashing, hash-chained ledger events, explicit expiration checks, and fail-closed behavior for unknown scopes, invalid roles, and forbidden queue transitions. -Include: affected version, reproduction steps, impact assessment, and suggested fix if available. +**Cryptography and signing** -## Security model (v0.8+) +- Ed25519 signatures on decisions and grants when production signing is enabled (`SCOPE_PRODUCTION_MODE`, minimum signing assurance policy in `policy/minimum_signing_assurance.yaml`) +- Signing assurance levels (SAL0–SAL4) with registry key binding and reviewer public-key references; external HSM/KMS interface documented for SAL4 (`docs/signing_assurance.md`, `docs/key_management.md`) +- Combined `scope_trust_root_hash` ties policy and reviewer registry integrity into decision, grant, and PCS export provenance -SCOPE provides layered institutional trust boundaries: +**Identity** -- **Identity assurance (IAL0–IAL4)**: OIDC/JWT verification, optional SAML assertion verify, org RBAC mapping. Production mode rejects caller-supplied identity JSON without verified tokens (`SCOPE_PRODUCTION_MODE=1`). -- **Signing assurance (SAL0–SAL4)**: Ed25519 local keys, registry-backed keys, and reference KMS/HSM adapters. Minimum SAL enforced per scope in `policy/minimum_signing_assurance.yaml`. -- **Authority provenance**: Two-stage RBAC + SCOPE authority checks with explicit `authority_checks` on decisions and grants. -- **Ledger integrity**: Hash-chained local JSONL, optional remote/WORM sinks with `fail_closed` delivery for high-risk events. -- **AKTA contract**: Split summary schemas (`completed` vs `session_required`); verifiable pilot fixture pack. +- Identity assurance levels (IAL0–IAL4) with provenance on decisions and session grants (`scope/identity_assurance.py`, `docs/identity_assurance.md`) +- Optional OIDC/JWT verification (`SCOPE_OIDC_*`, `scope identity verify-token`) for institutional identity claims; org RBAC in `policy/org_rbac.yaml` is separate from SCOPE scope authority -SCOPE does **not** replace IRB, biosafety, EHS, or certify scientific safety. See [docs/limitations.md](docs/limitations.md). +**Ledger and delivery** -## Cryptography +- Local hash-chained JSONL ledger with verification APIs +- Optional remote HTTP append sink (`SCOPE_LEDGER_REMOTE_URL`); delivery semantics `best_effort`, `at_least_once` (spool), and `fail_closed` for high-risk grant issuance when remote delivery is required +- Runtime violation and expiration events for PF feedback loops; remote sink is not a WORM or authoritative tamper-evident store -- Decision and grant signatures use **Ed25519** (local PEM or registry-backed keys). -- OIDC tokens verified via JWKS (RS256) or static public key. -- Ledger events use SHA-256 hash chaining. +**AKTA review contract** -## Operational hardening +- Signed `summary.json` artifacts validated against split schemas for `completed` vs `session_required` (`scope-akta-review-v0.8.1`); consumers must branch on `summary.status` -For production deployment guidance see [docs/production_deployment.md](docs/production_deployment.md) and [docs/trusted_boundary.md](docs/trusted_boundary.md). +**Known limits** -Threat model details: [docs/threat_model.md](docs/threat_model.md). +- No live SAML/SCIM directory sync; RBAC and identity mapping are file-based +- Reviewer judgment, domain safety, and physical lab safety are out of scope +- See [docs/threat_model.md](docs/threat_model.md), [docs/trusted_boundary.md](docs/trusted_boundary.md), and [docs/limitations.md](docs/limitations.md) for residual risk and deployment boundaries. \ No newline at end of file diff --git a/adapters/vsa/fetch_report.py b/adapters/vsa/fetch_report.py index 8e4c2fc..6eba8fb 100644 --- a/adapters/vsa/fetch_report.py +++ b/adapters/vsa/fetch_report.py @@ -2,10 +2,13 @@ from __future__ import annotations +import argparse import json import os +import time import urllib.error import urllib.request +from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -59,3 +62,67 @@ def fetch_vsa_report_from_file_or_url( if text.startswith("http://") or text.startswith("https://"): return fetch_vsa_report(url=text, token=token) return import_vsa_report(source) + + +def schedule_vsa_refetch( + *, + report_id: str | None = None, + url: str | None = None, + interval_seconds: int = 3600, + on_refresh: Callable[[dict[str, Any]], None] | None = None, + max_iterations: int | None = None, + token: str | None = None, +) -> None: + """ + Scheduled re-fetch hook for evidence refresh pipelines. + + Downstream systems should compare ``evidence_summary.overall_state`` across + fetches and invoke grant expiration rules when evidence downgrades. + """ + iteration = 0 + while max_iterations is None or iteration < max_iterations: + report = fetch_vsa_report(url=url, report_id=report_id, token=token) + if on_refresh is not None: + on_refresh(report) + iteration += 1 + if max_iterations is not None and iteration >= max_iterations: + break + time.sleep(max(interval_seconds, 1)) + + +def _cli_main() -> int: + parser = argparse.ArgumentParser(description="Fetch or schedule VSA report re-fetch.") + parser.add_argument("--url", default=None) + parser.add_argument("--report-id", default=None) + parser.add_argument( + "--interval", type=int, default=0, help="Seconds between fetches (0 = once)." + ) + parser.add_argument("--once", action="store_true", help="Fetch once and exit.") + parser.add_argument("--out", type=Path, default=None, help="Write fetched report JSON.") + args = parser.parse_args() + + def _emit(report: dict[str, Any]) -> None: + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + else: + print(json.dumps(report, indent=2)) + + if args.once or args.interval <= 0: + _emit(fetch_vsa_report(url=args.url, report_id=args.report_id)) + return 0 + + schedule_vsa_refetch( + report_id=args.report_id, + url=args.url, + interval_seconds=args.interval, + on_refresh=_emit, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_cli_main()) diff --git a/docs/ecosystem_demo.md b/docs/ecosystem_demo.md new file mode 100644 index 0000000..e846991 --- /dev/null +++ b/docs/ecosystem_demo.md @@ -0,0 +1,134 @@ +# Live Ecosystem Demo (AKTA → SCOPE → PF → PCS) + +This document describes the **live** cross-repo authorization loop using sibling repositories and optional REST triggers. For fixture-only walkthroughs, see [akta_scope_demo.md](akta_scope_demo.md). + +Related: [external_integration_contracts.md](external_integration_contracts.md), [pf_core_bridge.md](pf_core_bridge.md), [pcs_export.md](pcs_export.md). + +## Prerequisites + +```powershell +pip install -e ".[dev]" +``` + +Configure partner repo paths (local checkouts): + +| Variable | Purpose | +|----------|---------| +| `AKTA_REPO_PATH` | AKTA runtime checkout (optional; demo uses bundled fixtures) | +| `PF_CORE_REPO_PATH` | PF-Core validator for `--live` PF export | +| `PCS_CORE_REPO_PATH` | PCS validator for `--live` PCS export | +| `SCOPE_REST_URL` | SCOPE REST base URL when using REST trigger (default `http://127.0.0.1:8765`) | +| `SCOPE_API_KEY` | Bearer token when REST auth is enabled | + +## One-command demo + +**Linux / macOS:** + +```bash +export PF_CORE_REPO_PATH=/path/to/pf-core # optional live validation +export PCS_CORE_REPO_PATH=/path/to/pcs-core +bash scripts/ecosystem_demo.sh +``` + +**Windows (PowerShell):** + +```powershell +$env:PF_CORE_REPO_PATH = "C:\path\to\pf-core" +$env:PCS_CORE_REPO_PATH = "C:\path\to\pcs-core" +.\scripts\ecosystem_demo.ps1 +``` + +Output directory defaults to `/tmp/scope_ecosystem_demo` (Linux) or `%TEMP%\scope_ecosystem_demo` (Windows). + +## Demo flow + +```mermaid +flowchart LR + AKTA[AKTA_trigger_record] --> SCOPE[scope_akta_review] + SCOPE --> PF[export_pf_live] + PF --> VIOL[pf_inject_violation] + VIOL --> LEDGER[scope_ledger] + SCOPE --> PCS[export_pcs_live] + LEDGER --> QUAL[quality_report] +``` + +1. **AKTA → SCOPE** — `scope akta review` (CLI) or `scripts/akta_rest_review.py` (REST) +2. **SCOPE → PF** — `scope export pf --validate --live` +3. **PF violation loop** — `scripts/pf_inject_violation.py` simulates a blocked tool call and records `runtime_scope_violation` +4. **SCOPE → PCS** — `scope export pcs --validate --live` +5. **Quality metrics** — `scope quality report` with non-zero `post_approval_runtime_violation_rate` + +## REST trigger path + +Start the REST server: + +```bash +uvicorn adapters.generic_rest.server:app --host 127.0.0.1 --port 8765 +``` + +Run demo with REST: + +```bash +USE_REST=true SCOPE_REST_URL=http://127.0.0.1:8765 bash scripts/ecosystem_demo.sh +``` + +Or call the AKTA wrapper directly: + +```bash +python scripts/akta_rest_review.py \ + --akta-record examples/protocol_drift/akta_record.json \ + --akta-trigger examples/protocol_drift/review_trigger.json \ + --reviewer examples/protocol_drift/reviewer_protocol_owner.json \ + --grant-scope protocol_draft \ + --decision-rationale "REST demo approval" \ + --out-dir /tmp/akta_rest_out +``` + +## Session-complete path + +Multi-role demo with votes manifest: + +```bash +scope akta review \ + --akta-record examples/pilot/multi_role_genomics_review/akta_record.json \ + --akta-trigger examples/pilot/multi_role_genomics_review/review_trigger.json \ + --session-complete \ + --votes examples/pilot/multi_role_genomics_review/votes.json \ + --grant-scope genomics_analysis \ + --out-dir /tmp/multi_role_demo +``` + +REST equivalent: pass `--session-complete` and `--votes` to `scripts/akta_rest_review.py`. + +## VSA scheduled re-fetch + +Evidence downgrade can trigger grant expiration via existing expiration rules. Schedule periodic VSA re-fetch: + +```python +from adapters.vsa.fetch_report import schedule_vsa_refetch + +schedule_vsa_refetch( + report_id="VSA-REPORT-001", + interval_seconds=3600, + on_refresh=lambda report: print(report["evidence_summary"]), +) +``` + +Or run the CLI hook: + +```bash +python -m adapters.vsa.fetch_report --report-id VSA-REPORT-001 --interval 3600 --once +``` + +Set `VSA_API_URL` and `VSA_API_TOKEN` for live fetches. + +## Acceptance criteria + +- One command runs the full loop locally +- `post_approval_runtime_violation_rate` > 0 after PF inject +- PF/PCS live validators pass when repo paths are set +- Skips explicitly when paths absent (default CI remains green) + +## CI + +Optional `live-ecosystem` job in `.github/workflows/ci.yml` runs when repository variables are configured. See [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/docs/external_integration_contracts.md b/docs/external_integration_contracts.md index 5b8acb9..7269421 100644 --- a/docs/external_integration_contracts.md +++ b/docs/external_integration_contracts.md @@ -1,4 +1,4 @@ -# External Integration Contracts (v0.7) +# External Integration Contracts (v0.9) Formal field-mapping contracts for AKTA, PF-Core, PCS, and VSA integrations. These contracts define the shapes SCOPE produces or consumes locally; external repositories must implement compatible endpoints or adapters. @@ -60,7 +60,7 @@ Evidence vocabulary mapping: [evidence_vocab_mapping.md](evidence_vocab_mapping. | `scope_grant.json` | Issued grant (`completed` only) | | `summary.json` | Adapter summary; schema selected by `summary.status` | -Contract version: `scope-akta-review-v0.8.1`. Branch on `summary.status`: +Contract version: `scope-akta-review-v0.9` (compatible with v0.8.1+ consumers). Branch on `summary.status`: | `summary.status` | Schema | |------------------|--------| @@ -208,27 +208,45 @@ REST: full queue lifecycle under `/v0/review-queue/{id}/...`. Quality metrics: `open_queue_count`, `overdue_queue_count` (open statuses include `in_review`, `needs_information`, `escalated`). -## Version alignment (v0.7) +## Version alignment (v0.9) | Artifact | Version field | Expected value | |----------|---------------|----------------| -| SCOPE package | `pyproject.toml` / `scope/_version.py` | `0.7.0` | -| SCOPE packet | `packet_version` | `0.7.0` | -| SCOPE grant | `grant_version` | `0.7.0` | -| Quality report | `report_version` | `0.7` | -| Review queue | `queue_version` | `0.7.0` | -| AKTA review summary | `adapter_contract_version` | `scope-akta-review-v0.7` | +| SCOPE package | `pyproject.toml` / `scope/_version.py` | `0.9.0` | +| SCOPE packet | `packet_version` | `0.9.0` | +| SCOPE grant | `grant_version` | `0.9.0` | +| Quality report | `report_version` | `0.8` | +| Review queue | `queue_version` | `0.9.0` | +| AKTA review summary | `adapter_contract_version` | `scope-akta-review-v0.9` | | PF obligation | `obligation_version` | `pf-core-v0.5` | | PCS manifest | `manifest_version` | `pcs-v0.5` | -| Policy bundle | `version` in YAML | `scope-core-v0.7` | +| Policy bundle | `version` in YAML | `scope-core-v0.9` | + +## Environment paths and live demo + +Partner repositories are referenced via environment variables (never committed): + +| Variable | Required for | Example | +|----------|--------------|---------| +| `AKTA_REPO_PATH` | AKTA-side tooling / optional CI | `/opt/akta` | +| `PF_CORE_REPO_PATH` | PF live validation + violation loop | `/opt/pf-core` | +| `PCS_CORE_REPO_PATH` | PCS live validation | `/opt/pcs-core` | +| `SCOPE_REST_URL` | REST demo (`scripts/akta_rest_review.py`) | `http://127.0.0.1:8765` | +| `SCOPE_API_KEY` | Authenticated REST | (institutional secret) | +| `VSA_API_URL` | Scheduled VSA re-fetch | `https://vsa.example/api` | +| `VSA_API_TOKEN` | VSA bearer auth | (institutional secret) | + +One-command ecosystem demo: [ecosystem_demo.md](ecosystem_demo.md). + +PF violation feedback uses `scripts/pf_inject_violation.py` (loads `adapters/pf_core/export_obligation.py` output, records `runtime_scope_violation` via CLI or `POST /v0/ledger/violations`). ## External repo dependencies These integrations require live services or repositories not present in this repo: - **AKTA**: authoritative admissibility decisions and nested record storage -- **PF-Core**: runtime obligation enforcement at tool invocation -- **PCS**: release pipeline ingestion and institutional signing workflows +- **PF-Core**: runtime obligation enforcement at tool invocation (`pf-core-v0.5`) +- **PCS**: release pipeline ingestion and institutional signing workflows (`pcs-v0.5`) - **VSA**: live ScientificReport generation from validation pipelines -Local adapters validate shapes and hashes by default. Cross-repo end-to-end tests run when env paths are configured (`tests/test_live_contracts.py`). +Local adapters validate shapes and hashes by default. Cross-repo end-to-end tests run when env paths are configured (`tests/test_live_contracts.py`, optional CI job `live-ecosystem`). diff --git a/pyproject.toml b/pyproject.toml index 37363ee..5c7585b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.8.1" +version = "0.9.0" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/scope/_version.py b/scope/_version.py index e5ac1c0..b6621b3 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.8.1" +__version__ = "0.9.0" diff --git a/scope/external_contracts.py b/scope/external_contracts.py index 90b873b..fb1ef7a 100644 --- a/scope/external_contracts.py +++ b/scope/external_contracts.py @@ -11,6 +11,7 @@ PF_CORE_REPO_ENV = "PF_CORE_REPO_PATH" PCS_CORE_REPO_ENV = "PCS_CORE_REPO_PATH" +AKTA_REPO_ENV = "AKTA_REPO_PATH" PF_VALIDATOR_CANDIDATES = ( "scripts/validate_scope_obligation.py", diff --git a/scope/integration_versions.py b/scope/integration_versions.py index 78fc19c..109c990 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -4,5 +4,5 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" -SCOPE_CORE_VERSION = "scope-core-v0.8" -AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.8.1" +SCOPE_CORE_VERSION = "scope-core-v0.9" +AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.9" diff --git a/scope/quality.py b/scope/quality.py index f752d32..729654e 100644 --- a/scope/quality.py +++ b/scope/quality.py @@ -242,7 +242,7 @@ def analyze_ledger(events: list[dict[str, Any]], policy: PolicyStore) -> dict[st "post_approval_failure_rate": len(post_failures) / max(len(grants), 1), "post_approval_protocol_drift_rate": len(post_protocol_drift) / max(len(grants), 1), "post_approval_evidence_downgrade_rate": len(post_evidence_downgrade) / max(len(grants), 1), - "post_approval_runtime_violation_rate": len(violations) / max(len(grants), 1), + "post_approval_runtime_violation_rate": len(confirmed_violations) / max(len(grants), 1), "runtime_violation_outcome_count": len(confirmed_violations), "review_sla_breach_count": len(sla_breaches), "grant_use_count": len(grant_used), diff --git a/scripts/akta_rest_review.py b/scripts/akta_rest_review.py new file mode 100644 index 0000000..7c5664e --- /dev/null +++ b/scripts/akta_rest_review.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Thin AKTA-side wrapper: POST review trigger/record to SCOPE REST.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +def _load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def post_akta_review( + *, + base_url: str, + akta_record: dict[str, Any], + akta_trigger: dict[str, Any], + reviewer: dict[str, Any], + grant_scope: str, + decision_rationale: str, + out_dir: str | None = None, + queue_dir: str | None = None, + session_mode: bool = False, + session_complete: bool = False, + votes: list[dict[str, Any]] | None = None, + api_key: str | None = None, + signing_key_path: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "akta_record": akta_record, + "akta_trigger": akta_trigger, + "reviewer": reviewer, + "grant_scope": grant_scope, + "decision_rationale": decision_rationale, + } + if out_dir: + payload["out_dir"] = out_dir + if queue_dir: + payload["queue_dir"] = queue_dir + if session_mode: + payload["session_mode"] = True + if session_complete: + payload["session_complete"] = True + if votes: + payload["votes"] = votes + if signing_key_path: + payload["signing_key_path"] = signing_key_path + + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + body = json.dumps(payload, sort_keys=True).encode("utf-8") + req = urllib.request.Request( + f"{base_url.rstrip('/')}/v0/akta/review", + data=body, + headers=headers, + method="POST", + ) + with urllib.request.urlopen(req, timeout=120) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Trigger SCOPE AKTA review via REST.") + parser.add_argument("--akta-record", required=True, type=Path) + parser.add_argument("--akta-trigger", required=True, type=Path) + parser.add_argument("--reviewer", required=True, type=Path) + parser.add_argument("--grant-scope", required=True) + parser.add_argument("--decision-rationale", required=True) + parser.add_argument("--out-dir", default=None) + parser.add_argument("--queue-dir", default=None) + parser.add_argument("--session-mode", action="store_true") + parser.add_argument("--session-complete", action="store_true") + parser.add_argument("--votes", type=Path, default=None, help="Votes JSON manifest.") + parser.add_argument( + "--rest-url", + default=os.environ.get("SCOPE_REST_URL", "http://127.0.0.1:8765"), + ) + parser.add_argument("--api-key", default=os.environ.get("SCOPE_API_KEY")) + parser.add_argument("--signing-key", default=None) + args = parser.parse_args() + + votes = _load_json(args.votes) if args.votes else None + if votes is not None and not isinstance(votes, list): + votes_list = votes.get("votes") if isinstance(votes, dict) else None + votes = votes_list if isinstance(votes_list, list) else [votes] + + try: + summary = post_akta_review( + base_url=args.rest_url, + akta_record=_load_json(args.akta_record), + akta_trigger=_load_json(args.akta_trigger), + reviewer=_load_json(args.reviewer), + grant_scope=args.grant_scope, + decision_rationale=args.decision_rationale, + out_dir=args.out_dir, + queue_dir=args.queue_dir, + session_mode=args.session_mode, + session_complete=args.session_complete, + votes=votes, + api_key=args.api_key, + signing_key_path=args.signing_key, + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"SCOPE REST error {exc.code}: {detail}", file=sys.stderr) + return 1 + except urllib.error.URLError as exc: + print(f"SCOPE REST unreachable: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ecosystem_demo.ps1 b/scripts/ecosystem_demo.ps1 new file mode 100644 index 0000000..0538f80 --- /dev/null +++ b/scripts/ecosystem_demo.ps1 @@ -0,0 +1,87 @@ +# Ecosystem demo v2: AKTA → SCOPE → PF → PCS → ledger violation → quality report. +param( + [string]$AktaTrigger = "examples/protocol_drift/review_trigger.json", + [string]$AktaRecord = "examples/protocol_drift/akta_record.json", + [string]$Reviewer = "examples/protocol_drift/reviewer_protocol_owner.json", + [string]$GrantScope = "protocol_draft", + [string]$OutDir = "$env:TEMP\scope_ecosystem_demo", + [string]$Policy = "policy/", + [string]$QueueDir = ".scope/queues", + [string]$Ledger = ".scope/ecosystem_ledger.jsonl", + [switch]$UseRest, + [string]$ScopeRestUrl = "http://127.0.0.1:8765", + [string]$SigningKey = "" +) + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot\.. + +New-Item -ItemType Directory -Force -Path $OutDir, $QueueDir | Out-Null +if (Test-Path $Ledger) { Remove-Item $Ledger -Force } + +Write-Host "== Step 1: AKTA → SCOPE review ==" +if ($UseRest) { + $restArgs = @( + "scripts/akta_rest_review.py", + "--akta-trigger", $AktaTrigger, + "--akta-record", $AktaRecord, + "--reviewer", $Reviewer, + "--grant-scope", $GrantScope, + "--decision-rationale", "Ecosystem demo live approval", + "--out-dir", $OutDir, + "--queue-dir", $QueueDir, + "--rest-url", $ScopeRestUrl + ) + if ($SigningKey) { $restArgs += @("--signing-key", $SigningKey) } + python @restArgs +} else { + $aktaArgs = @( + "akta", "review", + "--akta-trigger", $AktaTrigger, + "--akta-record", $AktaRecord, + "--grant-scope", $GrantScope, + "--reviewer", $Reviewer, + "--decision-rationale", "Ecosystem demo live approval", + "--out-dir", $OutDir, + "--queue-dir", $QueueDir, + "--ledger", $Ledger, + "--policy", $Policy + ) + if ($SigningKey) { $aktaArgs += @("--signing-key", $SigningKey) } + scope @aktaArgs +} + +$packet = Join-Path $OutDir "scope_review_packet.json" +$decision = Join-Path $OutDir "scope_decision.json" +$grant = Join-Path $OutDir "scope_grant.json" +$pfOut = Join-Path $OutDir "pf_obligation.json" +$pcsOut = Join-Path $OutDir "pcs_export" +$qualityOut = Join-Path $OutDir "quality_report.json" + +Write-Host "== Step 2: SCOPE → PF obligation export ==" +scope export pf --grant $grant --out $pfOut --validate --live + +Write-Host "== Step 3: PF simulated block → SCOPE violation ==" +$violationArgs = @("scripts/pf_inject_violation.py", "--grant", $grant, "--ledger", $Ledger, "--policy", $Policy) +if ($UseRest) { $violationArgs += @("--rest-url", $ScopeRestUrl) } +python @violationArgs + +Write-Host "== Step 4: SCOPE → PCS archive export ==" +scope export pcs --packet $packet --decision $decision --grant $grant --out $pcsOut --validate --live --policy $Policy + +Write-Host "== Step 5: Quality report ==" +scope quality report --ledger $Ledger --out $qualityOut --queue-dir $QueueDir + +python -c @" +import json, sys +from pathlib import Path +report = json.loads(Path(r'$qualityOut').read_text(encoding='utf-8')) +rate = report.get('metrics', {}).get('post_approval_runtime_violation_rate', 0) +count = report.get('metrics', {}).get('runtime_violation_outcome_count', 0) +print(f'post_approval_runtime_violation_rate={rate}') +print(f'runtime_violation_outcome_count={count}') +if rate <= 0 or count <= 0: + raise SystemExit('Expected non-zero runtime violation metrics after PF inject') +"@ + +Write-Host "Ecosystem demo complete -> $OutDir" diff --git a/scripts/ecosystem_demo.sh b/scripts/ecosystem_demo.sh new file mode 100644 index 0000000..a5edab6 --- /dev/null +++ b/scripts/ecosystem_demo.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Ecosystem demo v2: AKTA → SCOPE → PF → PCS → ledger violation → quality report. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +AKTA_TRIGGER="${AKTA_TRIGGER:-examples/protocol_drift/review_trigger.json}" +AKTA_RECORD="${AKTA_RECORD:-examples/protocol_drift/akta_record.json}" +REVIEWER="${REVIEWER:-examples/protocol_drift/reviewer_protocol_owner.json}" +GRANT_SCOPE="${GRANT_SCOPE:-protocol_draft}" +OUT_DIR="${OUT_DIR:-/tmp/scope_ecosystem_demo}" +POLICY="${POLICY:-policy/}" +QUEUE_DIR="${QUEUE_DIR:-.scope/queues}" +LEDGER="${LEDGER:-.scope/ecosystem_ledger.jsonl}" +USE_REST="${USE_REST:-false}" +SCOPE_REST_URL="${SCOPE_REST_URL:-http://127.0.0.1:8765}" + +mkdir -p "$OUT_DIR" "$QUEUE_DIR" +rm -f "$LEDGER" + +SIGN_ARGS=() +if [[ -n "${SIGNING_KEY:-}" ]]; then + SIGN_ARGS=(--signing-key "$SIGNING_KEY") +fi + +echo "== Step 1: AKTA → SCOPE review ==" +if [[ "$USE_REST" == "true" ]]; then + python scripts/akta_rest_review.py \ + --akta-trigger "$AKTA_TRIGGER" \ + --akta-record "$AKTA_RECORD" \ + --reviewer "$REVIEWER" \ + --grant-scope "$GRANT_SCOPE" \ + --decision-rationale "Ecosystem demo live approval" \ + --out-dir "$OUT_DIR" \ + --queue-dir "$QUEUE_DIR" \ + --rest-url "$SCOPE_REST_URL" \ + "${SIGN_ARGS[@]/#/--signing-key }" +else + scope akta review \ + --akta-trigger "$AKTA_TRIGGER" \ + --akta-record "$AKTA_RECORD" \ + --grant-scope "$GRANT_SCOPE" \ + --reviewer "$REVIEWER" \ + --decision-rationale "Ecosystem demo live approval" \ + --out-dir "$OUT_DIR" \ + --queue-dir "$QUEUE_DIR" \ + --ledger "$LEDGER" \ + --policy "$POLICY" \ + "${SIGN_ARGS[@]}" +fi + +PACKET="$OUT_DIR/scope_review_packet.json" +DECISION="$OUT_DIR/scope_decision.json" +GRANT="$OUT_DIR/scope_grant.json" +PF_OUT="$OUT_DIR/pf_obligation.json" +PCS_OUT="$OUT_DIR/pcs_export" +QUALITY_OUT="$OUT_DIR/quality_report.json" + +echo "== Step 2: SCOPE → PF obligation export ==" +scope export pf --grant "$GRANT" --out "$PF_OUT" --validate --live + +echo "== Step 3: PF simulated block → SCOPE violation ==" +python scripts/pf_inject_violation.py \ + --grant "$GRANT" \ + --ledger "$LEDGER" \ + --policy "$POLICY" \ + ${SCOPE_REST_URL:+--rest-url "$SCOPE_REST_URL"} + +echo "== Step 4: SCOPE → PCS archive export ==" +scope export pcs \ + --packet "$PACKET" \ + --decision "$DECISION" \ + --grant "$GRANT" \ + --out "$PCS_OUT" \ + --validate \ + --live \ + --policy "$POLICY" + +echo "== Step 5: Quality report (expect non-zero violation rate) ==" +scope quality report --ledger "$LEDGER" --out "$QUALITY_OUT" --queue-dir "$QUEUE_DIR" + +python - <<'PY' +import json, sys +from pathlib import Path +report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +rate = report.get("metrics", {}).get("post_approval_runtime_violation_rate", 0) +count = report.get("metrics", {}).get("runtime_violation_outcome_count", 0) +print(f"post_approval_runtime_violation_rate={rate}") +print(f"runtime_violation_outcome_count={count}") +if rate <= 0 or count <= 0: + sys.exit("Expected non-zero runtime violation metrics after PF inject") +PY +"$QUALITY_OUT" + +echo "Ecosystem demo complete -> $OUT_DIR" diff --git a/scripts/pf_inject_violation.py b/scripts/pf_inject_violation.py new file mode 100644 index 0000000..b7c9b84 --- /dev/null +++ b/scripts/pf_inject_violation.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Simulate PF-Core runtime block and record violation in SCOPE ledger.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from adapters.pf_core.export_obligation import export_pf_obligation + + +def _load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def _pick_blocked_tool(obligation: dict) -> tuple[str, str]: + blocked = obligation.get("blocked_tools") or [] + if blocked: + tool = str(blocked[0]) + return tool, f"PF blocked disallowed tool {tool}" + permitted = obligation.get("permitted_tools") or [] + if permitted: + tool = str(permitted[0]) + return tool, f"PF simulated scope violation for {tool}" + return "unknown.tool", "PF simulated runtime scope violation" + + +def _record_via_cli(grant_id: str, tool: str, reason: str, ledger: Path, policy: Path) -> dict: + from scope import ScopeEngine + + engine = ScopeEngine.from_policy_dir(policy, ledger_path=ledger) + return engine.record_runtime_violation(grant_id, tool=tool, reason=reason) + + +def _record_via_rest( + grant_id: str, + tool: str, + reason: str, + *, + base_url: str, + api_key: str | None, +) -> dict: + payload = json.dumps({"grant_id": grant_id, "tool": tool, "reason": reason}).encode("utf-8") + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + req = urllib.request.Request( + f"{base_url.rstrip('/')}/v0/ledger/violations", + data=payload, + headers=headers, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Inject PF runtime violation into SCOPE ledger.") + parser.add_argument("--grant", required=True, type=Path, help="SCOPE grant JSON path.") + parser.add_argument("--ledger", type=Path, default=Path(".scope/ledger.jsonl")) + parser.add_argument("--policy", type=Path, default=Path("policy")) + parser.add_argument("--tool", default=None, help="Tool name to record (default: from obligation).") + parser.add_argument("--reason", default=None, help="Violation reason.") + parser.add_argument( + "--rest-url", + default=os.environ.get("SCOPE_REST_URL"), + help="SCOPE REST base URL (optional; default CLI ledger append).", + ) + parser.add_argument( + "--api-key", + default=os.environ.get("SCOPE_API_KEY"), + help="REST bearer token when using --rest-url.", + ) + args = parser.parse_args() + + grant = _load_json(args.grant) + obligation = export_pf_obligation(grant) + tool, default_reason = _pick_blocked_tool(obligation) + tool = args.tool or tool + reason = args.reason or default_reason + grant_id = str(grant["grant_id"]) + + if args.rest_url: + try: + event = _record_via_rest( + grant_id, tool, reason, base_url=args.rest_url, api_key=args.api_key + ) + except urllib.error.URLError as exc: + print(f"REST violation record failed: {exc}", file=sys.stderr) + return 1 + else: + event = _record_via_cli(grant_id, tool, reason, args.ledger, args.policy) + + print(json.dumps({"simulated_pf_block": True, "tool": tool, "event": event}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_live_contracts.py b/tests/test_live_contracts.py index 8c376de..4d4bd93 100644 --- a/tests/test_live_contracts.py +++ b/tests/test_live_contracts.py @@ -2,7 +2,10 @@ from __future__ import annotations +import json import os +import subprocess +import sys from pathlib import Path import pytest @@ -77,3 +80,70 @@ def test_pcs_live_validation_when_repo_present(tmp_path): if "no PCS validator found" in message: pytest.skip(message) assert ok, message + + +def test_pf_violation_inject_script(tmp_path: Path) -> None: + """PF violation loop produces non-zero quality metrics.""" + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "ledger.jsonl") + packet = engine.create_packet(EX / "akta_record.json", EX / "review_trigger.json") + decision = engine.submit_decision( + packet, + {"reviewer_id": "r1", "role": "protocol_owner"}, + {"type": "approve_narrower_scope", "approved_scope": "protocol_draft", "rationale": "ok"}, + ) + grant = engine.issue_grant(packet, decision) + grant_path = tmp_path / "grant.json" + grant_path.write_text(json.dumps(grant, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + script = ROOT / "scripts" / "pf_inject_violation.py" + completed = subprocess.run( + [ + sys.executable, + str(script), + "--grant", + str(grant_path), + "--ledger", + str(tmp_path / "ledger.jsonl"), + "--policy", + str(ROOT / "policy"), + ], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "ledger.jsonl") + report = engine.quality_report() + metrics = report.get("metrics", {}) + assert metrics.get("post_approval_runtime_violation_rate", 0) > 0 + assert metrics.get("runtime_violation_outcome_count", 0) > 0 + + +@pytest.mark.skipif(sys.platform == "win32", reason="ecosystem_demo.sh requires bash") +def test_ecosystem_demo_script_dry(tmp_path: Path) -> None: + """Ecosystem demo script runs without live PF/PCS repos.""" + out_dir = tmp_path / "demo_out" + ledger = tmp_path / "ledger.jsonl" + env = os.environ.copy() + env.pop(PF_CORE_REPO_ENV, None) + env.pop(PCS_CORE_REPO_ENV, None) + completed = subprocess.run( + [ + "bash", + str(ROOT / "scripts" / "ecosystem_demo.sh"), + ], + capture_output=True, + text=True, + check=False, + cwd=ROOT, + env={ + **env, + "OUT_DIR": str(out_dir), + "LEDGER": str(ledger), + "QUEUE_DIR": str(tmp_path / "queues"), + }, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + quality = json.loads((out_dir / "quality_report.json").read_text(encoding="utf-8")) + assert quality["summary"]["post_approval_runtime_violation_rate"] > 0