diff --git a/CHANGELOG.md b/CHANGELOG.md index 760e76c..3cbb1c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v0.7.0 (2026-06-28) + +Institutional pilot hardening release: + +- **Identity assurance (IAL0–IAL4)**: `scope/identity_assurance.py`, provenance on decisions/grants (including session grants), OIDC + org RBAC wiring; `identity_source` and `authority_checks` on decision/grant provenance +- **RBAC vs SCOPE authority separation**: two-stage checks in `scope/authority.py` with explicit `authority_checks` provenance block +- **Ledger delivery semantics**: `best_effort`, `at_least_once`, `fail_closed` modes with spool, `delivery_state`, and fail-closed blocking on high-risk grant issuance +- **Review queue state machine**: explicit transitions (`in_review`, `needs_information`, `escalated`, `expired`, reopen) +- **Signing assurance (SAL0–SAL4)**: minimum policy, production enforcement, HSM/KMS external interface +- **Frozen AKTA review contract**: `summary.json` schema, `scope-akta-review-v0.7` adapter version +- Policy bundle tagged `scope-core-v0.7` + +### Audit fixes (pre-release) + +- Grant provenance inherits `identity_claim_hash`, `authority_checks`, and `delegation_id` from issuing decisions (including session grants) +- `summary.json` includes `production_mode` on all AKTA review paths; schema and docs aligned +- Review queue: `information_received_at`, escalation reason/actor fields, ledger event on engine escalation +- End-to-end tests and extended evals assert decision/grant provenance field parity + ## v0.6.0 (2026-06-28) Institutional foundations release: diff --git a/README.md b/README.md index f9e4b23..fb86b93 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,76 @@ -# SCOPE +
-Home: [https://github.com/fraware/SCOPE](https://github.com/fraware/SCOPE) +
+ ███████╗ ██████╗  ██████╗ ██████╗ ███████╗
+ ██╔════╝██╔═══██╗██╔═══██╗██╔══██╗██╔════╝
+ ███████╗██║   ██║██║   ██║██████╔╝█████╗
+ ╚════██║██║   ██║██║   ██║██╔═══╝ ██╔══╝
+ ███████║╚██████╔╝╚██████╔╝██║     ███████╗
+ ╚══════╝ ╚═════╝  ╚═════╝ ╚═╝     ╚══════╝
+
-SCOPE is the Scoped Scientific Authorization Protocol (v0.6.0). +**Scoped authorization for AI-shaped scientific work** -AKTA can decide that an AI-shaped scientific action requires review or authorization. SCOPE turns that decision into a structured review packet, assigns the right reviewer role, captures a scoped decision, emits a bounded grant, enforces expiration, and produces artifacts that can be verified and packaged. +[![Version](https://img.shields.io/badge/version-0.7.0-blue)](https://github.com/fraware/SCOPE/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![CI](https://github.com/fraware/SCOPE/actions/workflows/ci.yml/badge.svg)](https://github.com/fraware/SCOPE/actions/workflows/ci.yml) -Review is not a checkbox. Review is a role, artifact, scope, expiration, and accountability trail. +
+ +Repository: [https://github.com/fraware/SCOPE](https://github.com/fraware/SCOPE) + +--- + +## Why SCOPE exists + +When an AI system proposes a scientific action — updating a protocol, submitting to a robot queue, or publishing a claim — someone with the right expertise should review it before tools run. Most systems treat that as a checkbox. SCOPE treats it as a **structured workflow**: who reviewed, what they saw, what scope they approved, when it expires, and what happened at runtime. + +SCOPE does **not** certify that an action is safe or correct. It produces auditable artifacts and enforces the scope a qualified reviewer actually approved. + +## What SCOPE does + +- Builds a **review packet** from upstream signals (what changed, what evidence exists, who should review) +- Captures a **typed reviewer decision** with rationale, role, and provenance +- Issues a **bounded grant** — allowed tools, scope, and expiration — tied to that decision +- **Enforces grants at runtime** and records violations, revocations, and expirations +- Maintains a **hash-chained ledger** for accountability and quality metrics +- **Exports** obligations and packaging artifacts for downstream runtimes (PF-Core, PCS) + +```mermaid +flowchart LR + A[Upstream signal] --> B[Review packet] + B --> C[Reviewer decision] + C --> D[Scoped grant] + D --> E[Runtime enforcement] + E --> F[Ledger and exports] +``` + +> **Review is not a checkbox.** Review is a role, an artifact, a scope, an expiration, and an accountability trail. + +--- ## Quick start +Under two minutes from clone to a working review: + ```bash +git clone https://github.com/fraware/SCOPE.git +cd SCOPE pip install -e ".[dev]" pytest -python evals/run_review_cases.py ``` -## CLI +Run the bundled evaluation scenarios (8 core; add `--extended` for 13 more, 21 total): + +```bash +python evals/run_review_cases.py +python evals/run_review_cases.py --extended +``` -### AKTA review (one-shot) +### AKTA one-shot review -Primary AKTA integration path — packet, decision, grant, and summary in one command: +The primary integration path when [AKTA](docs/akta_integration.md) flags an action for review: one command produces the packet, decision, grant, and summary. ```bash scope akta review \ @@ -29,234 +79,207 @@ scope akta review \ --grant-scope protocol_draft \ --reviewer examples/protocol_drift/reviewer_protocol_owner.json \ --decision-rationale "Narrow protocol draft approval only." \ - --out-dir /tmp/akta_review_out + --out-dir ./out/akta_review ``` -Writes `scope_review_packet.json`, `scope_decision.json`, `scope_grant.json`, and `summary.json` to `--out-dir`. +Outputs in `./out/akta_review/`: -In production mode, also pass `--signing-key` (Ed25519 private key PEM) so the decision is signed before grant issue. +| File | Purpose | +|------|---------| +| `scope_review_packet.json` | Artifacts and context for the reviewer | +| `scope_decision.json` | Structured approval with rationale | +| `scope_grant.json` | Runtime authorization bound to the decision | +| `summary.json` | One-line status for automation | -### Packet workflow +Full contract: [docs/akta_review_contract.md](docs/akta_review_contract.md). End-to-end demo: [docs/akta_scope_demo.md](docs/akta_scope_demo.md). -```bash -scope packet create --akta-record examples/protocol_change_review/akta_record.json \ - --akta-trigger examples/protocol_change_review/review_trigger.json \ - --vsa-report adapters/vsa/examples/scientific_report_example.json \ - --out /tmp/packet.json +--- -scope packet validate /tmp/packet.json +## Core workflows -scope packet render /tmp/packet.json --format markdown --out /tmp/packet.md -``` +### Review a request -### Decision and grant +Build and validate a packet from AKTA inputs, optionally enriched with a VSA scientific report: ```bash -scope decision submit --packet /tmp/packet.json \ - --reviewer examples/protocol_change_review/reviewer_protocol_owner.json \ - --decision examples/protocol_change_review/decision.json \ - --out /tmp/decision.json - -scope grant issue --packet /tmp/packet.json --decision /tmp/decision.json --out /tmp/grant.json - -scope grant check --grant /tmp/grant.json --requested-tool robot_queue.submit \ - --context examples/protocol_change_review/current_context.json \ - --ledger /tmp/scope_events.jsonl - -scope grant revoke --grant-id SCOPE-GRANT-XXXXXX --ledger /tmp/scope_events.jsonl +scope packet create \ + --akta-record examples/protocol_change_review/akta_record.json \ + --akta-trigger examples/protocol_change_review/review_trigger.json \ + --out ./out/packet.json -scope grant status --grant-id SCOPE-GRANT-XXXXXX --ledger /tmp/scope_events.jsonl +scope packet validate ./out/packet.json +scope packet render ./out/packet.json --format markdown --out ./out/packet.md ``` -### Multi-reviewer sessions (A6 and similar) - -```bash -scope review session create --packet /tmp/packet.json --out /tmp/session.json \ - --session-store json --session-dir /tmp/sessions - -scope review session vote --session /tmp/session.json --packet /tmp/packet.json \ - --reviewer examples/reviewer_domain_scientist.json \ - --decision examples/decision_ds.json --out /tmp/decision_ds.json \ - --session-store json --session-dir /tmp/sessions +For multi-reviewer cases, use review sessions (`scope review session …`) or the review queue (`scope review queue …`). See [docs/reviewer_guide.md](docs/reviewer_guide.md). -scope review session vote --session /tmp/session.json --packet /tmp/packet.json \ - --reviewer examples/reviewer_protocol_owner.json \ - --decision examples/decision_po.json --out /tmp/decision_po.json \ - --session-store json --session-dir /tmp/sessions +### Issue a grant -scope review session issue-grant --session /tmp/session.json --packet /tmp/packet.json \ - --decision /tmp/decision_ds.json --decision /tmp/decision_po.json --out /tmp/grant.json \ - --session-store json --session-dir /tmp/sessions - -scope review session status --session-id SCOPE-SESS-XXXXXX --packet /tmp/packet.json \ - --session-store json --session-dir /tmp/sessions -``` - -Session backends: `memory` (default), `json`, `sqlite`. Use `--session-dir` for persistence path. - -Review lifecycle ledger events: +Submit a reviewer decision, then issue a grant from the approved scope: ```bash -scope review open --packet-id SCOPE-PKT-XXXXXX --actor-id reviewer-1 --ledger /tmp/scope_events.jsonl +scope decision submit \ + --packet ./out/packet.json \ + --reviewer examples/protocol_change_review/reviewer_protocol_owner.json \ + --decision examples/protocol_change_review/decision.json \ + --out ./out/decision.json -scope review view-artifact --packet-id SCOPE-PKT-XXXXXX --artifact protocol_diff_ref \ - --actor-id reviewer-1 --ledger /tmp/scope_events.jsonl +scope grant issue \ + --packet ./out/packet.json \ + --decision ./out/decision.json \ + --out ./out/grant.json ``` -Quorum modes (`require_all`, `require_any`, `n_of_m`, `statistical_co_review`) and -`safety_veto_roles` are configured via optional `--quorum-policy` on session create. +In production deployments, decisions must be cryptographically signed before grant issue. See [docs/trusted_boundary.md](docs/trusted_boundary.md) and [docs/key_management.md](docs/key_management.md). -### Signing and production mode +### Check authorization at runtime -Production mode requires a signed decision before grant issue. Decisions may be submitted unsigned, then signed: +Before a tool executes, verify the grant still allows the requested action: ```bash -export SCOPE_PRODUCTION_MODE=true - -scope decision submit --packet /tmp/packet.json --reviewer reviewer.json \ - --decision decision.json --out /tmp/decision.json - -scope decision sign --decision /tmp/decision.json --key keys/reviewer.pem --out /tmp/signed_decision.json -scope decision validate --require-signature /tmp/signed_decision.json - -scope grant issue --packet /tmp/packet.json --decision /tmp/signed_decision.json --out /tmp/grant.json -scope grant sign --grant /tmp/grant.json --key keys/reviewer.pem --out /tmp/signed_grant.json -scope verify --artifact /tmp/signed_decision.json --public-key keys/reviewer.pub --type decision +scope grant check \ + --grant ./out/grant.json \ + --requested-tool robot_queue.submit \ + --context examples/protocol_change_review/current_context.json \ + --ledger ./out/scope_events.jsonl ``` -Use `--public-key` for verification without private key access. `--key` remains for dev signing only. - -See [docs/trusted_boundary.md](docs/trusted_boundary.md) for trust assumptions. - -### Export and quality +Returns `ALLOWED` or `BLOCKED`. Revoke or inspect status via `scope grant revoke` and `scope grant status`. -```bash -scope export pf --grant /tmp/grant.json --out /tmp/pf_obligation.json --validate --live +> For the complete CLI surface (export, quality, identity, policy overlays), run `scope --help` or see [docs/scoped_scientific_authorization.md](docs/scoped_scientific_authorization.md). -scope export pcs --packet /tmp/packet.json --decision /tmp/decision.json \ - --grant /tmp/grant.json --out /tmp/pcs_bundle --validate --live +--- -scope quality report --ledger /tmp/scope_events.jsonl --out /tmp/quality_report.json \ - --queue-dir .scope/queues -``` +## Ecosystem -Set `PF_CORE_REPO_PATH` or `PCS_CORE_REPO_PATH` to sibling repo roots for optional live contract validation (`--live`). When unset, validation skips with an explicit message. +| Component | Role | +|-----------|------| +| **AKTA** | Classifies whether a scientific action needs review or authorization | +| **SCOPE** | Runs the review workflow and issues scoped grants | +| **PF-Core** | Runtime obligation verification from grant exports | +| **PCS** | Release packaging from packet, decision, and grant artifacts | +| **VSA** | Optional scientific report enrichment for review packets | -### Review queue +Integration field mappings: [docs/external_integration_contracts.md](docs/external_integration_contracts.md). Optional REST API: install with `pip install -e ".[rest]"` and run `uvicorn adapters.generic_rest.server:app`. -```bash -scope review queue create --packet /tmp/packet.json --out /tmp/queue.json \ - --queue-dir .scope/queues +Institutional pilots: start with [docs/institutional_pilot_guide.md](docs/institutional_pilot_guide.md) and sample fixtures in [examples/institutional_pilot/](examples/institutional_pilot/). -scope review queue assign --queue /tmp/queue.json \ - --reviewer examples/protocol_change_review/reviewer_protocol_owner.json +--- -scope review queue status --queue-dir .scope/queues +## Documentation -scope review queue list --queue-dir .scope/queues +| Document | Description | +|----------|-------------| +| [scoped_scientific_authorization.md](docs/scoped_scientific_authorization.md) | Protocol overview and doc index | +| [akta_review_contract.md](docs/akta_review_contract.md) | Frozen `scope akta review` output contract | +| [akta_integration.md](docs/akta_integration.md) | AKTA adapter and trigger mapping | +| [akta_scope_demo.md](docs/akta_scope_demo.md) | Full AKTA → SCOPE → PF → PCS walkthrough | +| [reviewer_guide.md](docs/reviewer_guide.md) | Reviewer role guidance | +| [review_doctrine.md](docs/review_doctrine.md) | Review principles and accountability | +| [institutional_pilot_guide.md](docs/institutional_pilot_guide.md) | Pilot workshop and lab checklist | +| [institutional_guide.md](docs/institutional_guide.md) | Institutional deployment overview | +| [trusted_boundary.md](docs/trusted_boundary.md) | Trust assumptions and production mode | +| [limitations.md](docs/limitations.md) | In-repo vs external boundaries | +| [identity_assurance.md](docs/identity_assurance.md) | Reviewer identity provenance | +| [signing_assurance.md](docs/signing_assurance.md) | Decision and grant signing provenance | +| [rbac_scope_authority.md](docs/rbac_scope_authority.md) | Org RBAC and scope policy checks | +| [key_management.md](docs/key_management.md) | Key registry and signing providers | +| [quality_metrics.md](docs/quality_metrics.md) | Ledger-backed review quality metrics | +| [threat_model.md](docs/threat_model.md) | Threats addressed and residual risk | +| [external_integration_contracts.md](docs/external_integration_contracts.md) | Cross-repo field mappings | +| [pf_core_bridge.md](docs/pf_core_bridge.md) | PF-Core obligation export | +| [pcs_export.md](docs/pcs_export.md) | PCS bundle export | +| [evidence_vocab_mapping.md](docs/evidence_vocab_mapping.md) | Evidence vocabulary alignment | +| [field_thesis.md](docs/field_thesis.md) | Design rationale | +| [CHANGELOG.md](CHANGELOG.md) | Release history | -scope review queue decide --queue /tmp/queue.json --decision-id SCOPE-DEC-XXXXXX +--- -scope review queue grant --queue /tmp/queue.json --grant-id SCOPE-GRANT-XXXXXX +## Contributing -scope review queue close --queue /tmp/queue.json --reason withdrawn -``` +Contributions are welcome — bug reports, docs improvements, adapters, and test coverage all help. -### Key registry +1. **Open an issue** to discuss larger changes: [GitHub Issues](https://github.com/fraware/SCOPE/issues) +2. **Fork, branch, and open a PR**: [GitHub Pull Requests](https://github.com/fraware/SCOPE/pulls) +3. **Run the same checks as CI** before submitting: ```bash -scope key register --reviewer-id protocol_owner_1 \ - --public-key keys/reviewer.pub +# Linux / macOS +bash scripts/ci.sh -scope key list - -scope key verify-registry --decision /tmp/signed_decision.json +# Windows +.\scripts\ci.ps1 ``` -See [docs/key_management.md](docs/key_management.md). - -## REST API +CI runs on Python 3.10, 3.11, and 3.12: `ruff` lint, `mypy` typecheck, `pytest`, and all 21 evaluation scenarios with `--extended`. -Optional FastAPI server (install with `pip install -e ".[rest]"`): +Individual commands: ```bash -uvicorn adapters.generic_rest.server:app --reload +pip install -e ".[dev]" +ruff check scope tests evals adapters +mypy scope +pytest +python evals/run_review_cases.py --extended ``` -Set `SCOPE_LEDGER_PATH` for ledger-backed grant check/revoke/status. -Set `SCOPE_POLICY_DIR` to override the default `policy/` directory (REST key registry). -Set `SCOPE_SIGNING_KEY` for sign/verify endpoints. -Set `SCOPE_SESSION_STORE` and `SCOPE_SESSION_DIR` for persistent review sessions (`json` or `sqlite`). -Set `SCOPE_API_KEY` to require `Authorization: Bearer ` on all endpoints except `/v0/health`. -Set `SCOPE_REVIEW_ROUTE_PROMOTION=true` (default) to promote valid AKTA `review_scope` values to `requested_scope`. - -Grant check returns `{allowed, reason, code}` where `code` is `allowed`, `tool_blocked`, `tool_not_allowed`, `grant_expired`, or `grant_revoked`. - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/v0/health` | Health check | -| POST | `/v0/packets` | Create packet | -| POST | `/v0/packets/validate` | Validate packet | -| POST | `/v0/packets/render` | Render packet (markdown/html) | -| POST | `/v0/decisions` | Submit decision | -| POST | `/v0/decisions/sign` | Sign decision | -| POST | `/v0/review-sessions` | Create review session | -| GET | `/v0/review-sessions/{id}` | Session status | -| POST | `/v0/review-sessions/{id}/votes` | Submit session vote | -| POST | `/v0/review-sessions/{id}/grants` | Issue grant from session | -| POST | `/v0/grants` | Issue grant | -| POST | `/v0/grants/check` | Runtime grant check | -| POST | `/v0/grants/revoke` | Revoke grant | -| GET | `/v0/grants/{id}/status` | Grant ledger status | -| POST | `/v0/grants/sign` | Sign grant | -| POST | `/v0/verify` | Verify signed artifact | -| GET | `/v0/review-queue` | List queue entries and metrics | -| POST | `/v0/review-queue` | Create queue entry | -| POST | `/v0/review-queue/{id}/assign` | Assign reviewer | -| POST | `/v0/review-queue/{id}/decide` | Mark decided | -| POST | `/v0/review-queue/{id}/grant` | Mark granted | -| POST | `/v0/review-queue/{id}/close` | Close without grant | -| GET | `/v0/keys` | List key registry | -| POST | `/v0/keys/register` | Register reviewer public key | -| POST | `/v0/keys/verify-registry` | Verify decision against registry | -| POST | `/v0/akta/review` | AKTA one-shot review (packet → decision → grant) | -| GET | `/v0/quality` | Quality report (`?queue_dir=` optional) | -| POST | `/v0/export/pf` | PF-Core obligation export | -| POST | `/v0/export/pf/validate` | Validate PF export | -| POST | `/v0/export/pcs` | PCS bundle export | -| POST | `/v0/export/pcs/validate` | Validate PCS export | - -Full cross-repo demo: [docs/akta_scope_demo.md](docs/akta_scope_demo.md). -Integration contracts: [docs/external_integration_contracts.md](docs/external_integration_contracts.md). - -## Python API +### Repository layout + +| Path | Contents | +|------|----------| +| `scope/` | Core protocol engine and CLI | +| `schemas/` | JSON schemas for artifacts | +| `policy/` | YAML policy bundles and domain overlays | +| `adapters/` | AKTA, VSA, PF-Core, PCS, and REST integrations | +| `examples/` | Scenario fixtures for docs and evals | +| `evals/` | 8 core + 13 extended evaluation scenarios | +| `tests/` | Pytest suite | +| `docs/` | Protocol and integration documentation | + +### Python API ```python from scope import ScopeEngine engine = ScopeEngine.from_policy_dir("policy/", ledger_path="logs/scope_events.jsonl") -packet = engine.create_packet("examples/protocol_change_review/akta_record.json", - "examples/protocol_change_review/review_trigger.json") -decision = engine.submit_decision(packet, {"reviewer_id": "r1", "role": "protocol_owner"}, { - "type": "approve_narrower_scope", - "approved_scope": "protocol_draft", - "rationale": "Evidence supports validation draft only.", -}) +packet = engine.create_packet( + "examples/protocol_change_review/akta_record.json", + "examples/protocol_change_review/review_trigger.json", +) +decision = engine.submit_decision( + packet, + {"reviewer_id": "r1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Evidence supports validation draft only.", + }, +) grant = engine.issue_grant(packet, decision) -allowed = engine.check_grant_detailed(grant, "protocol_editor.draft_change", {"protocol_version": "protocol_v3"}) +allowed = engine.check_grant_detailed( + grant, "protocol_editor.draft_change", {"protocol_version": "protocol_v3"} +) ``` -## Repository layout +--- + +## For integrators + +Technical reference for assurance models, production mode, and cross-repo contracts: + +| Topic | Document | +|-------|----------| +| Identity assurance levels (IAL0–IAL4) | [identity_assurance.md](docs/identity_assurance.md) | +| Signing assurance levels (SAL0–SAL4) | [signing_assurance.md](docs/signing_assurance.md) | +| Two-stage authority checks | [rbac_scope_authority.md](docs/rbac_scope_authority.md) | +| Production mode and signing | [trusted_boundary.md](docs/trusted_boundary.md) | +| REST API (`POST /v0/akta/review`, grant check, queue) | [external_integration_contracts.md](docs/external_integration_contracts.md) | +| PF obligation and PCS manifest formats | [pf_core_bridge.md](docs/pf_core_bridge.md), [pcs_export.md](docs/pcs_export.md) | + +Environment variables for production (OIDC identity, ledger delivery, signing keys) are documented in [docs/trusted_boundary.md](docs/trusted_boundary.md) and [docs/institutional_pilot_guide.md](docs/institutional_pilot_guide.md). -- `scope/` - core protocol engine -- `schemas/` - JSON schemas for artifacts -- `policy/` - YAML policy files (`scope-core-v0.6`) and domain overlays -- `adapters/` - AKTA, VSA, PF-Core, PCS, REST integrations -- `examples/` - scenario fixtures -- `evals/` - eight core evaluation scenarios (+ four extended with `--extended`) -- `tests/` - pytest suite -- `docs/` - protocol documentation +--- ## License -MIT - see [LICENSE](LICENSE). +MIT — see [LICENSE](LICENSE). diff --git a/adapters/akta/examples/README.md b/adapters/akta/examples/README.md index 022ccb6..4e65b33 100644 --- a/adapters/akta/examples/README.md +++ b/adapters/akta/examples/README.md @@ -2,12 +2,39 @@ Sample inputs for `adapters/akta/import_record.py` and `import_trigger.py`. +## Fixtures + +| File | Description | +|------|-------------| +| `akta_record.json` | Minimal flat record stub | +| `akta_record_nested.json` | Nested v0.4-style record (golden fixture) | +| `akta_review_trigger_v04.json` | v0.4 trigger with alias fields | +| `review_trigger.json` | Simple flat trigger | + +Richer scenario fixtures live under [examples/protocol_change_review/](../../examples/protocol_change_review/). + +## Primary integration path (v0.7) + +```bash +scope akta review \ + --akta-record adapters/akta/examples/akta_record_nested.json \ + --akta-trigger adapters/akta/examples/akta_review_trigger_v04.json \ + --grant-scope protocol_draft \ + --reviewer examples/protocol_drift/reviewer_protocol_owner.json \ + --decision-rationale "Narrow protocol draft approval only." \ + --out-dir /tmp/akta_review_out +``` + +Output contract: [docs/akta_review_contract.md](../../docs/akta_review_contract.md). + +## Low-level Python import + ```python from adapters.akta.import_record import load_akta_record from adapters.akta.import_trigger import load_review_trigger -record = load_akta_record("adapters/akta/examples/akta_record.json") -trigger = load_review_trigger("adapters/akta/examples/review_trigger.json") +record = load_akta_record("adapters/akta/examples/akta_record_nested.json") +trigger = load_review_trigger("adapters/akta/examples/akta_review_trigger_v04.json") ``` -These mirror `examples/protocol_change_review/` fixtures. +Field mappings: [docs/external_integration_contracts.md](../../docs/external_integration_contracts.md). diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index fee15b8..28434f5 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -181,6 +181,20 @@ class ReviewQueueCloseRequest(BaseModel): reason: str = "" +class ReviewQueueNeedsInformationRequest(BaseModel): + reason: str = "" + + +class ReviewQueueCancelRequest(BaseModel): + reason: str = "" + + +class ReviewQueueEscalateEntryRequest(BaseModel): + reviewer: dict[str, Any] | None = None + reason: str = "" + actor_id: str | None = None + + class KeyRegisterRequest(BaseModel): reviewer_id: str public_key_path: str @@ -208,6 +222,16 @@ def _http_error(exc: Exception) -> HTTPException: return HTTPException(status_code=400, detail=str(exc)) +@app.exception_handler(ScopeValidationError) +async def _scope_validation_handler(_request: Request, exc: ScopeValidationError) -> HTTPException: + raise _http_error(exc) + + +@app.exception_handler(GrantValidationError) +async def _grant_validation_handler(_request: Request, exc: GrantValidationError) -> HTTPException: + raise _http_error(exc) + + @app.get("/v0/health") def health() -> dict[str, str]: return {"status": "ok", "version": __version__} @@ -460,6 +484,104 @@ def close_review_queue( return ReviewQueue.load(path).status_summary() +@app.post("/v0/review-queue/{queue_id}/in-review", dependencies=[Depends(_require_api_key)]) +def in_review_review_queue( + queue_id: str, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().in_review_review_queue(path) + return ReviewQueue.load(path).status_summary() + + +@app.post( + "/v0/review-queue/{queue_id}/needs-information", + dependencies=[Depends(_require_api_key)], +) +def needs_information_review_queue( + queue_id: str, + req: ReviewQueueNeedsInformationRequest, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().needs_information_review_queue(path, reason=req.reason) + return ReviewQueue.load(path).status_summary() + + +@app.post( + "/v0/review-queue/{queue_id}/information-received", + dependencies=[Depends(_require_api_key)], +) +def information_received_review_queue( + queue_id: str, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().information_received_review_queue(path) + return ReviewQueue.load(path).status_summary() + + +@app.post("/v0/review-queue/{queue_id}/reopen", dependencies=[Depends(_require_api_key)]) +def reopen_review_queue( + queue_id: str, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().reopen_review_queue(path) + return ReviewQueue.load(path).status_summary() + + +@app.post("/v0/review-queue/{queue_id}/expire", dependencies=[Depends(_require_api_key)]) +def expire_review_queue( + queue_id: str, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().expire_review_queue(path) + return ReviewQueue.load(path).status_summary() + + +@app.post("/v0/review-queue/{queue_id}/cancel", dependencies=[Depends(_require_api_key)]) +def cancel_review_queue( + queue_id: str, + req: ReviewQueueCancelRequest, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().cancel_review_queue(path, reason=req.reason) + return ReviewQueue.load(path).status_summary() + + +@app.post("/v0/review-queue/{queue_id}/escalate", dependencies=[Depends(_require_api_key)]) +def escalate_review_queue_entry( + queue_id: str, + req: ReviewQueueEscalateEntryRequest, + queue_dir: str | None = None, +) -> dict[str, Any]: + from scope.review_queue import ReviewQueue + + path = _find_queue_path(queue_id, queue_dir) + get_engine().escalate_review_queue_entry( + path, + req.reviewer, + reason=req.reason, + actor_id=req.actor_id, + ) + return ReviewQueue.load(path).status_summary() + + def _policy_dir() -> Path: return Path(get_engine().policy.policy_dir) diff --git a/adapters/pcs/examples/README.md b/adapters/pcs/examples/README.md index 7fd7ddb..91a18c7 100644 --- a/adapters/pcs/examples/README.md +++ b/adapters/pcs/examples/README.md @@ -6,16 +6,34 @@ Sample PCS release layout produced by `adapters/pcs/export_artifact.py`: - `scope_decision.json` - `scope_grant.json` - `pf_obligation.json` -- `release_manifest.json` (artifact hashes and source IDs) +- `release_manifest.json` (artifact hashes, trust root, registry metadata) -Generate live output: +Manifest version: `pcs-v0.5`. See [docs/pcs_export.md](../../docs/pcs_export.md). + +## Generate live output + +Use artifacts from [examples/institutional_pilot/](../../examples/institutional_pilot/) or your own grant workflow: + +```bash +scope export pcs \ + --packet examples/institutional_pilot/scope_packet.json \ + --decision examples/institutional_pilot/scope_decision.json \ + --grant examples/institutional_pilot/scope_grant.json \ + --out dist/pcs_scope_artifact/ \ + --validate +``` + +Optional live contract validation: ```bash +export PCS_CORE_REPO_PATH=/path/to/pcs-core scope export pcs \ - --packet /tmp/packet.json \ - --decision /tmp/decision.json \ - --grant /tmp/grant.json \ - --out dist/pcs_scope_artifact/ + --packet examples/institutional_pilot/scope_packet.json \ + --decision examples/institutional_pilot/scope_decision.json \ + --grant examples/institutional_pilot/scope_grant.json \ + --out dist/pcs_scope_artifact/ --validate --live ``` -The manifest includes canonical `sha256:` hashes for each artifact file. +The manifest includes canonical `sha256:` hashes for each artifact file plus `scope_trust_root_hash` when policy and registry are configured. + +Field mapping: [docs/external_integration_contracts.md](../../docs/external_integration_contracts.md). diff --git a/adapters/pf_core/examples/README.md b/adapters/pf_core/examples/README.md index da2473c..a63d10b 100644 --- a/adapters/pf_core/examples/README.md +++ b/adapters/pf_core/examples/README.md @@ -2,15 +2,28 @@ Sample output from `adapters/pf_core/export_obligation.py` after issuing a protocol-draft grant. -Generate live output: +Contract version: `pf-core-v0.5`. See [docs/pf_core_bridge.md](../../docs/pf_core_bridge.md). + +## Generate live output + +After running a grant workflow (e.g. [examples/stale_grant_attempt/](../../examples/stale_grant_attempt/) or [examples/institutional_pilot/](../../examples/institutional_pilot/)): ```bash scope export pf \ - --grant examples/protocol_change_review/scope_grant.json \ - --out dist/pf_obligation.json + --grant examples/institutional_pilot/scope_grant.json \ + --out dist/pf_obligation.json \ + --validate ``` -Or from Python: +Optional live contract validation: + +```bash +export PF_CORE_REPO_PATH=/path/to/pf-core +scope export pf --grant examples/institutional_pilot/scope_grant.json \ + --out dist/pf_obligation.json --validate --live +``` + +## Python API ```python from adapters.pf_core.export_obligation import export_pf_obligation @@ -19,4 +32,6 @@ import json obligation = export_pf_obligation(json.load(open("scope_grant.json"))) ``` -See `pf_obligation.json` in this directory for the expected shape. +## Sample shape + +See `pf_obligation.json` in this directory for a representative obligation layout. Regenerate from a live grant for current contract fields and signature metadata. diff --git a/adapters/pf_core/examples/pf_obligation.json b/adapters/pf_core/examples/pf_obligation.json index 7a76fd9..26f18d8 100644 --- a/adapters/pf_core/examples/pf_obligation.json +++ b/adapters/pf_core/examples/pf_obligation.json @@ -1,5 +1,5 @@ { - "obligation_version": "pf-core-v0.1", + "obligation_version": "pf-core-v0.5", "grant_id": "SCOPE-GRANT-EXAMPLE", "grant_hash": "sha256:example", "permitted_tools": ["protocol_editor.draft_change"], diff --git a/docs/akta_integration.md b/docs/akta_integration.md index e8ce226..1f110ff 100644 --- a/docs/akta_integration.md +++ b/docs/akta_integration.md @@ -2,12 +2,32 @@ SCOPE begins when AKTA returns `review_required` or `authorization_required`. +## Primary integration path + +For packet → decision → grant in one step, use: + +```bash +scope akta review \ + --akta-record path/to/akta_record.json \ + --akta-trigger path/to/review_trigger.json \ + --grant-scope protocol_draft \ + --reviewer path/to/reviewer.json \ + --decision-rationale "Rationale text." \ + --out-dir /tmp/akta_review_out +``` + +REST equivalent: `POST /v0/akta/review`. + +Output contract: [akta_review_contract.md](akta_review_contract.md). Full demo: [akta_scope_demo.md](akta_scope_demo.md). Field mappings: [external_integration_contracts.md](external_integration_contracts.md). + ## Inputs - **AKTA Record** — scientific action context, artifacts, constraints - **Review trigger** — requested tool, action type, admissibility -## Adapter +Golden fixtures: `adapters/akta/examples/` (see [adapters/akta/examples/README.md](../adapters/akta/examples/README.md)). + +## Low-level adapter (Python) ```python from adapters.akta.import_trigger import load_review_trigger @@ -18,6 +38,8 @@ engine = ScopeEngine.from_policy_dir("policy/") packet = engine.create_packet("akta_record.json", trigger) ``` +Use this path when you need custom packet enrichment (VSA reports, manual review steps) before decision submit. + ## Required trigger fields - `scientific_action_type` (e.g. `A5_protocol_modification`) @@ -25,3 +47,11 @@ packet = engine.create_packet("akta_record.json", trigger) - `requested_tool` SCOPE resolves required reviewer roles from `policy/role_to_action_matrix.yaml`. + +## v0.7 provenance on review output + +Decisions and grants from `scope akta review` record: + +- Identity assurance level (IAL0–IAL4) — see [identity_assurance.md](identity_assurance.md) +- Signing assurance level (SAL0–SAL4) — see [signing_assurance.md](signing_assurance.md) +- Two-stage `authority_checks` when RBAC is enforced — see [rbac_scope_authority.md](rbac_scope_authority.md) diff --git a/docs/akta_review_contract.md b/docs/akta_review_contract.md new file mode 100644 index 0000000..50fd57f --- /dev/null +++ b/docs/akta_review_contract.md @@ -0,0 +1,55 @@ +# AKTA Review Output Contract + +SCOPE v0.7 freezes the `scope akta review` output contract under `out_dir/`. + +## Artifacts + +| File | Description | +|------|-------------| +| `scope_review_packet.json` | Review packet | +| `scope_decision.json` | Signed or unsigned decision | +| `scope_grant.json` | Issued grant | +| `summary.json` | Adapter summary (validated against schema) | + +## summary.json contract + +Contract version constant: `scope-akta-review-v0.7` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). + +Required fields: + +```json +{ + "packet_path": "...", + "decision_path": "...", + "grant_path": "...", + "approved_scope": "...", + "requested_scope": "...", + "adapter_contract_version": "scope-akta-review-v0.7", + "identity_assurance_level": "IAL0", + "signing_assurance_level": "SAL1", + "production_mode": false +} +``` + +Optional fields: `status`, `packet_id`, `decision_id`, `grant_id`, `allowed_tools`, `blocked_tools`, `decision_type`, `scope_trust_root_hash`, `queue_id`. + +Schema: `schemas/scope_akta_review_summary.schema.json` + +## Acceptance criteria + +`scope akta review` enforces: + +- Summary validates against schema +- Overbroad approval fails (approved scope stronger than requested) +- Unsigned production grant fails without signing key/provider +- Missing reviewer authority fails (two-stage RBAC + SCOPE policy) + +## Primary AKTA path + +`scope akta review` is the documented primary AKTA integration path for packet → decision → grant workflows. + +## Related documentation + +- [akta_integration.md](akta_integration.md) — integration overview +- [akta_scope_demo.md](akta_scope_demo.md) — full cross-repo demo +- [external_integration_contracts.md](external_integration_contracts.md) — AKTA field mappings diff --git a/docs/akta_scope_demo.md b/docs/akta_scope_demo.md index e6a7c72..6aa5bb5 100644 --- a/docs/akta_scope_demo.md +++ b/docs/akta_scope_demo.md @@ -1,6 +1,8 @@ # AKTA to SCOPE to PF to PCS Demo -This document walks through the full authorization chain using SCOPE v0.5.1. +This document walks through the full authorization chain using SCOPE v0.7. + +Related: [akta_review_contract.md](akta_review_contract.md), [external_integration_contracts.md](external_integration_contracts.md), [institutional_pilot_guide.md](institutional_pilot_guide.md). ## Primary path: `scope akta review` @@ -23,12 +25,13 @@ Outputs in `/tmp/akta_review_out/`: | `scope_review_packet.json` | SCOPE review packet from AKTA inputs | | `scope_decision.json` | Scoped approval decision | | `scope_grant.json` | Bounded authorization grant | -| `summary.json` | Machine-readable paths, scope, blocked tools, status | +| `summary.json` | Machine-readable contract summary (validated against schema) | + +`summary.json` includes `adapter_contract_version` (`scope-akta-review-v0.7`), `identity_assurance_level`, `signing_assurance_level`, and `production_mode`. SCOPE rejects overbroad `--grant-scope` values against the packet's `requested_scope`. -In production mode (`$env:SCOPE_PRODUCTION_MODE = "true"`), pass `--signing-key` with the -reviewer's Ed25519 private key PEM. The command signs the decision before grant issue: +In production mode (`$env:SCOPE_PRODUCTION_MODE = "true"`), pass `--signing-key` or `--signing-provider` with the reviewer's key. The command signs the decision before grant issue: ```powershell $env:SCOPE_PRODUCTION_MODE = "true" @@ -42,7 +45,16 @@ scope akta review ` --out-dir /tmp/akta_review_out ``` -Without `--signing-key` in production mode, the command fails before writing a grant. +Optional institutional identity: + +```powershell +scope akta review ... ` + --identity-token $env:OIDC_TOKEN ` + --enforce-rbac ` + --signing-provider registry --reviewer-id protocol_owner_1 +``` + +Without signing credentials in production mode, the command fails before writing a grant. For non-production workflows, sign the decision and re-issue the grant from saved artifacts (see Step 4 below). @@ -172,7 +184,7 @@ scope export pcs ` --validate ``` -PCS bundle includes manifest hashes for tamper detection. +PCS bundle includes manifest hashes for tamper detection. Obligation version: `pf-core-v0.5`; manifest version: `pcs-v0.5`. ## Step 7: Quality report @@ -192,4 +204,4 @@ akta record evaluate --input action.json --out akta_record.json akta review trigger --record akta_record.json --out review_trigger.json ``` -SCOPE consumes both artifacts via `scope packet create`. +SCOPE consumes both artifacts via `scope packet create` or the one-shot `scope akta review`. diff --git a/docs/evidence_vocab_mapping.md b/docs/evidence_vocab_mapping.md index c0882d5..0049c24 100644 --- a/docs/evidence_vocab_mapping.md +++ b/docs/evidence_vocab_mapping.md @@ -34,3 +34,5 @@ AKTA and SCOPE share an evidence-state vocabulary with historical aliases. SCOPE ## Quality warning When a reviewer submits an approval decision (`approve`, `approve_narrower_scope`) and the packet `scientific_context.evidence_state` is in the weak set, SCOPE emits ledger event `quality_warning_emitted` with `warning_type: approval_despite_low_evidence`. + +See [external_integration_contracts.md](external_integration_contracts.md) for AKTA import behavior and [quality_metrics.md](quality_metrics.md) for related metrics. diff --git a/docs/external_integration_contracts.md b/docs/external_integration_contracts.md index e5b92d5..2aa67b7 100644 --- a/docs/external_integration_contracts.md +++ b/docs/external_integration_contracts.md @@ -1,7 +1,9 @@ -# External Integration Contracts (v0.5) +# External Integration Contracts (v0.7) 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. +Related docs: [akta_review_contract.md](akta_review_contract.md), [pf_core_bridge.md](pf_core_bridge.md), [pcs_export.md](pcs_export.md), [evidence_vocab_mapping.md](evidence_vocab_mapping.md). + ## AKTA import ### Inputs @@ -47,6 +49,21 @@ Golden fixtures: Evidence vocabulary mapping: [evidence_vocab_mapping.md](evidence_vocab_mapping.md). +## AKTA review output (primary path) + +`scope akta review` and `POST /v0/akta/review` emit a frozen output bundle under `out_dir/`: + +| File | Description | +|------|-------------| +| `scope_review_packet.json` | Review packet | +| `scope_decision.json` | Decision (signed in production mode) | +| `scope_grant.json` | Issued grant | +| `summary.json` | Adapter summary validated against `schemas/scope_akta_review_summary.schema.json` | + +Contract version: `scope-akta-review-v0.7`. Required `summary.json` fields include `adapter_contract_version`, `identity_assurance_level`, `signing_assurance_level`, and `production_mode`. + +Full contract: [akta_review_contract.md](akta_review_contract.md). + ## VSA import ### Input @@ -93,6 +110,8 @@ Example: `adapters/vsa/examples/scientific_report_example.json`. Contract fixture: `tests/fixtures/contracts/pf_obligation_contract.json`. +See [pf_core_bridge.md](pf_core_bridge.md). + ### Live validation (optional) When `PF_CORE_REPO_PATH` points to a PF-Core checkout, SCOPE can invoke a documented validator script from that repo: @@ -135,7 +154,7 @@ Directory containing: Contract fixture: `tests/fixtures/contracts/pcs_manifest_contract.json`. -Key registry workflow: [key_management.md](key_management.md). +Key registry workflow: [key_management.md](key_management.md). See [pcs_export.md](pcs_export.md). ### Live validation (optional) @@ -168,27 +187,33 @@ Resolved at packet create via `scope/review_assignment.py`: Schema: `schemas/review_assignment.schema.json`. -## Review queue (v0.5) +## Review queue (v0.7) File-backed queue entries under `.scope/queues/` (override with `--queue-dir`). Schema: `schemas/scope_review_queue.schema.json`. -CLI: `scope review queue create|assign|status`. +Ten-state workflow: `open`, `assigned`, `in_review`, `needs_information`, `escalated`, `decided`, `granted`, `expired`, `closed`, `cancelled`. Grants require `decided → granted`; direct grant from open or in-review states is forbidden. + +CLI: `scope review queue create|assign|status|list|decide|grant|close` plus transition commands (`in-review`, `needs-information`, etc.). + +REST: full queue lifecycle under `/v0/review-queue/{id}/...`. -Quality metrics: `open_queue_count`, `overdue_queue_count`. +Quality metrics: `open_queue_count`, `overdue_queue_count` (open statuses include `in_review`, `needs_information`, `escalated`). -## Version alignment (v0.5) +## Version alignment (v0.7) | Artifact | Version field | Expected value | |----------|---------------|----------------| -| SCOPE packet | `packet_version` | `0.5.1` | -| SCOPE grant | `grant_version` | `0.5.1` | -| Quality report | `report_version` | `0.5` | -| Review queue | `queue_version` | `0.5.1` | +| 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` | | PF obligation | `obligation_version` | `pf-core-v0.5` | | PCS manifest | `manifest_version` | `pcs-v0.5` | -| Policy bundle | `version` in YAML | `scope-core-v0.5` | +| Policy bundle | `version` in YAML | `scope-core-v0.7` | ## External repo dependencies diff --git a/docs/field_thesis.md b/docs/field_thesis.md index bf9e4f2..b1dc871 100644 --- a/docs/field_thesis.md +++ b/docs/field_thesis.md @@ -14,3 +14,5 @@ SCOPE operationalizes review by binding it to: - Measurable review quality The field-level change: **review becomes enforceable, auditable, and improvable.** + +See [scoped_scientific_authorization.md](scoped_scientific_authorization.md) for the SCOPE v0.7 implementation. diff --git a/docs/identity_assurance.md b/docs/identity_assurance.md new file mode 100644 index 0000000..6315bab --- /dev/null +++ b/docs/identity_assurance.md @@ -0,0 +1,53 @@ +# Identity Assurance (IAL) + +SCOPE v0.7 records identity assurance levels on every decision and grant provenance block. + +## Levels + +| Level | Alias | Meaning | +|-------|-------|---------| +| IAL0 | `caller_supplied` | Caller-supplied reviewer JSON only; not institutional authority | +| IAL1 | `local_signed_key` | Valid local signature on decision; no OIDC verification | +| IAL2 | `oidc_verified` | OIDC-verified identity without matching institutional directory role | +| IAL3 | `oidc_plus_rbac_role` | OIDC plus institutional directory role from `org_rbac.yaml` | +| IAL4 | `oidc_plus_rbac_plus_delegation` | OIDC plus active delegation record with `delegation_id` | + +## Provenance fields + +```json +{ + "identity_assurance_level": "IAL2", + "identity_source": "oidc_jwt", + "role_resolution_source": "oidc_verified", + "delegation_id": "ds1->ds2", + "identity_claim_hash": "sha256:..." +} +``` + +`role_resolution_source` values: + +- `caller_supplied` — IAL0 caller JSON only +- `local_signed_key` — IAL1 signed decision without OIDC +- `oidc_verified` — IAL2 OIDC identity without org RBAC role match +- `org_rbac` — IAL3/IAL4 institutional role resolved from `org_rbac.yaml` + +## Rules + +- Caller-supplied JSON alone is IAL0 and must not be labeled institutional in render output or documentation. +- OIDC without RBAC role match cannot grant institutional authority (IAL2 without role authority). +- Delegated roles require `delegation_id` from an active `org_rbac` delegation record. +- Wire identity via `--identity-token` or `SCOPE_OIDC_ENABLED` with `--enforce-rbac` / `SCOPE_ENFORCE_RBAC`. + +## Configuration + +- `policy/identity_mapping.yaml` — claim to reviewer_id and role mapping +- `policy/org_rbac.yaml` — institutional directory roles and delegations +- `SCOPE_OIDC_JWKS_URL`, `SCOPE_OIDC_ISSUER`, `SCOPE_OIDC_AUDIENCE`, `SCOPE_OIDC_PUBLIC_KEY_PEM` + +Schema: `schemas/identity_assurance.schema.json` + +## Related documentation + +- [rbac_scope_authority.md](rbac_scope_authority.md) — two-stage authority and `authority_checks` +- [trusted_boundary.md](trusted_boundary.md) — OIDC configuration and trust assumptions +- [akta_review_contract.md](akta_review_contract.md) — IAL recorded in `summary.json` diff --git a/docs/institutional_guide.md b/docs/institutional_guide.md index e49ca3d..583c006 100644 --- a/docs/institutional_guide.md +++ b/docs/institutional_guide.md @@ -1,19 +1,31 @@ # Institutional Guide -Institutions adopting SCOPE v0.1 should: +Institutions adopting SCOPE v0.7 should start with the [institutional pilot guide](institutional_pilot_guide.md), which covers workshop flow, checklist, and v0.7 features (identity assurance, signing assurance, review queue, ledger delivery). -1. Configure `policy/` files for local role assignments and scope boundaries -2. Integrate AKTA review triggers into the SCOPE packet pipeline -3. Enforce grants at runtime via PF-Core or equivalent -4. Archive SCOPE ledger events with PCS release packages -5. Monitor quality reports for rubber-stamping and scope violations +## Adoption checklist + +1. Configure and version-pin `policy/` files (`scope-core-v0.7`) for local role assignments and scope boundaries +2. Map lab personnel to reviewer roles; generate Ed25519 keypairs and register public keys +3. Integrate AKTA review triggers via `scope akta review` or `scope packet create` +4. Enable production mode (`SCOPE_PRODUCTION_MODE=true`) and minimum signing assurance for grant enforcement +5. Optional: OIDC identity (`SCOPE_OIDC_*`) and org RBAC (`SCOPE_ENFORCE_RBAC=true`) +6. Enforce grants at runtime via PF-Core or equivalent +7. Archive SCOPE ledger events with PCS release packages +8. Monitor quality reports for rubber-stamping and scope violations ## Evidence for auditors -- Who reviewed (role and ID) -- What scope was approved -- What remained blocked -- When grants expired -- Whether runtime respected grants +- Who reviewed (role, ID, identity assurance level, authority checks) +- What scope was approved and what remained blocked +- Signing assurance level on decisions and grants +- When grants expired and whether runtime respected grants +- Trust root hashes binding policy and key registry SCOPE provides the authorization trail; it does not certify scientific correctness or regulatory compliance. + +## Related documentation + +- [institutional_pilot_guide.md](institutional_pilot_guide.md) — workshop and lab integration +- [trusted_boundary.md](trusted_boundary.md) — trust assumptions +- [limitations.md](limitations.md) — in-repo vs external boundaries +- [reviewer_guide.md](reviewer_guide.md) — role-specific guidance diff --git a/docs/institutional_pilot_guide.md b/docs/institutional_pilot_guide.md index cd94e19..0ae08d4 100644 --- a/docs/institutional_pilot_guide.md +++ b/docs/institutional_pilot_guide.md @@ -1,6 +1,15 @@ # Institutional Pilot Guide -This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKTA. +This guide supports workshop, funder, and lab pilots of SCOPE v0.7 alongside AKTA. + +## v0.7 institutional additions + +- **Identity assurance (IAL0–IAL4)** on every decision and grant provenance — [identity_assurance.md](identity_assurance.md) +- **Authority checks** with two-stage org RBAC then SCOPE scope policy separation — [rbac_scope_authority.md](rbac_scope_authority.md) +- **Signing assurance (SAL0–SAL4)** with production minimums in `policy/minimum_signing_assurance.yaml` — [signing_assurance.md](signing_assurance.md) +- **Ledger delivery modes**: `best_effort`, `at_least_once` (spool), `fail_closed` for high-risk events (`SCOPE_LEDGER_DELIVERY_MODE`) +- **Review queue workflow** (10 states) with REST, CLI, and static HTML dashboard +- **Frozen AKTA review contract** (`summary.json` schema, `scope akta review` primary path) — [akta_review_contract.md](akta_review_contract.md) ## v0.6 institutional additions @@ -21,7 +30,7 @@ This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKT ## Workshop flow (90 minutes) 1. **AKTA evaluation** — Present a scientific action; AKTA emits record + review trigger -2. **SCOPE packet** — `scope packet create` and `scope packet render` for reviewers +2. **SCOPE packet** — `scope packet create` or `scope akta review` for one-shot packet/decision/grant 3. **Review** — Reviewers read rendered packet (permits/denies, checklist, warnings) 4. **Decision** — `scope decision submit` (unsigned OK in production mode) 5. **Sign** — `scope decision sign` with pilot reviewer key @@ -29,7 +38,7 @@ This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKT 7. **Verify** — `scope verify --public-key` for auditor demonstration 8. **Export** — PCS bundle for institutional record-keeping -Sample artifacts: `examples/institutional_pilot/` +Sample artifacts: [examples/institutional_pilot/](../examples/institutional_pilot/) ## Multi-reviewer pilot (A6) @@ -47,27 +56,30 @@ Collect votes from `domain_scientist` and `protocol_owner`, then issue grant fro Export quality report from ledger: ```bash -scope quality report --ledger pilot_events.jsonl --out quality_report.json +scope quality report --ledger pilot_events.jsonl --out quality_report.json \ + --queue-dir .scope/queues ``` -Report includes reviewer metrics, warnings, and event counts. Non-certification: metrics describe process quality, not scientific validity. +Report includes reviewer metrics, warnings, queue counts, and event counts. Non-certification: metrics describe process quality, not scientific validity. See [quality_metrics.md](quality_metrics.md). ## Lab integration checklist -- [ ] Policy YAML reviewed and version-pinned +- [ ] Policy YAML reviewed and version-pinned (`scope-core-v0.7`) - [ ] Reviewer roles mapped to lab personnel -- [ ] Ed25519 keypairs generated per reviewer role +- [ ] Ed25519 keypairs generated per reviewer role; public keys registered - [ ] PF-Core runtime configured to enforce grant obligations - [ ] Ledger path configured (`SCOPE_LEDGER_PATH`) +- [ ] Ledger delivery mode set for institutional risk tolerance - [ ] Session store directory secured (if using json/sqlite) - [ ] Pilot limitations documented to participants ## Limitations -See [limitations.md](limitations.md). Pilots should not rely on SCOPE for IdP, RBAC, biosecurity clearance, or clinical oversight. +See [limitations.md](limitations.md). Pilots should not rely on SCOPE for full enterprise IdP, live directory sync, biosecurity clearance, or clinical oversight. ## Related docs - [reviewer_guide.md](reviewer_guide.md) — role-specific guidance - [trusted_boundary.md](trusted_boundary.md) — trust assumptions - [akta_scope_demo.md](akta_scope_demo.md) — full cross-repo demo +- [key_management.md](key_management.md) — key registry workflow diff --git a/docs/key_management.md b/docs/key_management.md index 4a97947..044c3e9 100644 --- a/docs/key_management.md +++ b/docs/key_management.md @@ -1,26 +1,30 @@ -# Key management (v0.6) +# Key Management (v0.7) SCOPE supports optional local binding between `reviewer_id` and Ed25519 public keys via `policy/reviewer_key_registry.yaml`. -## Signing providers (v0.6) +See also [signing_assurance.md](signing_assurance.md) and [trusted_boundary.md](trusted_boundary.md). -| Provider | CLI flag | Description | -|----------|----------|-------------| -| `local` | `--signing-key` / `--key` | Explicit Ed25519 private key PEM (default) | -| `env` | `--signing-provider env` | Reads `SCOPE_SIGNING_KEY` path | -| `registry` | `--signing-provider registry --reviewer-id X` | Pilot: `signing_key_path` in registry entry | +## Signing providers + +| Provider | CLI flag | SAL level | Description | +|----------|----------|-----------|-------------| +| `local` | `--signing-key` / `--key` | SAL1 | Explicit Ed25519 private key PEM (default) | +| `env` | `--signing-provider env` | SAL2 | Reads `SCOPE_SIGNING_KEY` path | +| `registry` | `--signing-provider registry --reviewer-id X` | SAL3 | Pilot: `signing_key_path` in registry entry | ```bash scope decision sign --decision d.json --signing-provider env --out signed.json -scope akta review ... --signing-provider registry --reviewer-id ds1 --signing-key ignored +scope akta review ... --signing-provider registry --reviewer-id ds1 ``` **Pilot only:** `signing_key_path` in `reviewer_key_registry.yaml` references a local private key for institutional pilots. Do not use in production; prefer HSM or env-scoped keys. +Production mode enforces minimum SAL per scope via `policy/minimum_signing_assurance.yaml` (default SAL1; high-risk scopes such as `robot_queue_submission` require SAL3). + ## Registry file ```yaml -version: scope-core-v0.5 +version: scope-core-v0.7 reviewers: protocol_owner_1: public_key_ref: sha256:... @@ -96,4 +100,4 @@ Use `scope_trust_root_hash` when a downstream system needs a single digest bindi - Treat the registry as institution-local policy, version-controlled separately from reviewer PEM files. - Rotate keys by registering a new public key ref, re-signing outstanding decisions if needed, and bumping registry version. -- Production deployments should store private keys in an HSM or institutional secret store; SCOPE v0.5 still signs locally via PEM paths only. +- Production deployments should store private keys in an HSM or institutional secret store. SCOPE v0.7 records signing assurance level (SAL) on provenance; wire external HSM/KMS via `HsmKmsSigningProvider` (SAL4 interface only). diff --git a/docs/limitations.md b/docs/limitations.md index 11f65d8..a7da8c3 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,5 +1,15 @@ # Limitations +## Implemented in v0.7 + +- Identity assurance levels (IAL0–IAL4) with provenance on decisions and grants +- Two-stage institutional RBAC then SCOPE scope policy authority checks +- Ledger delivery modes: best_effort, at_least_once (spool), fail_closed for high-risk events +- Review queue workflow state machine with explicit forbidden transitions +- Signing assurance levels (SAL0–SAL4) with minimum policy enforcement at grant issue +- Frozen AKTA review output contract (`summary.json` schema, adapter version constant) +- Policy bundle `scope-core-v0.7` + ## Implemented in v0.6 - OIDC/JWT identity verification with JWKS or static PEM (`scope identity verify-token`, `SCOPE_OIDC_ENABLED`) @@ -52,7 +62,7 @@ - No full enterprise IdP/SAML stack; OIDC/JWT RS256 foundation only - Registry `signing_key_path` is pilot-only; production HSM/KMS integration is operator-managed -- Remote ledger sink is best-effort; authoritative tamper evidence remains local JSONL chain +- Remote ledger sink supports delivery modes; authoritative tamper evidence remains local JSONL chain - Review queue dashboard is static HTML; no interactive workflow UI - RBAC is YAML-file based; no live directory sync - PF/PCS live repo validation still optional; mock validators used when sibling repos absent @@ -65,4 +75,4 @@ SCOPE does not replace AKTA, PF-Core, VSA, PCS, IRB, biosafety, EHS, or legal co ## Roadmap -See CHANGELOG for future plans: HSM-backed signing, authoritative remote ledger, interactive review UI, and enterprise directory RBAC sync. +See [CHANGELOG](../CHANGELOG.md) for release history and future plans: HSM-backed signing (SAL4 interface present), authoritative remote ledger, interactive review UI, and enterprise directory RBAC sync. diff --git a/docs/pcs_export.md b/docs/pcs_export.md index b77bcf8..54b356c 100644 --- a/docs/pcs_export.md +++ b/docs/pcs_export.md @@ -2,6 +2,8 @@ PCS packages SCOPE artifacts into release chains. +Manifest version: `pcs-v0.5`. Full field mapping: [external_integration_contracts.md](external_integration_contracts.md). + ## Export ```bash @@ -9,15 +11,33 @@ scope export pcs \ --packet packet.json \ --decision decision.json \ --grant grant.json \ - --out dist/pcs_scope_artifact/ + --out dist/pcs_scope_artifact/ \ + --validate +``` + +Optional live validation: + +```bash +export PCS_CORE_REPO_PATH=/path/to/pcs-core +scope export pcs --packet p.json --decision d.json --grant g.json \ + --out dist/pcs_scope_artifact/ --validate --live ``` +Sample layout: [examples/institutional_pilot/](../examples/institutional_pilot/) (run export to generate a full bundle). + ## Output layout - `scope_packet.json` - `scope_decision.json` - `scope_grant.json` - `pf_obligation.json` -- `release_manifest.json` (artifact hashes) +- `release_manifest.json` (artifact hashes, trust root, registry metadata) + +Manifest includes `scope_trust_root_hash`, `registry_version`, `registry_hash`, and optional signature fields from signed decisions. All artifacts are schema-valid and canonically hashable for release verification. + +## Related documentation + +- [pf_core_bridge.md](pf_core_bridge.md) — PF obligation export +- [key_management.md](key_management.md) — registry and trust root workflow diff --git a/docs/pf_core_bridge.md b/docs/pf_core_bridge.md index 5ba1e0a..9493f25 100644 --- a/docs/pf_core_bridge.md +++ b/docs/pf_core_bridge.md @@ -2,18 +2,36 @@ SCOPE grants export to PF-Core runtime obligations that specify permitted and blocked tools. +Contract version: `pf-core-v0.5`. Full field mapping: [external_integration_contracts.md](external_integration_contracts.md). + ## Export ```bash -scope export pf --grant grant.json --out pf_obligation.json +scope export pf --grant grant.json --out pf_obligation.json --validate +``` + +Optional live validation when a PF-Core checkout is configured: + +```bash +export PF_CORE_REPO_PATH=/path/to/pf-core +scope export pf --grant grant.json --out pf_obligation.json --validate --live ``` +Sample output shape: [adapters/pf_core/examples/pf_obligation.json](../adapters/pf_core/examples/pf_obligation.json) (regenerate with a live grant for current contract fields). + ## Obligation fields +- `obligation_version` — `pf-core-v0.5` - `permitted_tools` — tools runtime may invoke - `blocked_tools` — tools that must remain blocked - `approved_scope` — SCOPE approval scope - `constraints` — protocol version, single-use flags - `expiration` — invalidation triggers +- Signature fields — copied when present on signed grant PF-Core verifies that runtime traces respect these obligations. SCOPE defines permission; PF-Core verifies compliance. + +## Related documentation + +- [pcs_export.md](pcs_export.md) — PCS bundle includes `pf_obligation.json` +- [akta_scope_demo.md](akta_scope_demo.md) — end-to-end export demo diff --git a/docs/quality_metrics.md b/docs/quality_metrics.md index b1a2eb4..e7ea565 100644 --- a/docs/quality_metrics.md +++ b/docs/quality_metrics.md @@ -64,15 +64,28 @@ SCOPE measures whether review is meaningful through ledger-backed analytics. | `residual_block_violation` | Approval may not preserve AKTA blocks | | `stale_grant_attempt` | Grant used after expiration | | `scope_violation_attempt` | Tool outside grant scope | +| `ledger_delivery_failure` | Remote ledger delivery failed (v0.7) | -## Review queue (v0.5) +## Review queue (v0.7) | Metric | Description | |--------|-------------| -| `open_queue_count` | Queue entries in `open` or `assigned` status (file-backed `.scope/queues/`) | -| `overdue_queue_count` | Open/assigned entries past `due_at` SLA timestamp | +| `open_queue_count` | Queue entries in open statuses: `open`, `assigned`, `in_review`, `needs_information`, `escalated` | +| `overdue_queue_count` | Open-status entries past `due_at` SLA timestamp | +| `ledger_delivery_failure_count` | Failed remote ledger deliveries recorded on the engine | -Queue status lifecycle: `open` → `assigned` → `decided` → `granted` (or `closed`). +Ten-state lifecycle (explicit transitions only): + +``` +open → assigned → in_review → decided → granted → closed + ↓ ↓ + needs_information escalated + ↓ ↓ + expired (reopen → open) + cancelled / closed (terminal without grant) +``` + +Grants require `decided → granted`. Direct grant from `open`, `assigned`, `in_review`, `needs_information`, or `escalated` is forbidden. ## Generate report @@ -81,8 +94,7 @@ scope quality report --ledger logs/scope_events.jsonl --out report.json \ --queue-dir .scope/queues ``` -All metrics in `policy/quality_metrics.yaml` are implemented in v0.5 (`v0_4_status: implemented` for core metrics; `v0_5_status: implemented` for queue metrics). -Thresholds are configured in the same file. +All metrics in `policy/quality_metrics.yaml` are implemented in v0.7. Thresholds are configured in the same file. ## Ledger events diff --git a/docs/rbac_scope_authority.md b/docs/rbac_scope_authority.md new file mode 100644 index 0000000..6b1dcd5 --- /dev/null +++ b/docs/rbac_scope_authority.md @@ -0,0 +1,61 @@ +# RBAC vs SCOPE Authority Separation + +SCOPE v0.7 enforces two-stage authority before accepting decisions. + +## Stage 1: Institutional RBAC + +Maps `reviewer_id` to effective institutional roles from `policy/org_rbac.yaml`: + +- Direct membership in `org_units` +- Active delegations (with expiry check) +- RBAC action permissions (`can_submit_decisions`, `can_vote_in_session`) + +Institutional RBAC is separate from SCOPE scope permissions. + +## Stage 2: SCOPE Policy + +Maps effective role to scientific action and approval scope via `policy/reviewer_roles.yaml`: + +- `role_to_action_matrix.yaml` — which roles may act on action types +- `can_approve_scopes` — which scopes each SCOPE role may approve + +A reviewer may hold an institutional role that does not permit a given SCOPE scope even when RBAC grants directory membership. + +## Examples + +- `domain_scientist` in org directory cannot approve `robot_queue_submission` when SCOPE role policy forbids it. +- `protocol_owner` cannot approve `robot_queue_submission` unless listed in `can_approve_scopes`. +- Expired delegation invalidates institutional authority for the delegated role. + +## Enforcement + +Implemented in `scope/authority.py` and invoked from `ScopeEngine.submit_decision` and session votes when: + +- `SCOPE_ENFORCE_RBAC=1`, or +- Identity assurance reaches IAL3/IAL4 (institutional OIDC path) + +Identity assurance provenance records which stage resolved the role (`role_resolution_source`). + +## authority_checks provenance + +Every decision and grant records explicit two-stage authority outcomes: + +```json +{ + "authority_checks": { + "rbac_enforced": true, + "rbac_role_valid": true, + "scope_role_valid": true, + "scope_approval_valid": true, + "delegation_id": null + } +} +``` + +When RBAC is disabled or identity is below IAL3, `rbac_enforced` and `rbac_role_valid` are `false` so auditors can distinguish skipped checks from passed checks. Active delegations populate `delegation_id` in both identity provenance and `authority_checks`. + +## Related documentation + +- [identity_assurance.md](identity_assurance.md) — IAL levels and provenance fields +- [limitations.md](limitations.md) — RBAC scope and external IdP boundaries +- [institutional_pilot_guide.md](institutional_pilot_guide.md) — pilot RBAC configuration diff --git a/docs/review_doctrine.md b/docs/review_doctrine.md index 573dc35..814acc5 100644 --- a/docs/review_doctrine.md +++ b/docs/review_doctrine.md @@ -10,6 +10,8 @@ 6. **Human judgment must become machine-readable** — runtime can enforce decisions; release systems can audit them. 7. **SCOPE does not certify safety** — grants authorize scoped transitions, not scientific correctness. +See [scoped_scientific_authorization.md](scoped_scientific_authorization.md) and [limitations.md](limitations.md). + ## Packet design Packets must not ask "Do you approve this?" They ask whether the reviewer approves a specific action under a specific scope with named blocked tools and expiration conditions. diff --git a/docs/reviewer_guide.md b/docs/reviewer_guide.md index 5097fd0..39fa107 100644 --- a/docs/reviewer_guide.md +++ b/docs/reviewer_guide.md @@ -16,6 +16,19 @@ A SCOPE Packet includes: Rendered packets include explicit **Approval Permits** and **Approval Does NOT Permit** sections derived from policy. This display is for review coordination only and does not constitute certification or institutional approval. +## Identity and signing (v0.7) + +Your decision records how your identity was verified: + +| Level | Meaning | +|-------|---------| +| IAL0 | Caller-supplied reviewer JSON only | +| IAL1+ | Signed decision or OIDC-verified identity | + +In production mode, sign your decision before grant issue. Signing assurance level (SAL) is recorded on the grant. Institutions may require OIDC tokens (`--identity-token`) and RBAC enforcement (`--enforce-rbac`). + +See [identity_assurance.md](identity_assurance.md) and [signing_assurance.md](signing_assurance.md). + ## Reviewer roles ### protocol_owner @@ -73,4 +86,10 @@ Use `--replace-vote` to supersede a prior vote from the same reviewer. In production mode, submit your decision first, then sign with your Ed25519 private key before grant issue. Your `reviewer_public_key_ref` in the reviewer fixture must match the signing key (or be attached at sign time). Auditors verify with your public key only. -Institutions may configure `policy/reviewer_key_registry.yaml` to bind reviewer IDs to expected public keys. +Institutions may configure `policy/reviewer_key_registry.yaml` to bind reviewer IDs to expected public keys. See [key_management.md](key_management.md). + +## Related documentation + +- [institutional_pilot_guide.md](institutional_pilot_guide.md) — pilot workshop flow +- [evidence_vocab_mapping.md](evidence_vocab_mapping.md) — evidence state aliases +- [quality_metrics.md](quality_metrics.md) — how review quality is measured diff --git a/docs/scoped_scientific_authorization.md b/docs/scoped_scientific_authorization.md index c9d9f2b..0d30b27 100644 --- a/docs/scoped_scientific_authorization.md +++ b/docs/scoped_scientific_authorization.md @@ -1,22 +1,47 @@ # Scoped Scientific Authorization -SCOPE turns AKTA `review_required` and `authorization_required` outputs into structured scientific review packets, typed reviewer assignments, scoped decisions, expiring grants, runtime obligations, and audit-ready records. +SCOPE (v0.7.0) turns AKTA `review_required` and `authorization_required` outputs into structured scientific review packets, typed reviewer assignments, scoped decisions, expiring grants, runtime obligations, and audit-ready records. ## Core doctrine Review is a role, artifact, scope, expiration, and accountability trail. +See [review_doctrine.md](review_doctrine.md) and [field_thesis.md](field_thesis.md). + ## Artifacts 1. **SCOPE Packet** — artifacts a reviewer must inspect -2. **SCOPE Decision** — structured reviewer response -3. **SCOPE Grant** — machine-readable authorization for runtime -4. **SCOPE Ledger** — hash-chained audit trail +2. **SCOPE Decision** — structured reviewer response with identity and authority provenance +3. **SCOPE Grant** — machine-readable authorization for runtime with signing assurance +4. **SCOPE Ledger** — hash-chained audit trail with configurable delivery modes 5. **SCOPE Quality Report** — review quality metrics and warnings +## Assurance models (v0.7) + +| Model | Levels | Purpose | +|-------|--------|---------| +| Identity assurance (IAL) | IAL0–IAL4 | How reviewer identity was established | +| Signing assurance (SAL) | SAL0–SAL4 | How decisions and grants were signed | +| Authority checks | Two-stage | Org RBAC then SCOPE scope policy separation | + +Details: [identity_assurance.md](identity_assurance.md), [signing_assurance.md](signing_assurance.md), [rbac_scope_authority.md](rbac_scope_authority.md). + ## Ecosystem role - **AKTA** classifies scientific action admissibility - **SCOPE** reviews and authorizes within scope -- **PF-Core** verifies runtime compliance -- **PCS** packages release artifacts +- **PF-Core** verifies runtime compliance (`pf-core-v0.5` obligations) +- **PCS** packages release artifacts (`pcs-v0.5` manifests) + +Primary AKTA path: `scope akta review` — see [akta_review_contract.md](akta_review_contract.md). + +## Documentation index + +| Topic | Document | +|-------|----------| +| Pilot onboarding | [institutional_pilot_guide.md](institutional_pilot_guide.md) | +| Trust assumptions | [trusted_boundary.md](trusted_boundary.md) | +| Limitations | [limitations.md](limitations.md) | +| Integration contracts | [external_integration_contracts.md](external_integration_contracts.md) | +| Reviewer guidance | [reviewer_guide.md](reviewer_guide.md) | +| Threat model | [threat_model.md](threat_model.md) | diff --git a/docs/signing_assurance.md b/docs/signing_assurance.md new file mode 100644 index 0000000..e665005 --- /dev/null +++ b/docs/signing_assurance.md @@ -0,0 +1,38 @@ +# Signing Assurance (SAL) + +SCOPE v0.7 records signing assurance levels on decision and grant provenance. + +## Levels + +| Level | Meaning | +|-------|---------| +| SAL0 | Unsigned artifact | +| SAL1 | Local PEM (`LocalPemProvider`) with valid Ed25519 signature | +| SAL2 | `EnvKeyProvider` (environment key path) | +| SAL3 | `RegistryKeyProvider` with reviewer_id binding | +| SAL4 | External HSM/KMS (interface only; no in-repo implementation) | + +## Policy + +`policy/minimum_signing_assurance.yaml` defines production minimums: + +- Default minimum: SAL1 +- High-risk scopes (e.g. `robot_queue_submission`): SAL3 + +Production mode enforces minimum SAL at grant issue. + +## Operational risk + +`EnvKeyProvider` emits CLI and log warnings about environment key path exposure. Prefer registry-bound (SAL3) or HSM/KMS (SAL4) for production. + +## SAL4 external boundary + +`scope.signing_assurance.HsmKmsSigningProvider` documents the institutional integration point. Configure vendor SDKs outside SCOPE; set `SCOPE_HSM_ENDPOINT` when wiring external signers. + +Schema provenance field: `signing_assurance_level`. + +## Related documentation + +- [key_management.md](key_management.md) — signing providers and key registry +- [trusted_boundary.md](trusted_boundary.md) — production mode enforcement +- [akta_review_contract.md](akta_review_contract.md) — SAL recorded in `summary.json` diff --git a/docs/threat_model.md b/docs/threat_model.md index 484dc18..d8f94f9 100644 --- a/docs/threat_model.md +++ b/docs/threat_model.md @@ -1,19 +1,29 @@ # Threat Model -## v0.1 threats addressed +## Threats addressed (v0.7) -- Schema validation failures -- Decision tampering (canonical hashes) -- Grant broadening (scope hierarchy enforcement) -- Stale approval reuse (expiration checks) +- Schema validation failures on AKTA imports, packets, decisions, grants, and session artifacts +- Decision tampering (canonical hashes; optional Ed25519 signatures in production mode) +- Grant broadening (scope hierarchy enforcement; overbroad approval rejection) +- Stale approval reuse (expiration checks on protocol version, evidence state, domain overlay, and more) - Unknown scope / invalid role (fail closed) -- Ledger deletion (hash chain verification) +- Ledger deletion or tampering (local hash chain verification) +- Fake reviewer identity (partial mitigation via IAL/OIDC/RBAC at IAL3–IAL4; registry key binding at SAL3) +- Unsigned production grants (production mode requires signed decisions; minimum SAL enforced) +- Remote ledger loss (delivery modes: best_effort, at_least_once spool, fail_closed for high-risk grants) +- Queue workflow bypass (explicit 10-state transitions; forbidden direct grant paths) -## v0.1 threats not fully addressed +## Threats partially addressed -- Fake reviewer identity (trusted caller assumed) -- Digital signatures and external ledger -- Collusion and institutional pressure -- Artifact substitution without hash verification +- Collusion and institutional pressure (quality metrics detect rubber-stamping; not prevented) +- Artifact substitution without hash verification (packets should use hash-addressed refs; operator responsibility) +- HSM/KMS key exfiltration (SAL4 interface documented; operator-managed external integration) + +## Threats not fully addressed + +- Reviewer competence, honesty, or scientific judgment +- Domain safety, legal compliance, or physical lab safety +- Live directory sync for enterprise RBAC (YAML-file based only) +- Authoritative remote ledger (local JSONL chain remains source of tamper evidence) See [trusted_boundary.md](trusted_boundary.md) and [limitations.md](limitations.md). diff --git a/docs/trusted_boundary.md b/docs/trusted_boundary.md index 82c6279..0d7b6f3 100644 --- a/docs/trusted_boundary.md +++ b/docs/trusted_boundary.md @@ -1,16 +1,17 @@ # Trusted Boundary -SCOPE v0.3 assumes: +SCOPE v0.7 assumes: -- Reviewer identity is provided by a trusted caller or configured registry -- Reviewer role assignments are correctly configured in policy YAML +- Reviewer identity is provided by a trusted caller, OIDC/JWT verification, or configured registry +- Reviewer role assignments are correctly configured in policy YAML and, when enabled, `org_rbac.yaml` - Artifacts in packets are authentic or hash-addressed - Runtime systems enforce grants or pass them to PF-Core - AKTA Records are valid and schema-checked -- When `SCOPE_PRODUCTION_MODE` is enabled, signing keys are held by authorized reviewers +- When `SCOPE_PRODUCTION_MODE` is enabled, signing keys are held by authorized reviewers and meet minimum signing assurance (SAL) for the approved scope - Session stores (JSON/SQLite) are protected at the filesystem level +- Ledger delivery mode matches institutional risk tolerance (`SCOPE_LEDGER_DELIVERY_MODE`) -SCOPE v0.3 does not guarantee reviewer competence, honesty, scientific truth, domain safety, legal compliance, or physical lab safety. +SCOPE v0.7 does not guarantee reviewer competence, honesty, scientific truth, domain safety, legal compliance, or physical lab safety. ## Production mode and signing @@ -20,7 +21,7 @@ Set `SCOPE_PRODUCTION_MODE=true` (or `1`, `yes`, `production`) to enforce fail-c |-------|-------------| | Decision submit | Signature optional; unsigned decisions marked `signature_required=true` | | Decision validate | Use `--require-signature` to enforce signature presence | -| Grant issue | Source decision must carry a valid `decision_signature` | +| Grant issue | Source decision must carry a valid `decision_signature`; minimum SAL enforced per scope | | Grant sign | Optional; copies signature metadata from decision when present | | Verify | Use `--public-key` for verification without private key access | @@ -36,8 +37,39 @@ scope verify --artifact signed_decision.json --public-key reviewer.pub --type de Grant issue validates the **decision** signature, not the grant signature. Grant signing is a separate step for downstream verification, PF/PCS export, and institutional audit trails. +Signing assurance levels (SAL0–SAL4) are recorded on grant provenance. See [signing_assurance.md](signing_assurance.md) and [key_management.md](key_management.md). + REST sign/verify endpoints accept `SCOPE_SIGNING_KEY` (private) or `public_key_path` in verify requests. +## Identity assurance (IAL) + +Identity assurance levels (IAL0–IAL4) record how reviewer identity was established. Caller JSON alone is IAL0; OIDC plus org RBAC can reach IAL3/IAL4. Two-stage authority checks (`authority_checks` provenance) distinguish skipped RBAC from failed checks. + +| Variable | Purpose | +|----------|---------| +| `SCOPE_OIDC_ENABLED` | Enable REST middleware identity binding | +| `SCOPE_OIDC_JWKS_URL` | JWKS endpoint for RS256 verification | +| `SCOPE_OIDC_ISSUER` | Expected JWT `iss` claim | +| `SCOPE_OIDC_AUDIENCE` | Expected JWT `aud` claim | +| `SCOPE_OIDC_PUBLIC_KEY_PEM` | Static PEM alternative to JWKS | +| `SCOPE_ENFORCE_RBAC` | Require org RBAC on decision submit (also triggered at IAL3/IAL4) | + +Claim mapping is configured in `policy/identity_mapping.yaml`. CLI: `scope identity verify-token --token ...` + +See [identity_assurance.md](identity_assurance.md) and [rbac_scope_authority.md](rbac_scope_authority.md). + +## Ledger delivery + +Remote ledger append supports three delivery modes via `SCOPE_LEDGER_DELIVERY_MODE`: + +| Mode | Behavior | +|------|----------| +| `best_effort` | Append to remote sink without blocking grant issue (default) | +| `at_least_once` | Spool failed remote deliveries for retry | +| `fail_closed` | Block high-risk grant issuance when remote delivery fails | + +Authoritative tamper evidence remains the local hash-chained JSONL ledger. See [limitations.md](limitations.md). + ## Multi-reviewer sessions Review sessions collect votes from required roles before grant issuance. Safety officers listed in `safety_veto_roles` may submit a `reject` decision even when not in the action's required role list; a safety veto blocks grant issuance regardless of approving votes. @@ -45,7 +77,7 @@ Review sessions collect votes from required roles before grant issuance. Safety Session persistence is configurable: | Backend | CLI | Environment | -|---------|-----|---------------| +|---------|-----|-------------| | memory | `--session-store memory` (default) | `SCOPE_SESSION_STORE=memory` | | JSON files | `--session-store json --session-dir .scope/sessions` | `SCOPE_SESSION_STORE=json` | | SQLite | `--session-store sqlite --session-dir .scope/sessions.db` | `SCOPE_SESSION_STORE=sqlite` | @@ -54,23 +86,9 @@ Duplicate votes from the same reviewer are rejected. Votes are recorded in both ## Policy version -Active policy is tagged `scope-core-v0.6`. Grants record `provenance.scope_policy_version`; runtime context may include matching `scope_policy_version` for expiration checks. - -## Identity (v0.6) - -Optional OIDC/JWT verification binds bearer tokens to reviewer identity: - -| Variable | Purpose | -|----------|---------| -| `SCOPE_OIDC_ENABLED` | Enable REST middleware identity binding | -| `SCOPE_OIDC_JWKS_URL` | JWKS endpoint for RS256 verification | -| `SCOPE_OIDC_ISSUER` | Expected JWT `iss` claim | -| `SCOPE_OIDC_AUDIENCE` | Expected JWT `aud` claim | -| `SCOPE_OIDC_PUBLIC_KEY_PEM` | Static PEM alternative to JWKS | - -Claim mapping is configured in `policy/identity_mapping.yaml`. CLI: `scope identity verify-token --token ...` +Active policy is tagged `scope-core-v0.7`. Grants record `provenance.scope_policy_version`; runtime context may include matching `scope_policy_version` for expiration checks. -## Trust root (v0.6) +## Trust root Signed decisions and grants carry provenance hashes: diff --git a/evals/run_review_cases.py b/evals/run_review_cases.py index 220039b..808ca0f 100644 --- a/evals/run_review_cases.py +++ b/evals/run_review_cases.py @@ -1,4 +1,4 @@ -"""Evaluation scenario runner for SCOPE v0.6.""" +"""Evaluation scenario runner for SCOPE v0.7.""" from __future__ import annotations @@ -16,13 +16,24 @@ sys.path.insert(0, str(ROOT)) from scope import ScopeEngine -from scope.errors import DecisionValidationError, RoleValidationError, ScopeValidationError +from scope.errors import ( + DecisionValidationError, + LedgerError, + RoleValidationError, + ScopeValidationError, +) +from scope.review_workflow import validate_transition from scope.signing import Ed25519Signer from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair POLICY = ROOT / "policy" SCENARIOS_DIR = ROOT / "evals" / "scenarios" EXTENDED_DIR = SCENARIOS_DIR / "extended" +LEDGER_ENV_KEYS = ( + "SCOPE_LEDGER_DELIVERY_MODE", + "SCOPE_LEDGER_REMOTE_URL", + "SCOPE_LEDGER_REMOTE_TOKEN", +) @dataclass @@ -32,6 +43,125 @@ class ScenarioResult: message: str +def _save_ledger_env() -> dict[str, str | None]: + return {key: os.environ.get(key) for key in LEDGER_ENV_KEYS} + + +def _restore_ledger_env(saved: dict[str, str | None]) -> None: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _apply_ledger_env(scenario: dict[str, Any]) -> None: + if scenario.get("ledger_delivery_mode"): + os.environ["SCOPE_LEDGER_DELIVERY_MODE"] = str(scenario["ledger_delivery_mode"]) + if scenario.get("ledger_remote_url"): + os.environ["SCOPE_LEDGER_REMOTE_URL"] = str(scenario["ledger_remote_url"]) + + +def _run_queue_transition_scenario(scenario: dict[str, Any]) -> ScenarioResult: + name = scenario["name"] + trans = scenario["expect_queue_transition_error"] + try: + validate_transition(trans["from"], trans["to"]) + return ScenarioResult( + name, + False, + f"Expected transition error for {trans['from']} -> {trans['to']}", + ) + except ScopeValidationError as exc: + return ScenarioResult(name, True, f"Correctly rejected: {exc}") + + +def _assert_identity_provenance( + scenario: dict[str, Any], + decision: dict[str, Any], + grant: dict[str, Any] | None = None, +) -> ScenarioResult | None: + name = scenario["name"] + expected_ial = scenario.get("expect_identity_assurance_level") + if expected_ial: + actual = (decision.get("provenance") or {}).get("identity_assurance_level") + if actual != expected_ial: + return ScenarioResult( + name, + False, + f"Decision IAL {actual} != {expected_ial}", + ) + if grant is not None: + grant_ial = (grant.get("provenance") or {}).get("identity_assurance_level") + if grant_ial != expected_ial: + return ScenarioResult( + name, + False, + f"Grant IAL {grant_ial} != {expected_ial}", + ) + expected_source = scenario.get("expect_role_resolution_source") + if expected_source: + actual = (decision.get("provenance") or {}).get("role_resolution_source") + if actual != expected_source: + return ScenarioResult( + name, + False, + f"Decision role_resolution_source {actual} != {expected_source}", + ) + if grant is not None: + grant_source = (grant.get("provenance") or {}).get("role_resolution_source") + if grant_source != expected_source: + return ScenarioResult( + name, + False, + f"Grant role_resolution_source {grant_source} != {expected_source}", + ) + + expected_identity_source = scenario.get("expect_identity_source") + if expected_identity_source: + actual = (decision.get("provenance") or {}).get("identity_source") + if actual != expected_identity_source: + return ScenarioResult( + name, + False, + f"Decision identity_source {actual} != {expected_identity_source}", + ) + if grant is not None: + grant_source = (grant.get("provenance") or {}).get("identity_source") + if grant_source != expected_identity_source: + return ScenarioResult( + name, + False, + f"Grant identity_source {grant_source} != {expected_identity_source}", + ) + + if grant is not None and scenario.get("expect_authority_checks", True): + decision_checks = (decision.get("provenance") or {}).get("authority_checks") + grant_checks = (grant.get("provenance") or {}).get("authority_checks") + if not decision_checks: + return ScenarioResult(name, False, "Decision missing authority_checks provenance") + if not grant_checks: + return ScenarioResult(name, False, "Grant missing authority_checks provenance") + if grant_checks != decision_checks: + return ScenarioResult( + name, + False, + "Grant authority_checks != decision authority_checks", + ) + + expected_sal = scenario.get("expect_signing_assurance_level") + if expected_sal and grant is not None: + actual_sal = (grant.get("provenance") or {}).get("signing_assurance_level") + if actual_sal != expected_sal: + return ScenarioResult( + name, + False, + f"Grant SAL {actual_sal} != {expected_sal}", + ) + + return None + + def _load(name: str, *, extended: bool = False) -> dict[str, Any]: base = EXTENDED_DIR if extended else SCENARIOS_DIR path = base / name @@ -77,9 +207,83 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult: else: os.environ.pop("SCOPE_PRODUCTION_MODE", None) - engine = ScopeEngine.from_policy_dir(POLICY) + if scenario.get("expect_queue_transition_error"): + return _run_queue_transition_scenario(scenario) + + saved_ledger_env = _save_ledger_env() + try: + defer_ledger = scenario.get("defer_ledger_env_until_grant") + if not defer_ledger: + _apply_ledger_env(scenario) + with tempfile.TemporaryDirectory() as ledger_tmp: + ledger_path = None + if scenario.get("ledger_delivery_mode"): + ledger_path = Path(ledger_tmp) / "events.jsonl" + engine = ScopeEngine.from_policy_dir(POLICY, ledger_path=ledger_path) + return _run_scenario_with_engine(engine, scenario, defer_ledger_env=defer_ledger) + finally: + _restore_ledger_env(saved_ledger_env) + + +def _run_akta_review_scenario( + engine: ScopeEngine, + scenario: dict[str, Any], +) -> ScenarioResult: + from scope.akta_review import run_akta_review + from scope.schema_util import validate_artifact + + name = scenario["name"] + with tempfile.TemporaryDirectory() as out_dir: + signing_key: Path | None = None + if scenario.get("sign_before_grant"): + key = Path(out_dir) / "reviewer.pem" + pub = Path(out_dir) / "reviewer.pub" + Ed25519Signer.generate_keypair(key, pub) + signing_key = key + summary = run_akta_review( + engine, + akta_record=scenario["akta_record"], + akta_trigger=scenario["akta_trigger"], + grant_scope=scenario.get("grant_scope", scenario["decision"]["approved_scope"]), + reviewer=scenario["reviewer"], + decision_rationale=scenario["decision"]["rationale"], + out_dir=out_dir, + signing_key=signing_key, + ) + + validate_artifact(summary, "scope_akta_review_summary.schema.json") + expected_sal = scenario.get("expect_signing_assurance_level") + if expected_sal and summary.get("signing_assurance_level") != expected_sal: + return ScenarioResult( + name, + False, + f"Summary SAL {summary.get('signing_assurance_level')} != {expected_sal}", + ) + expected_ial = scenario.get("expect_identity_assurance_level") + if expected_ial and summary.get("identity_assurance_level") != expected_ial: + return ScenarioResult( + name, + False, + f"Summary IAL {summary.get('identity_assurance_level')} != {expected_ial}", + ) + for field in ("packet_path", "decision_path", "grant_path"): + path = Path(summary[field]) + if not path.is_file(): + return ScenarioResult(name, False, f"Missing artifact: {field}") + return ScenarioResult(name, True, "AKTA review contract OK") + + +def _run_scenario_with_engine( + engine: ScopeEngine, + scenario: dict[str, Any], + *, + defer_ledger_env: bool = False, +) -> ScenarioResult: name = scenario["name"] try: + if scenario.get("run_akta_review"): + return _run_akta_review_scenario(engine, scenario) + vsa_path = scenario.get("vsa_report_path") vsa_report = ROOT / vsa_path if vsa_path else None packet = engine.create_packet( @@ -142,13 +346,22 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult: "exp": int(time.time()) + 3600, }, ) + if scenario.get("enforce_rbac"): + os.environ["SCOPE_ENFORCE_RBAC"] = "true" decision = engine.submit_decision( packet, scenario["reviewer"], scenario["decision"], identity_token=token, + enforce_rbac=scenario.get("enforce_rbac"), ) + identity_check = _assert_identity_provenance(scenario, decision) + if identity_check is not None: + return identity_check grant = engine.issue_grant(packet, decision) + identity_check = _assert_identity_provenance(scenario, decision, grant) + if identity_check is not None: + return identity_check approved = grant["authorization"]["approved_scope"] if scenario.get("expected_scope") and approved != scenario["expected_scope"]: return ScenarioResult( @@ -183,7 +396,29 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult: Ed25519Signer.generate_keypair(key, pub) decision = engine.sign_decision(decision, Ed25519Signer(key)) + if scenario.get("expect_grant_error"): + if defer_ledger_env: + from scope.ledger import ScopeLedger + + _apply_ledger_env(scenario) + assert engine.ledger.path is not None + engine.ledger = ScopeLedger(engine.ledger.path) + try: + engine.issue_grant(packet, decision) + return ScenarioResult(name, False, "Expected grant error but succeeded") + except LedgerError as exc: + if "grant_issued" not in str(exc) and defer_ledger_env: + return ScenarioResult( + name, + False, + f"Expected grant_issued fail_closed error, got: {exc}", + ) + return ScenarioResult(name, True, f"Correctly rejected: {exc}") + grant = engine.issue_grant(packet, decision) + identity_check = _assert_identity_provenance(scenario, decision, grant) + if identity_check is not None: + return identity_check violation = scenario.get("record_runtime_violation") if violation: @@ -230,6 +465,8 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult: except Exception as exc: if scenario.get("expect_decision_error"): return ScenarioResult(name, True, f"Correctly rejected: {exc}") + if scenario.get("expect_grant_error") and isinstance(exc, LedgerError): + return ScenarioResult(name, True, f"Correctly rejected: {exc}") return ScenarioResult(name, False, str(exc)) @@ -253,6 +490,11 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult: "queue_auto_assign.json", "runtime_violation_outcome_metric.json", "biosecurity_mandatory_session.json", + "identity_assurance_caller_ial0.json", + "identity_assurance_local_signed_ial1.json", + "queue_invalid_transition.json", + "fail_closed_grant_blocked.json", + "akta_review_signed_summary.json", ] @@ -273,7 +515,7 @@ def main() -> int: parser.add_argument( "--extended", action="store_true", - help="Also run extended v0.5 scenarios (multi-review, signing, VSA, overlay)", + help="Also run extended v0.7 scenarios (IAL, SAL, queue, ledger, AKTA contract)", ) args = parser.parse_args() diff --git a/evals/scenarios/extended/akta_review_signed_summary.json b/evals/scenarios/extended/akta_review_signed_summary.json new file mode 100644 index 0000000..f395dd6 --- /dev/null +++ b/evals/scenarios/extended/akta_review_signed_summary.json @@ -0,0 +1,25 @@ +{ + "name": "akta_review_signed_summary", + "run_akta_review": true, + "akta_record": { + "record_id": "AKTA-EVAL-SUMMARY", + "scientific_action_type": "A5_protocol_modification" + }, + "akta_trigger": { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft" + }, + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Signed AKTA review summary contract validation" + }, + "grant_scope": "protocol_draft", + "sign_before_grant": true, + "expect_identity_assurance_level": "IAL1", + "expect_signing_assurance_level": "SAL1" +} diff --git a/evals/scenarios/extended/fail_closed_grant_blocked.json b/evals/scenarios/extended/fail_closed_grant_blocked.json new file mode 100644 index 0000000..cabccd8 --- /dev/null +++ b/evals/scenarios/extended/fail_closed_grant_blocked.json @@ -0,0 +1,24 @@ +{ + "name": "fail_closed_blocks_grant_ledger", + "akta_record": { + "record_id": "AKTA-EVAL-FAILCLOSED", + "scientific_action_type": "A5_protocol_modification" + }, + "akta_trigger": { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft" + }, + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Grant must fail when remote ledger unavailable in fail_closed mode" + }, + "ledger_delivery_mode": "fail_closed", + "ledger_remote_url": "http://127.0.0.1:1/unreachable", + "defer_ledger_env_until_grant": true, + "expect_grant_error": true +} diff --git a/evals/scenarios/extended/identity_assurance_caller_ial0.json b/evals/scenarios/extended/identity_assurance_caller_ial0.json new file mode 100644 index 0000000..7c5a8db --- /dev/null +++ b/evals/scenarios/extended/identity_assurance_caller_ial0.json @@ -0,0 +1,24 @@ +{ + "name": "identity_assurance_caller_ial0", + "akta_record": { + "record_id": "AKTA-EVAL-IAL0", + "scientific_action_type": "A5_protocol_modification" + }, + "akta_trigger": { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft" + }, + "reviewer": {"reviewer_id": "caller_only", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Caller-supplied JSON must record IAL0 provenance" + }, + "expected_scope": "protocol_draft", + "expect_identity_assurance_level": "IAL0", + "expect_role_resolution_source": "caller_supplied", + "expect_identity_source": "caller_json" +} diff --git a/evals/scenarios/extended/identity_assurance_local_signed_ial1.json b/evals/scenarios/extended/identity_assurance_local_signed_ial1.json new file mode 100644 index 0000000..bdbb4f1 --- /dev/null +++ b/evals/scenarios/extended/identity_assurance_local_signed_ial1.json @@ -0,0 +1,25 @@ +{ + "name": "identity_assurance_local_signed_ial1", + "akta_record": { + "record_id": "AKTA-EVAL-IAL1", + "scientific_action_type": "A5_protocol_modification" + }, + "akta_trigger": { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft" + }, + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Local signed key must record IAL1 provenance" + }, + "expected_scope": "protocol_draft", + "sign_before_grant": true, + "expect_identity_assurance_level": "IAL1", + "expect_role_resolution_source": "local_signed_key", + "expect_identity_source": "local_signed_key" +} diff --git a/evals/scenarios/extended/oidc_identity_mock_path.json b/evals/scenarios/extended/oidc_identity_mock_path.json index 5306b80..2ff6bb0 100644 --- a/evals/scenarios/extended/oidc_identity_mock_path.json +++ b/evals/scenarios/extended/oidc_identity_mock_path.json @@ -2,23 +2,27 @@ "name": "oidc_identity_mock_path", "akta_record": { "record_id": "AKTA-EVAL-OIDC", - "scientific_action_type": "A5_protocol_modification" + "scientific_action_type": "A4_recommendation" }, "akta_trigger": { "akta_admissibility": "review_required", - "scientific_action_type": "A5_protocol_modification", - "requested_action": "draft_protocol", - "requested_tool": "protocol_editor.draft_change", - "requested_scope": "protocol_draft" + "scientific_action_type": "A4_recommendation", + "requested_action": "draft_recommendation", + "requested_tool": "recommendation.draft", + "requested_scope": "draft_recommendation" }, "reviewer": {"reviewer_id": "legacy-id", "role": "wrong_role"}, "decision": { - "type": "approve_narrower_scope", - "approved_scope": "protocol_draft", - "rationale": "OIDC identity override test" + "type": "approve", + "approved_scope": "draft_recommendation", + "rationale": "OIDC identity override with org RBAC (IAL3)" }, - "expected_scope": "protocol_draft", + "expected_scope": "draft_recommendation", "use_oidc_identity": true, - "oidc_sub": "po1", - "oidc_role": "protocol_owner" + "oidc_sub": "ds1", + "oidc_role": "domain_scientist", + "enforce_rbac": true, + "expect_identity_assurance_level": "IAL3", + "expect_role_resolution_source": "org_rbac", + "expect_identity_source": "oidc_jwt" } diff --git a/evals/scenarios/extended/production_signing_sequence.json b/evals/scenarios/extended/production_signing_sequence.json index 2f48fbc..a568442 100644 --- a/evals/scenarios/extended/production_signing_sequence.json +++ b/evals/scenarios/extended/production_signing_sequence.json @@ -19,5 +19,6 @@ "rationale": "signed approval" }, "expected_scope": "protocol_draft", - "sign_before_grant": true + "sign_before_grant": true, + "expect_signing_assurance_level": "SAL1" } diff --git a/evals/scenarios/extended/queue_invalid_transition.json b/evals/scenarios/extended/queue_invalid_transition.json new file mode 100644 index 0000000..278875d --- /dev/null +++ b/evals/scenarios/extended/queue_invalid_transition.json @@ -0,0 +1,7 @@ +{ + "name": "queue_invalid_transition_blocked", + "expect_queue_transition_error": { + "from": "open", + "to": "granted" + } +} diff --git a/examples/decision_ds.json b/examples/decision_ds.json new file mode 100644 index 0000000..3c92447 --- /dev/null +++ b/examples/decision_ds.json @@ -0,0 +1,6 @@ +{ + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Co-review confirms draft-only scope is appropriate for current evidence.", + "reviewer_confidence": 0.75 +} diff --git a/examples/decision_po.json b/examples/decision_po.json new file mode 100644 index 0000000..0aa9e77 --- /dev/null +++ b/examples/decision_po.json @@ -0,0 +1,15 @@ +{ + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "Evidence supports draft-only validation; active protocol update not justified.", + "rejected_scope": [ + "active_protocol_update", + "robot_queue_submission", + "queue_prioritization" + ], + "required_modifications": [ + "restore original acceptance threshold", + "label output as validation-only" + ], + "reviewer_confidence": 0.78 +} diff --git a/examples/institutional_pilot/README.md b/examples/institutional_pilot/README.md index 390bdbc..01fb08c 100644 --- a/examples/institutional_pilot/README.md +++ b/examples/institutional_pilot/README.md @@ -1,6 +1,6 @@ # Institutional Pilot Examples -Sample artifacts for SCOPE v0.5.1 pilot workshops. See docs/institutional_pilot_guide.md. +Sample artifacts for SCOPE v0.7 institutional pilot workshops. See [docs/institutional_pilot_guide.md](../../docs/institutional_pilot_guide.md). | File | Description | |------|-------------| @@ -14,3 +14,16 @@ Sample artifacts for SCOPE v0.5.1 pilot workshops. See docs/institutional_pilot_ | reviewer_protocol_owner.json | Reviewer identity fixture | | decision.json | Decision input fixture | | current_context.json | Runtime context for grant check | + +Artifacts use policy bundle `scope-core-v0.7`. In production mode, sign decisions before grant issue; see [docs/trusted_boundary.md](../../docs/trusted_boundary.md). + +To regenerate PCS export from these artifacts: + +```bash +scope export pcs \ + --packet examples/institutional_pilot/scope_packet.json \ + --decision examples/institutional_pilot/scope_decision.json \ + --grant examples/institutional_pilot/scope_grant.json \ + --out dist/pcs_scope_artifact/ \ + --validate +``` diff --git a/examples/institutional_pilot/current_context.json b/examples/institutional_pilot/current_context.json index bba5bb1..b5958b9 100644 --- a/examples/institutional_pilot/current_context.json +++ b/examples/institutional_pilot/current_context.json @@ -2,5 +2,5 @@ "protocol_version": "protocol_v3", "evidence_state": "E2_preliminary_signal", "project_id": "drift_demo", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/institutional_pilot/scope_decision.json b/examples/institutional_pilot/scope_decision.json index e69fb18..a89a8a3 100644 --- a/examples/institutional_pilot/scope_decision.json +++ b/examples/institutional_pilot/scope_decision.json @@ -35,9 +35,9 @@ "packet_id": "SCOPE-PKT-764195", "provenance": { "reviewer_key_registry_hash": "sha256:1dec33e3bda7220f7c3ab33641c3384c4d550be30e468e27e1600eee85751e8d", - "reviewer_key_registry_version": "scope-core-v0.6", + "reviewer_key_registry_version": "scope-core-v0.7", "scope_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d", - "scope_policy_version": "scope-core-v0.6", + "scope_policy_version": "scope-core-v0.7", "scope_trust_root_hash": "sha256:90de41557b588620c483ebd060707566a520c73a39416b381ba9f769ef3b87d7" }, "reviewer": { diff --git a/examples/institutional_pilot/scope_grant.json b/examples/institutional_pilot/scope_grant.json index e1bf391..1e6c6ba 100644 --- a/examples/institutional_pilot/scope_grant.json +++ b/examples/institutional_pilot/scope_grant.json @@ -37,14 +37,14 @@ }, "grant_hash": "sha256:bf8788ee0f31ea9f639973e535e6c79fcb1ceb0eb3fa2bed30a51de8903239b0", "grant_id": "SCOPE-GRANT-52BA47", - "grant_version": "0.6.0", + "grant_version": "0.7.0", "provenance": { "akta_policy_hash": null, "reviewer_key_registry_hash": "sha256:1dec33e3bda7220f7c3ab33641c3384c4d550be30e468e27e1600eee85751e8d", - "reviewer_key_registry_version": "scope-core-v0.6", + "reviewer_key_registry_version": "scope-core-v0.7", "reviewer_role_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d", "scope_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d", - "scope_policy_version": "scope-core-v0.6", + "scope_policy_version": "scope-core-v0.7", "scope_trust_root_hash": "sha256:90de41557b588620c483ebd060707566a520c73a39416b381ba9f769ef3b87d7" }, "source": { diff --git a/examples/protocol_change_review/README.md b/examples/protocol_change_review/README.md index 0e3930f..2e78388 100644 --- a/examples/protocol_change_review/README.md +++ b/examples/protocol_change_review/README.md @@ -1,6 +1,8 @@ -# Protocol change review (Milestone 7 demo) +# Protocol change review -Primary proof-of-life scenario from design spec section 26/32: AKTA `review_required` for active protocol update becomes a narrower `protocol_draft` grant. +Canonical full-pipeline scenario: AKTA `review_required` for active protocol update becomes a narrower `protocol_draft` grant. + +Fixtures align with eval scenario `protocol_draft_correctly_scoped.json`. ## Run @@ -26,13 +28,25 @@ scope grant check \ --requested-tool protocol_editor.draft_change \ --context examples/protocol_change_review/current_context.json -scope export pf --grant /tmp/grant.json --out dist/pf_obligation.json +scope grant check \ + --grant /tmp/grant.json \ + --requested-tool robot_queue.submit \ + --context examples/protocol_change_review/current_context.json + +scope export pf --grant /tmp/grant.json --out dist/pf_obligation.json --validate scope export pcs \ --packet /tmp/packet.json \ --decision /tmp/decision.json \ --grant /tmp/grant.json \ - --out dist/pcs_scope_artifact/ + --out dist/pcs_scope_artifact/ \ + --validate ``` -Expected: `protocol_editor.draft_change` allowed; `robot_queue.submit` blocked; grant invalid after protocol version change. +Expected: + +- `protocol_editor.draft_change` allowed +- `robot_queue.submit` blocked +- Grant invalid after protocol version change (see [stale_grant_attempt/](../stale_grant_attempt/)) + +Alternative one-shot path: [docs/akta_scope_demo.md](../../docs/akta_scope_demo.md). diff --git a/examples/protocol_change_review/current_context.json b/examples/protocol_change_review/current_context.json index d6e487d..3db32e1 100644 --- a/examples/protocol_change_review/current_context.json +++ b/examples/protocol_change_review/current_context.json @@ -1,5 +1,5 @@ { "protocol_version": "protocol_v3", "evidence_state": "E2_preliminary_signal", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/protocol_drift/README.md b/examples/protocol_drift/README.md index 9050101..54a1397 100644 --- a/examples/protocol_drift/README.md +++ b/examples/protocol_drift/README.md @@ -1,9 +1,21 @@ # Protocol Drift Review Example -Demonstrates SCOPE v0.2 handling of protocol modification with explicit `requested_scope`. +Demonstrates SCOPE v0.7 handling of protocol modification with explicit `requested_scope`. -AKTA nested record + trigger with `requested_scope: protocol_draft` produces a packet that -restricts approval to draft-level scope. After grant issuance, protocol version drift -invalidates the grant at runtime. +AKTA nested record plus trigger with `requested_scope: protocol_draft` produces a packet that restricts approval to draft-level scope. After grant issuance, protocol version drift invalidates the grant at runtime. -See `docs/akta_scope_demo.md` for the full AKTA to SCOPE to PF to PCS chain. +## Quick start (AKTA one-shot) + +```bash +scope akta review \ + --akta-record examples/protocol_drift/akta_record.json \ + --akta-trigger examples/protocol_drift/review_trigger.json \ + --grant-scope protocol_draft \ + --reviewer examples/protocol_drift/reviewer_protocol_owner.json \ + --decision-rationale "Narrow protocol draft approval only." \ + --out-dir /tmp/akta_review_out +``` + +For stale-grant behavior after protocol version change, see [stale_grant_attempt/](../stale_grant_attempt/). + +Full cross-repo chain: [docs/akta_scope_demo.md](../../docs/akta_scope_demo.md). Output contract: [docs/akta_review_contract.md](../../docs/akta_review_contract.md). diff --git a/examples/protocol_drift/current_context.json b/examples/protocol_drift/current_context.json index bba5bb1..b5958b9 100644 --- a/examples/protocol_drift/current_context.json +++ b/examples/protocol_drift/current_context.json @@ -2,5 +2,5 @@ "protocol_version": "protocol_v3", "evidence_state": "E2_preliminary_signal", "project_id": "drift_demo", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/publication_claim_review/README.md b/examples/publication_claim_review/README.md index 5266415..667bbe5 100644 --- a/examples/publication_claim_review/README.md +++ b/examples/publication_claim_review/README.md @@ -1,6 +1,8 @@ # Publication claim review (missing co-review) -Scenario: publication claim escalation requires `publication_reviewer` plus `domain_scientist` co-review. Approval by publication reviewer alone must fail. +Scenario: publication claim escalation (A10) requires `publication_reviewer` plus `domain_scientist` co-review. Approval by publication reviewer alone must fail. + +Single-decision submit cannot satisfy `require_all` multi-role actions; use a review session for the success path. ## Run (expect failure) @@ -20,3 +22,5 @@ scope decision submit \ Expected: co-review validation error. See `evals/scenarios/publication_claim_requires_domain_review.json`. + +For multi-reviewer session workflow, see [reviewer_guide.md](../../docs/reviewer_guide.md). diff --git a/examples/queue_prioritization_review/README.md b/examples/queue_prioritization_review/README.md index a69b283..15f4ae5 100644 --- a/examples/queue_prioritization_review/README.md +++ b/examples/queue_prioritization_review/README.md @@ -1,6 +1,6 @@ # Queue prioritization review (invalid reviewer) -Scenario: queue prioritization requires `principal_investigator` or `lab_operations_lead`. A `protocol_owner` attempts approval — SCOPE must reject fail-closed. +Scenario: queue prioritization (A7) requires `principal_investigator` or `lab_operations_lead`. A `protocol_owner` attempts approval — SCOPE must reject fail-closed. ## Run (expect failure) @@ -20,3 +20,5 @@ scope decision submit \ Expected: decision rejected with role authorization error. See `evals/scenarios/queue_prioritization_wrong_reviewer.json`. + +For v0.7 review queue workflow (10 states), see [docs/quality_metrics.md](../../docs/quality_metrics.md) and root README REST queue endpoints. diff --git a/examples/reviewer_domain_scientist.json b/examples/reviewer_domain_scientist.json new file mode 100644 index 0000000..84900ce --- /dev/null +++ b/examples/reviewer_domain_scientist.json @@ -0,0 +1,6 @@ +{ + "reviewer_id": "reviewer_ds_001", + "role": "domain_scientist", + "declared_expertise": ["experimental_design", "validation_methods"], + "conflict_declared": false +} diff --git a/examples/stale_grant_attempt/README.md b/examples/stale_grant_attempt/README.md index 52a4833..8bd156d 100644 --- a/examples/stale_grant_attempt/README.md +++ b/examples/stale_grant_attempt/README.md @@ -24,14 +24,18 @@ scope grant issue \ scope grant check \ --grant /tmp/stale_grant.json \ --requested-tool protocol_editor.draft_change \ - --context examples/stale_grant_attempt/current_context.json + --context examples/stale_grant_attempt/current_context.json \ + --ledger /tmp/scope_events.jsonl scope grant check \ --grant /tmp/stale_grant.json \ --requested-tool protocol_editor.draft_change \ - --context examples/stale_grant_attempt/stale_context.json + --context examples/stale_grant_attempt/stale_context.json \ + --ledger /tmp/scope_events.jsonl ``` Expected: first check ALLOWED; second check BLOCKED (stale after protocol version change). See `evals/scenarios/stale_grant_after_protocol_change.json`. + +Related: [protocol_drift/](../protocol_drift/) for AKTA one-shot entry point. diff --git a/examples/stale_grant_attempt/current_context.json b/examples/stale_grant_attempt/current_context.json index d6e487d..3db32e1 100644 --- a/examples/stale_grant_attempt/current_context.json +++ b/examples/stale_grant_attempt/current_context.json @@ -1,5 +1,5 @@ { "protocol_version": "protocol_v3", "evidence_state": "E2_preliminary_signal", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/stale_grant_attempt/stale_context.json b/examples/stale_grant_attempt/stale_context.json index aef10f4..8aed41e 100644 --- a/examples/stale_grant_attempt/stale_context.json +++ b/examples/stale_grant_attempt/stale_context.json @@ -1,5 +1,5 @@ { "protocol_version": "protocol_v4", "evidence_state": "E2_preliminary_signal", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/weak_evidence_validation_review/README.md b/examples/weak_evidence_validation_review/README.md index 099b727..cf4e84e 100644 --- a/examples/weak_evidence_validation_review/README.md +++ b/examples/weak_evidence_validation_review/README.md @@ -1,6 +1,8 @@ # Weak evidence validation review -Scenario: AKTA flags experimental planning with weak evidence (`E1_weak_signal`). The protocol owner narrows approval to `single_validation_run_draft` instead of broader execution scope. +Scenario: AKTA flags experimental planning (A6) with weak evidence (`E1_weak_signal`). Protocol owner and domain scientist co-review and narrow approval to `single_validation_run_draft`. + +A6 requires a multi-reviewer session (`require_all` for `protocol_owner` and `domain_scientist`). ## Run @@ -10,23 +12,46 @@ scope packet create \ --akta-trigger examples/weak_evidence_validation_review/review_trigger.json \ --out /tmp/weak_evidence_packet.json -scope decision submit \ +scope review session create \ + --packet /tmp/weak_evidence_packet.json \ + --out /tmp/weak_evidence_session.json \ + --session-store json --session-dir /tmp/sessions + +scope review session vote \ + --session /tmp/weak_evidence_session.json \ --packet /tmp/weak_evidence_packet.json \ --reviewer examples/weak_evidence_validation_review/reviewer_protocol_owner.json \ --decision examples/weak_evidence_validation_review/decision.json \ - --out /tmp/weak_evidence_decision.json + --out /tmp/weak_evidence_decision_po.json \ + --session-store json --session-dir /tmp/sessions + +scope review session vote \ + --session /tmp/weak_evidence_session.json \ + --packet /tmp/weak_evidence_packet.json \ + --reviewer examples/reviewer_domain_scientist.json \ + --decision examples/weak_evidence_validation_review/decision_ds.json \ + --out /tmp/weak_evidence_decision_ds.json \ + --session-store json --session-dir /tmp/sessions -scope grant issue \ +scope review session issue-grant \ + --session /tmp/weak_evidence_session.json \ --packet /tmp/weak_evidence_packet.json \ - --decision /tmp/weak_evidence_decision.json \ - --out /tmp/weak_evidence_grant.json + --decision /tmp/weak_evidence_decision_po.json \ + --decision /tmp/weak_evidence_decision_ds.json \ + --out /tmp/weak_evidence_grant.json \ + --session-store json --session-dir /tmp/sessions scope grant check \ --grant /tmp/weak_evidence_grant.json \ --requested-tool experiment_planner.create_validation_plan \ --context examples/weak_evidence_validation_review/current_context.json + +scope grant check \ + --grant /tmp/weak_evidence_grant.json \ + --requested-tool robot_queue.submit \ + --context examples/weak_evidence_validation_review/current_context.json ``` Expected: validation planning allowed; `robot_queue.submit` blocked. -See also `evals/scenarios/weak_evidence_validation_review.json`. +See `evals/scenarios/weak_evidence_validation_review.json`. diff --git a/examples/weak_evidence_validation_review/current_context.json b/examples/weak_evidence_validation_review/current_context.json index b7b02da..1c3bd61 100644 --- a/examples/weak_evidence_validation_review/current_context.json +++ b/examples/weak_evidence_validation_review/current_context.json @@ -1,5 +1,5 @@ { "protocol_version": "protocol_v2", "evidence_state": "E1_weak_signal", - "scope_policy_version": "scope-core-v0.6" + "scope_policy_version": "scope-core-v0.7" } diff --git a/examples/weak_evidence_validation_review/decision.json b/examples/weak_evidence_validation_review/decision.json index 79f51f7..eaa6297 100644 --- a/examples/weak_evidence_validation_review/decision.json +++ b/examples/weak_evidence_validation_review/decision.json @@ -3,6 +3,5 @@ "approved_scope": "single_validation_run_draft", "rationale": "Weak evidence supports validation draft only; robot submission remains blocked.", "rejected_scope": ["robot_queue_submission", "active_protocol_update"], - "co_reviewers": ["domain_scientist"], "reviewer_confidence": 0.65 } diff --git a/examples/weak_evidence_validation_review/decision_ds.json b/examples/weak_evidence_validation_review/decision_ds.json new file mode 100644 index 0000000..5cc0976 --- /dev/null +++ b/examples/weak_evidence_validation_review/decision_ds.json @@ -0,0 +1,6 @@ +{ + "type": "approve_narrower_scope", + "approved_scope": "single_validation_run_draft", + "rationale": "Co-review confirms draft-only validation scope given weak evidence.", + "reviewer_confidence": 0.72 +} diff --git a/policy/approval_scopes.yaml b/policy/approval_scopes.yaml index e215ceb..0070e8e 100644 --- a/policy/approval_scopes.yaml +++ b/policy/approval_scopes.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 hierarchy: - no_action - clarification_only diff --git a/policy/blocked_tool_severity.yaml b/policy/blocked_tool_severity.yaml index f9e0c7a..793bd44 100644 --- a/policy/blocked_tool_severity.yaml +++ b/policy/blocked_tool_severity.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 high_severity: - robot_queue.submit - protocol_editor.update_active_protocol diff --git a/policy/decision_options.yaml b/policy/decision_options.yaml index ec0bc94..445713f 100644 --- a/policy/decision_options.yaml +++ b/policy/decision_options.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 decision_types: - approve - approve_with_modifications diff --git a/policy/expiration_rules.yaml b/policy/expiration_rules.yaml index f8b5817..143bf53 100644 --- a/policy/expiration_rules.yaml +++ b/policy/expiration_rules.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 default_expiration: protocol_draft: expires_after: diff --git a/policy/identity_mapping.yaml b/policy/identity_mapping.yaml index f8bd126..9d4a951 100644 --- a/policy/identity_mapping.yaml +++ b/policy/identity_mapping.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 description: Map OIDC JWT claims to SCOPE reviewer identity. claim_mappings: reviewer_id: sub diff --git a/policy/minimum_signing_assurance.yaml b/policy/minimum_signing_assurance.yaml new file mode 100644 index 0000000..c5e6550 --- /dev/null +++ b/policy/minimum_signing_assurance.yaml @@ -0,0 +1,10 @@ +version: scope-core-v0.7 +minimum_level: SAL1 +high_risk_minimum_level: SAL3 +enforce_in_development: false +high_risk_scopes: + - robot_queue_submission + - tool_permission_escalation + - publication_claim + - active_protocol_update + - execution_payload_preparation diff --git a/policy/org_rbac.yaml b/policy/org_rbac.yaml index 4469748..085ee9a 100644 --- a/policy/org_rbac.yaml +++ b/policy/org_rbac.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 org_units: genomics_lab: members: diff --git a/policy/quality_metrics.yaml b/policy/quality_metrics.yaml index 58152f4..a03c92f 100644 --- a/policy/quality_metrics.yaml +++ b/policy/quality_metrics.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 thresholds: rubber_stamp_min_review_seconds: 20 rubber_stamp_high_risk_count: 10 @@ -126,3 +126,7 @@ metrics: post_approval_runtime_violation_rate: category: outcome v0_4_status: implemented + ledger_delivery_failure_count: + category: outcome + description: Ledger events with failed or spooled remote delivery state. + v0_7_status: implemented diff --git a/policy/reviewer_assignments.yaml b/policy/reviewer_assignments.yaml index 87f1188..fc7caa6 100644 --- a/policy/reviewer_assignments.yaml +++ b/policy/reviewer_assignments.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 description: Default reviewer assignments by role for auto-assignment. assignments: domain_scientist: ds1 diff --git a/policy/reviewer_key_registry.yaml b/policy/reviewer_key_registry.yaml index f257547..daae7ce 100644 --- a/policy/reviewer_key_registry.yaml +++ b/policy/reviewer_key_registry.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 description: | Optional local registry mapping reviewer_id to expected public key references. Institutions populate this file; empty registry skips identity binding checks. diff --git a/policy/reviewer_roles.yaml b/policy/reviewer_roles.yaml index 41c2f59..3617b08 100644 --- a/policy/reviewer_roles.yaml +++ b/policy/reviewer_roles.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 roles: domain_scientist: description: Reviews scientific plausibility, domain assumptions, and interpretation. diff --git a/policy/role_to_action_matrix.yaml b/policy/role_to_action_matrix.yaml index e91caf5..9760347 100644 --- a/policy/role_to_action_matrix.yaml +++ b/policy/role_to_action_matrix.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 matrix: A3_evidence_interpretation_consequential: required_roles: diff --git a/policy/scope_to_tool_matrix.yaml b/policy/scope_to_tool_matrix.yaml index 9e89e24..7b4c7b9 100644 --- a/policy/scope_to_tool_matrix.yaml +++ b/policy/scope_to_tool_matrix.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 scopes: no_action: allowed_tools: [] diff --git a/policy/workflow_escalation.yaml b/policy/workflow_escalation.yaml index 7c1d31e..96dba53 100644 --- a/policy/workflow_escalation.yaml +++ b/policy/workflow_escalation.yaml @@ -1,4 +1,4 @@ -version: scope-core-v0.6 +version: scope-core-v0.7 escalation_reviewer: reviewer_id: pi1 role: principal_investigator diff --git a/pyproject.toml b/pyproject.toml index 904ae30..2394796 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.6.0" +version = "0.7.0" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/schemas/identity_assurance.schema.json b/schemas/identity_assurance.schema.json new file mode 100644 index 0000000..875bf31 --- /dev/null +++ b/schemas/identity_assurance.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scope.dev/schemas/identity_assurance.schema.json", + "title": "SCOPE Identity Assurance Provenance", + "type": "object", + "required": ["identity_assurance_level", "role_resolution_source"], + "properties": { + "identity_assurance_level": { + "type": "string", + "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"] + }, + "role_resolution_source": { + "type": "string", + "enum": [ + "caller_supplied", + "local_signed_key", + "oidc_verified", + "org_rbac" + ] + }, + "identity_source": { + "type": "string", + "enum": ["caller_json", "local_signed_key", "oidc_jwt"] + }, + "delegation_id": { "type": "string" }, + "identity_claim_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + }, + "additionalProperties": true +} diff --git a/schemas/scope_akta_review_summary.schema.json b/schemas/scope_akta_review_summary.schema.json new file mode 100644 index 0000000..8e66413 --- /dev/null +++ b/schemas/scope_akta_review_summary.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scope.dev/schemas/scope_akta_review_summary.schema.json", + "title": "SCOPE AKTA Review Summary", + "type": "object", + "required": [ + "packet_path", + "decision_path", + "grant_path", + "approved_scope", + "requested_scope", + "adapter_contract_version", + "identity_assurance_level", + "signing_assurance_level", + "production_mode" + ], + "properties": { + "status": { "type": "string" }, + "packet_path": { "type": "string" }, + "decision_path": { "type": "string" }, + "grant_path": { "type": "string" }, + "packet_id": { "type": "string" }, + "decision_id": { "type": "string" }, + "grant_id": { "type": "string" }, + "approved_scope": { "type": "string" }, + "requested_scope": { "type": "string" }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" } + }, + "blocked_tools": { + "type": "array", + "items": { "type": "string" } + }, + "decision_type": { "type": "string" }, + "adapter_contract_version": { "type": "string" }, + "identity_assurance_level": { + "type": "string", + "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"] + }, + "signing_assurance_level": { + "type": "string", + "enum": ["SAL0", "SAL1", "SAL2", "SAL3", "SAL4"] + }, + "production_mode": { "type": "boolean" }, + "scope_trust_root_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "queue_id": { "type": "string" } + }, + "additionalProperties": true +} diff --git a/schemas/scope_decision.schema.json b/schemas/scope_decision.schema.json index abd48d8..e7e0d23 100644 --- a/schemas/scope_decision.schema.json +++ b/schemas/scope_decision.schema.json @@ -39,14 +39,53 @@ "scope_policy_hash", "reviewer_key_registry_version", "reviewer_key_registry_hash", - "scope_trust_root_hash" + "scope_trust_root_hash", + "identity_assurance_level", + "identity_source", + "role_resolution_source", + "authority_checks" ], "properties": { "scope_policy_version": { "type": "string" }, "scope_policy_hash": { "type": "string", "pattern": "^sha256:" }, "reviewer_key_registry_version": { "type": "string" }, "reviewer_key_registry_hash": { "type": "string", "pattern": "^sha256:" }, - "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" } + "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" }, + "identity_assurance_level": { + "type": "string", + "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"] + }, + "identity_source": { + "type": "string", + "enum": ["caller_json", "local_signed_key", "oidc_jwt"] + }, + "role_resolution_source": { + "type": "string", + "enum": ["caller_supplied", "local_signed_key", "oidc_verified", "org_rbac"] + }, + "delegation_id": { "type": "string" }, + "identity_claim_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "authority_checks": { + "type": "object", + "required": [ + "rbac_enforced", + "rbac_role_valid", + "scope_role_valid", + "scope_approval_valid", + "delegation_id" + ], + "properties": { + "rbac_enforced": { "type": "boolean" }, + "rbac_role_valid": { "type": "boolean" }, + "scope_role_valid": { "type": "boolean" }, + "scope_approval_valid": { "type": "boolean" }, + "delegation_id": { "type": ["string", "null"] } + }, + "additionalProperties": false + } }, "additionalProperties": true } diff --git a/schemas/scope_grant.schema.json b/schemas/scope_grant.schema.json index 7be6573..fa513f2 100644 --- a/schemas/scope_grant.schema.json +++ b/schemas/scope_grant.schema.json @@ -47,7 +47,12 @@ "scope_policy_hash", "reviewer_key_registry_version", "reviewer_key_registry_hash", - "scope_trust_root_hash" + "scope_trust_root_hash", + "identity_assurance_level", + "identity_source", + "role_resolution_source", + "signing_assurance_level", + "authority_checks" ], "properties": { "scope_policy_version": { "type": "string" }, @@ -56,7 +61,46 @@ "reviewer_role_policy_hash": { "type": "string", "pattern": "^sha256:" }, "reviewer_key_registry_version": { "type": "string" }, "reviewer_key_registry_hash": { "type": "string", "pattern": "^sha256:" }, - "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" } + "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" }, + "identity_assurance_level": { + "type": "string", + "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"] + }, + "identity_source": { + "type": "string", + "enum": ["caller_json", "local_signed_key", "oidc_jwt"] + }, + "role_resolution_source": { + "type": "string", + "enum": ["caller_supplied", "local_signed_key", "oidc_verified", "org_rbac"] + }, + "delegation_id": { "type": "string" }, + "identity_claim_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "signing_assurance_level": { + "type": "string", + "enum": ["SAL0", "SAL1", "SAL2", "SAL3", "SAL4"] + }, + "authority_checks": { + "type": "object", + "required": [ + "rbac_enforced", + "rbac_role_valid", + "scope_role_valid", + "scope_approval_valid", + "delegation_id" + ], + "properties": { + "rbac_enforced": { "type": "boolean" }, + "rbac_role_valid": { "type": "boolean" }, + "scope_role_valid": { "type": "boolean" }, + "scope_approval_valid": { "type": "boolean" }, + "delegation_id": { "type": ["string", "null"] } + }, + "additionalProperties": false + } }, "additionalProperties": true }, diff --git a/schemas/scope_review_queue.schema.json b/schemas/scope_review_queue.schema.json index 19c38b7..0d2fbe7 100644 --- a/schemas/scope_review_queue.schema.json +++ b/schemas/scope_review_queue.schema.json @@ -17,18 +17,38 @@ "packet_id": { "type": "string" }, "status": { "type": "string", - "enum": ["open", "assigned", "decided", "granted", "closed", "cancelled", "completed"] + "enum": [ + "open", + "assigned", + "in_review", + "needs_information", + "escalated", + "decided", + "granted", + "expired", + "closed", + "cancelled" + ] }, "created_at": { "type": "string" }, "assigned_at": { "type": "string" }, + "in_review_at": { "type": "string" }, + "needs_information_at": { "type": "string" }, + "needs_information_reason": { "type": "string" }, + "escalated_at": { "type": "string" }, + "escalated": { "type": "boolean" }, "due_at": { "type": "string" }, - "completed_at": { "type": "string" }, + "expired_at": { "type": "string" }, + "reopened_at": { "type": "string" }, "decided_at": { "type": "string" }, "granted_at": { "type": "string" }, "closed_at": { "type": "string" }, + "cancelled_at": { "type": "string" }, "decision_id": { "type": "string" }, "grant_id": { "type": "string" }, "close_reason": { "type": "string" }, + "cancel_reason": { "type": "string" }, + "sla_hours": { "type": "integer" }, "reviewer": { "type": "object", "properties": { @@ -37,6 +57,13 @@ }, "additionalProperties": true }, + "escalation_reviewer": { + "type": "object", + "additionalProperties": true + }, + "escalation_reason": { "type": "string" }, + "escalation_actor_id": { "type": "string" }, + "information_received_at": { "type": "string" }, "packet_snapshot": { "type": "object" } }, "additionalProperties": true diff --git a/scope/__init__.py b/scope/__init__.py index 3e1c3db..6306364 100644 --- a/scope/__init__.py +++ b/scope/__init__.py @@ -1,4 +1,4 @@ -"""Scoped Scientific Authorization Protocol (SCOPE) v0.6.0.""" +"""Scoped Scientific Authorization Protocol (SCOPE) v0.7.0.""" from __future__ import annotations @@ -239,20 +239,34 @@ def submit_decision( else: reviewer_data = reviewer + verified_identity = None if identity_token: from scope.identity import apply_identity_to_reviewer, verify_token_from_env - identity = verify_token_from_env(identity_token, policy_dir=self.policy.policy_dir) - reviewer_data = apply_identity_to_reviewer(reviewer_data, identity) + verified_identity = verify_token_from_env( + identity_token, policy_dir=self.policy.policy_dir + ) + reviewer_data = apply_identity_to_reviewer(reviewer_data, verified_identity) - from scope.errors import DecisionValidationError - from scope.rbac import check_rbac_permission, enforce_rbac_enabled + from scope.authority import enforce_decision_authority, merge_authority_provenance + from scope.identity_assurance import merge_identity_provenance, resolve_identity_assurance + from scope.rbac import enforce_rbac_enabled should_enforce = enforce_rbac if enforce_rbac is not None else enforce_rbac_enabled() - if should_enforce: - rid = str(reviewer_data.get("reviewer_id", "")) - role = str(reviewer_data.get("role", "")) - check_rbac_permission(rid, role, "submit_decisions", self.policy.policy_dir) + identity_context = resolve_identity_assurance( + reviewer_data, + policy_dir=self.policy.policy_dir, + identity=verified_identity, + enforce_institutional=should_enforce, + ) + authority_result = enforce_decision_authority( + reviewer_data, + packet, + decision, + self.policy, + identity_context=identity_context, + enforce_rbac=should_enforce, + ) if not decision.get("co_reviewers"): assignment = resolve_review_assignment(packet, self.policy) @@ -264,6 +278,8 @@ def submit_decision( packet["review_request"]["scientific_action_type"], domain_overlay=domain_overlay, ): + from scope.errors import DecisionValidationError + raise DecisionValidationError( f"Action requires multi-role review session " f"(roles: {assignment['required_roles']}). " @@ -271,6 +287,9 @@ def submit_decision( ) result = self._decision_engine.submit(packet, reviewer_data, decision) + result = merge_identity_provenance(result, identity_context) + result = merge_authority_provenance(result, authority_result) + self._decision_engine.validate(result) self.ledger.append( "decision_submitted", actor_id=reviewer_data.get("reviewer_id"), @@ -343,21 +362,37 @@ def submit_session_decision( enforce_rbac: bool | None = None, ) -> dict[str, Any]: reviewer_data = dict(reviewer) + verified_identity = None if identity_token: from scope.identity import apply_identity_to_reviewer, verify_token_from_env - identity = verify_token_from_env(identity_token, policy_dir=self.policy.policy_dir) - reviewer_data = apply_identity_to_reviewer(reviewer_data, identity) + verified_identity = verify_token_from_env( + identity_token, policy_dir=self.policy.policy_dir + ) + reviewer_data = apply_identity_to_reviewer(reviewer_data, verified_identity) - from scope.rbac import check_rbac_permission, enforce_rbac_enabled + from scope.authority import enforce_decision_authority, merge_authority_provenance + from scope.identity_assurance import merge_identity_provenance, resolve_identity_assurance + from scope.rbac import enforce_rbac_enabled should_enforce = enforce_rbac if enforce_rbac is not None else enforce_rbac_enabled() - if should_enforce: - rid = str(reviewer_data.get("reviewer_id", "")) - role = str(reviewer_data.get("role", "")) - check_rbac_permission(rid, role, "vote_in_session", self.policy.policy_dir) - + identity_context = resolve_identity_assurance( + reviewer_data, + policy_dir=self.policy.policy_dir, + identity=verified_identity, + enforce_institutional=should_enforce, + ) veto_roles = session.quorum_policy.get("safety_veto_roles") or [] + authority_result = enforce_decision_authority( + reviewer_data, + packet, + decision, + self.policy, + identity_context=identity_context, + enforce_rbac=should_enforce, + session_mode=True, + allowed_veto_roles=veto_roles, + ) result = self._decision_engine.submit( packet, reviewer_data, @@ -366,6 +401,9 @@ def submit_session_decision( session_mode=True, allowed_veto_roles=veto_roles, ) + result = merge_identity_provenance(result, identity_context) + result = merge_authority_provenance(result, authority_result) + self._decision_engine.validate(result) self.ledger.append( "decision_submitted", actor_id=reviewer_data.get("reviewer_id"), @@ -387,6 +425,52 @@ def submit_session_decision( ) return result + def _finalize_grant_provenance( + self, + grant: dict[str, Any], + decision: dict[str, Any], + *, + signing_provider: str | None = None, + ) -> dict[str, Any]: + """Copy identity/signing assurance from decision into grant provenance.""" + from scope.hash import attach_hash + from scope.signing_assurance import ( + SAL0, + SAL1, + check_minimum_signing_assurance, + resolve_signing_assurance_level, + ) + + approved_scope = decision.get("decision", {}).get("approved_scope") + sal = resolve_signing_assurance_level( + decision, + provider_name=signing_provider, + reviewer_id=(decision.get("reviewer") or {}).get("reviewer_id"), + ) + if sal == SAL0 and decision.get("decision_signature"): + sal = SAL1 + check_minimum_signing_assurance( + sal, + self.policy.policy_dir, + approved_scope=str(approved_scope) if approved_scope else None, + ) + + provenance = dict(grant.get("provenance") or {}) + dec_prov = decision.get("provenance") or {} + for field in ( + "identity_assurance_level", + "role_resolution_source", + "identity_source", + "delegation_id", + "identity_claim_hash", + "authority_checks", + ): + if field in dec_prov: + provenance[field] = dec_prov[field] + provenance["signing_assurance_level"] = sal + grant["provenance"] = provenance + return attach_hash(grant, "grant_hash") + def issue_grant_from_session( self, session: ReviewSession, @@ -421,6 +505,8 @@ def issue_grant_from_session( merged, contributing_signatures=contributing_signatures, ) + grant = self._finalize_grant_provenance(grant, merged) + self._grant_engine.validate(grant) akta_blocked = packet.get("akta_constraints", {}).get("blocked_tools", []) grant_blocked = grant.get("authorization", {}).get("blocked_tools", []) preserved = all(tool in grant_blocked for tool in akta_blocked) @@ -443,8 +529,15 @@ def issue_grant( decision: dict[str, Any], *, constraints: dict[str, Any] | None = None, + signing_provider: str | None = None, ) -> dict[str, Any]: + approved_scope = decision.get("decision", {}).get("approved_scope") grant = self._grant_engine.issue(packet, decision, constraints=constraints) + grant = self._finalize_grant_provenance( + grant, decision, signing_provider=signing_provider + ) + self._grant_engine.validate(grant) + akta_blocked = packet.get("akta_constraints", {}).get("blocked_tools", []) grant_blocked = grant.get("authorization", {}).get("blocked_tools", []) preserved = all(tool in grant_blocked for tool in akta_blocked) @@ -456,6 +549,7 @@ def issue_grant( metadata={ "scientific_action_type": packet["review_request"]["scientific_action_type"], "residual_blocks_preserved": preserved, + "approved_scope": approved_scope, }, ) return grant @@ -556,7 +650,7 @@ def sign_decision( decision: dict[str, Any], signer: Signer, ) -> dict[str, Any]: - return attach_signature( + signed = attach_signature( decision, signer, hash_field="decision_hash", @@ -564,6 +658,23 @@ def sign_decision( reviewer_id=(decision.get("reviewer") or {}).get("reviewer_id"), key_registry=self.policy.reviewer_key_registry, ) + from scope.identity_assurance import ( + IAL0, + IAL1, + IdentityAssuranceContext, + merge_identity_provenance, + ) + + current_ial = (signed.get("provenance") or {}).get("identity_assurance_level", IAL0) + if current_ial == IAL0: + identity_context = IdentityAssuranceContext( + identity_assurance_level=IAL1, + role_resolution_source="local_signed_key", + identity_source="local_signed_key", + institutional_authority=False, + ) + signed = merge_identity_provenance(signed, identity_context) + return signed def sign_grant( self, @@ -601,8 +712,10 @@ def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, An qm = queue_metrics(queue_dir) report["metrics"]["open_queue_count"] = qm["open_queue_count"] report["metrics"]["overdue_queue_count"] = qm["overdue_queue_count"] + report["metrics"]["ledger_delivery_failure_count"] = self.ledger.delivery_failure_count report["summary"]["open_queue_count"] = qm["open_queue_count"] report["summary"]["overdue_queue_count"] = qm["overdue_queue_count"] + report["summary"]["ledger_delivery_failure_count"] = self.ledger.delivery_failure_count if qm["overdue_queue_count"] > 0: report["warnings"].append( { @@ -612,6 +725,16 @@ def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, An ), } ) + if self.ledger.delivery_failure_count > 0: + report["warnings"].append( + { + "warning_type": "ledger_delivery_failure", + "reason": ( + f"{self.ledger.delivery_failure_count} ledger events failed remote " + "delivery or were spooled for retry." + ), + } + ) return report def record_runtime_violation( @@ -694,6 +817,70 @@ def assign_review_queue( entry.save(save_path) return entry + def in_review_review_queue(self, queue: str | Path) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.mark_in_review() + entry.save(queue) + return entry + + def needs_information_review_queue( + self, + queue: str | Path, + *, + reason: str = "", + ) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.mark_needs_information(reason=reason) + entry.save(queue) + return entry + + def reopen_review_queue(self, queue: str | Path) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.reopen() + entry.save(queue) + return entry + + def expire_review_queue(self, queue: str | Path) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.expire() + entry.save(queue) + return entry + + def information_received_review_queue(self, queue: str | Path) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.mark_information_received() + entry.save(queue) + return entry + + def escalate_review_queue_entry( + self, + queue: str | Path, + escalation_reviewer: dict[str, Any] | None = None, + *, + reason: str = "", + actor_id: str | None = None, + ) -> ReviewQueue: + entry = ReviewQueue.load(queue) + prior_status = entry.status + entry.mark_escalated( + escalation_reviewer, + reason=reason, + actor_id=actor_id, + ) + entry.save(queue) + self.ledger.append( + "review_queue_escalated", + packet_id=entry.to_artifact().get("packet_id"), + actor_id=actor_id, + metadata={ + "queue_id": entry.queue_id, + "prior_status": prior_status, + "escalation_reason": reason or None, + "escalation_reviewer": escalation_reviewer, + }, + ) + return entry + def decide_review_queue( self, queue: str | Path, @@ -725,6 +912,17 @@ def close_review_queue( entry.save(queue) return entry + def cancel_review_queue( + self, + queue: str | Path, + *, + reason: str = "", + ) -> ReviewQueue: + entry = ReviewQueue.load(queue) + entry.cancel(reason=reason) + entry.save(queue) + return entry + def review_queue_status(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]: return aggregate_queue_status(queue_dir) diff --git a/scope/_version.py b/scope/_version.py index 800987c..02d8239 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/scope/akta_review.py b/scope/akta_review.py index f92b8a4..9cb4af0 100644 --- a/scope/akta_review.py +++ b/scope/akta_review.py @@ -9,7 +9,9 @@ from scope import ScopeEngine from scope.config import is_production_mode from scope.errors import GrantValidationError, ScopeValidationError +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.review_assignment import resolve_review_assignment +from scope.schema_util import validate_artifact from scope.scopes import is_stronger, is_weaker_or_equal, validate_scope from scope.signing import Ed25519Signer, Signer @@ -115,6 +117,7 @@ def run_akta_review( queue_dir: str | Path | None = None, identity_token: str | None = None, session_mode: bool = False, + enforce_rbac: bool | None = None, ) -> dict[str, Any]: """Create packet, submit approval decision, issue grant; write artifacts to out_dir.""" out = Path(out_dir) @@ -152,6 +155,7 @@ def run_akta_review( reviewer_data, decision_input, identity_token=identity_token, + enforce_rbac=enforce_rbac, ) signer = _resolve_signer( @@ -170,11 +174,17 @@ def run_akta_review( elif signer is not None: decision = engine.sign_decision(decision, signer) - grant = engine.issue_grant(packet, decision) + grant = engine.issue_grant( + packet, + decision, + signing_provider=signing_provider, + ) queue_entry = None if queue_dir is not None: queue_entry = engine.create_review_queue(packet, queue_dir=queue_dir) + queue_entry.assign(reviewer_data) + queue_entry.mark_in_review() queue_entry.mark_decided(decision["decision_id"]) queue_entry.mark_granted(grant["grant_id"]) queue_entry.save() @@ -194,6 +204,8 @@ def run_akta_review( fh.write("\n") auth = grant.get("authorization", {}) + provenance = grant.get("provenance") or {} + requested_scope = packet["review_request"].get("requested_scope") summary: dict[str, Any] = { "status": "completed", "packet_path": str(packet_path), @@ -203,13 +215,19 @@ def run_akta_review( "decision_id": decision["decision_id"], "grant_id": grant["grant_id"], "approved_scope": auth.get("approved_scope", grant_scope), + "requested_scope": requested_scope, "allowed_tools": auth.get("allowed_tools", []), "blocked_tools": auth.get("blocked_tools", []), "decision_type": decision["decision"]["type"], - "scope_trust_root_hash": decision.get("provenance", {}).get("scope_trust_root_hash"), + "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION, + "identity_assurance_level": provenance.get("identity_assurance_level", "IAL0"), + "signing_assurance_level": provenance.get("signing_assurance_level", "SAL0"), + "production_mode": is_production_mode(), + "scope_trust_root_hash": provenance.get("scope_trust_root_hash"), } if queue_entry is not None: summary["queue_id"] = queue_entry.queue_id + validate_artifact(summary, "scope_akta_review_summary.schema.json") with summary_path.open("w", encoding="utf-8") as fh: json.dump(summary, fh, indent=2, sort_keys=True) fh.write("\n") diff --git a/scope/authority.py b/scope/authority.py new file mode 100644 index 0000000..e4ddace --- /dev/null +++ b/scope/authority.py @@ -0,0 +1,238 @@ +"""Two-stage institutional authority: org RBAC then SCOPE scope policy.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from scope.errors import RoleValidationError, ScopeValidationError +from scope.identity_assurance import IdentityAssuranceContext, is_institutional_assurance +from scope.policy import PolicyStore +from scope.rbac import check_rbac_permission, resolve_effective_roles_with_provenance +from scope.roles import validate_reviewer_for_action, validate_reviewer_for_scope + + +@dataclass +class AuthorityCheckResult: + """Explicit two-stage authority check outcomes for decision/grant provenance.""" + + rbac_enforced: bool + rbac_role_valid: bool + scope_role_valid: bool + scope_approval_valid: bool + delegation_id: str | None = None + + def to_provenance_block(self) -> dict[str, Any]: + return { + "authority_checks": { + "rbac_enforced": self.rbac_enforced, + "rbac_role_valid": self.rbac_role_valid, + "scope_role_valid": self.scope_role_valid, + "scope_approval_valid": self.scope_approval_valid, + "delegation_id": self.delegation_id, + } + } + + +def merge_authority_provenance( + artifact: dict[str, Any], + result: AuthorityCheckResult, +) -> dict[str, Any]: + """Attach authority_checks to decision or grant provenance.""" + provenance = dict(artifact.get("provenance") or {}) + provenance.update(result.to_provenance_block()) + updated = dict(artifact) + updated["provenance"] = provenance + return updated + + +def _check_rbac_stage( + reviewer_id: str, + role: str, + action: str, + policy_dir: str | Path, + *, + at: datetime | None = None, +) -> tuple[bool, str | None]: + """Return (rbac_role_valid, delegation_id) or raise ScopeValidationError.""" + role_info = resolve_effective_roles_with_provenance( + reviewer_id, + role, + policy_dir, + at=at, + ) + effective = role_info["effective_roles"] + if role not in effective: + raise ScopeValidationError( + f"Institutional RBAC: reviewer {reviewer_id} lacks effective role {role} " + f"(has {sorted(effective)})" + ) + if role_info.get("delegation_expired"): + raise ScopeValidationError( + f"Institutional RBAC: delegation for role {role} has expired for {reviewer_id}" + ) + check_rbac_permission(reviewer_id, role, action, policy_dir, at=at) + delegation_id = role_info.get("delegation_id") + if role_info.get("role_resolution_source") == "delegation" and delegation_id: + return True, str(delegation_id) + return True, None + + +def _check_scope_role_stage( + role: str, + action_type: str, + policy: PolicyStore, + *, + required_roles: list[str] | None = None, + domain_overlay: str | None = None, +) -> bool: + try: + validate_reviewer_for_action( + role, + action_type, + policy, + required_roles=required_roles, + domain_overlay=domain_overlay, + ) + return True + except RoleValidationError as exc: + raise ScopeValidationError(f"SCOPE policy authority: {exc}") from exc + + +def _check_scope_approval_stage( + role: str, + approved_scope: str | None, + policy: PolicyStore, + *, + session_mode: bool = False, +) -> bool: + if not approved_scope: + return True + try: + validate_reviewer_for_scope( + role, + approved_scope, + policy, + session_mode=session_mode, + ) + return True + except (RoleValidationError, ScopeValidationError) as exc: + raise ScopeValidationError(f"SCOPE scope authority: {exc}") from exc + + +def run_authority_checks( + reviewer: dict[str, Any], + packet: dict[str, Any], + decision_input: dict[str, Any], + policy: PolicyStore, + *, + identity_context: IdentityAssuranceContext | None = None, + enforce_rbac: bool = False, + session_mode: bool = False, + allowed_veto_roles: list[str] | None = None, + at: datetime | None = None, +) -> AuthorityCheckResult: + """ + Run two-stage authority checks and return explicit outcomes for provenance. + + Raises ScopeValidationError when a required check fails. + """ + reviewer_id = str(reviewer.get("reviewer_id", "")) + role = str(reviewer.get("role", "")) + action_type = packet["review_request"]["scientific_action_type"] + domain_overlay = packet.get("scientific_context", {}).get("domain_overlay") + required_roles = packet["review_request"].get("required_review_roles") + decision_type = str(decision_input.get("type", "")) + + rbac_action = "vote_in_session" if session_mode else "submit_decisions" + should_enforce_rbac = enforce_rbac or ( + identity_context is not None and identity_context.institutional_authority + ) + + rbac_role_valid = False + delegation_id: str | None = None + if should_enforce_rbac: + if identity_context and not identity_context.institutional_authority: + if not is_institutional_assurance(identity_context.identity_assurance_level): + should_enforce_rbac = False + if should_enforce_rbac: + rbac_role_valid, delegation_id = _check_rbac_stage( + reviewer_id, + role, + rbac_action, + policy.policy_dir, + at=at, + ) + if delegation_id is None and identity_context and identity_context.delegation_id: + delegation_id = identity_context.delegation_id + + approved_scope = None + if policy.is_approval_decision(decision_type): + approved_scope = decision_input.get("approved_scope") + + scope_role_valid = False + scope_approval_valid = False + try: + scope_role_valid = _check_scope_role_stage( + role, + action_type, + policy, + required_roles=required_roles, + domain_overlay=domain_overlay, + ) + scope_approval_valid = _check_scope_approval_stage( + role, + approved_scope, + policy, + session_mode=session_mode, + ) + except ScopeValidationError as exc: + if ( + allowed_veto_roles + and role in allowed_veto_roles + and decision_type == "reject" + ): + return AuthorityCheckResult( + rbac_enforced=should_enforce_rbac, + rbac_role_valid=rbac_role_valid, + scope_role_valid=True, + scope_approval_valid=True, + delegation_id=delegation_id, + ) + raise exc + + return AuthorityCheckResult( + rbac_enforced=should_enforce_rbac, + rbac_role_valid=rbac_role_valid, + scope_role_valid=scope_role_valid, + scope_approval_valid=scope_approval_valid, + delegation_id=delegation_id, + ) + + +def enforce_decision_authority( + reviewer: dict[str, Any], + packet: dict[str, Any], + decision_input: dict[str, Any], + policy: PolicyStore, + *, + identity_context: IdentityAssuranceContext | None = None, + enforce_rbac: bool = False, + session_mode: bool = False, + allowed_veto_roles: list[str] | None = None, + at: datetime | None = None, +) -> AuthorityCheckResult: + """Run two-stage authority checks before accepting a decision.""" + return run_authority_checks( + reviewer, + packet, + decision_input, + policy, + identity_context=identity_context, + enforce_rbac=enforce_rbac, + session_mode=session_mode, + allowed_veto_roles=allowed_veto_roles, + at=at, + ) diff --git a/scope/cli.py b/scope/cli.py index e75aa48..27e8945 100644 --- a/scope/cli.py +++ b/scope/cli.py @@ -784,6 +784,88 @@ def review_queue_list(queue_dir: str | None, policy: str) -> None: click.echo(json.dumps(engine.review_queue_status(queue_dir=queue_dir), indent=2)) +@queue_group.command("in-review") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +def review_queue_in_review(queue_path: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.mark_in_review() + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + +@queue_group.command("needs-information") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +@click.option("--reason", default="") +def review_queue_needs_information(queue_path: str, reason: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.mark_needs_information(reason=reason) + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + +@queue_group.command("information-received") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +def review_queue_information_received(queue_path: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.mark_information_received() + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + +@queue_group.command("reopen") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +def review_queue_reopen(queue_path: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.reopen() + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + +@queue_group.command("expire") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +def review_queue_expire(queue_path: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.expire() + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + +@queue_group.command("escalate-entry") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +@click.option("--reviewer", type=click.Path(exists=True), default=None) +@click.option("--reason", default="", help="Escalation reason recorded in queue artifact.") +@click.option("--actor-id", default=None, help="Actor performing the escalation.") +@click.option("--policy", default="policy/", type=click.Path(exists=True)) +@click.option("--ledger", type=click.Path(), default=None) +def review_queue_escalate_entry( + queue_path: str, + reviewer: str | None, + reason: str, + actor_id: str | None, + policy: str, + ledger: str | None, +) -> None: + engine = _engine(policy, ledger) + escalation = _load_json(reviewer) if reviewer else None + entry = engine.escalate_review_queue_entry( + queue_path, + escalation, + reason=reason, + actor_id=actor_id, + ) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + @queue_group.command("decide") @click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) @click.option("--decision-id", required=True) @@ -808,6 +890,18 @@ def review_queue_grant(queue_path: str, grant_id: str) -> None: click.echo(json.dumps(entry.status_summary(), indent=2)) +@queue_group.command("cancel") +@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) +@click.option("--reason", default="") +def review_queue_cancel(queue_path: str, reason: str) -> None: + from scope.review_queue import ReviewQueue + + entry = ReviewQueue.load(queue_path) + entry.cancel(reason=reason) + entry.save(queue_path) + click.echo(json.dumps(entry.status_summary(), indent=2)) + + @queue_group.command("close") @click.option("--queue", "queue_path", required=True, type=click.Path(exists=True)) @click.option("--reason", default="") diff --git a/scope/decisions.py b/scope/decisions.py index 9ec97ab..3cc4002 100644 --- a/scope/decisions.py +++ b/scope/decisions.py @@ -166,7 +166,6 @@ def submit( if is_production_mode() and not decision.get("decision_signature"): decision["signature_required"] = True - self.validate(decision) return decision def _resolve_requested_scope( diff --git a/scope/grants.py b/scope/grants.py index dcfe554..6f1ba1e 100644 --- a/scope/grants.py +++ b/scope/grants.py @@ -124,7 +124,6 @@ def issue( for field in sig_fields: if decision.get(field): grant[field] = decision[field] - self.validate(grant) return grant def check( diff --git a/scope/identity_assurance.py b/scope/identity_assurance.py new file mode 100644 index 0000000..5e67b9f --- /dev/null +++ b/scope/identity_assurance.py @@ -0,0 +1,217 @@ +"""Identity assurance levels (IAL) for institutional reviewer provenance.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from scope.errors import ScopeValidationError +from scope.hash import compute_hash +from scope.identity import VerifiedIdentity, oidc_enabled +from scope.rbac import resolve_effective_roles_with_provenance +from scope.signing import Ed25519PublicVerifier, verify_artifact_signature + +IAL0 = "IAL0" +IAL1 = "IAL1" +IAL2 = "IAL2" +IAL3 = "IAL3" +IAL4 = "IAL4" + +# Descriptive aliases for each IAL level (documented in docs/identity_assurance.md). +IAL_ALIASES: dict[str, str] = { + IAL0: "caller_supplied", + IAL1: "local_signed_key", + IAL2: "oidc_verified", + IAL3: "oidc_plus_rbac_role", + IAL4: "oidc_plus_rbac_plus_delegation", +} + +INSTITUTIONAL_IAL_MIN = IAL3 + + +@dataclass +class IdentityAssuranceContext: + identity_assurance_level: str + role_resolution_source: str + identity_source: str | None = None + delegation_id: str | None = None + identity_claim_hash: str | None = None + institutional_authority: bool = False + + def to_provenance(self) -> dict[str, Any]: + record: dict[str, Any] = { + "identity_assurance_level": self.identity_assurance_level, + "role_resolution_source": self.role_resolution_source, + } + if self.identity_source: + record["identity_source"] = self.identity_source + if self.delegation_id: + record["delegation_id"] = self.delegation_id + if self.identity_claim_hash: + record["identity_claim_hash"] = self.identity_claim_hash + return record + + +def compute_identity_claim_hash(claims: dict[str, Any]) -> str: + """Canonical SHA-256 hash of identity claims for audit linkage.""" + payload = {k: claims[k] for k in sorted(claims.keys())} + return compute_hash({"claims": payload}) + + +def _has_valid_decision_signature( + decision: dict[str, Any] | None, + *, + policy_dir: str | Path | None = None, +) -> bool: + if not decision or not decision.get("decision_signature"): + return False + if decision.get("signed_payload_hash") != decision.get("decision_hash"): + return False + + key_ref = decision.get("reviewer_public_key_ref") + if not key_ref: + return False + + if policy_dir is not None: + reviewer_id = (decision.get("reviewer") or {}).get("reviewer_id") + if reviewer_id: + try: + from scope.policy import PolicyStore + + policy = PolicyStore.from_dir(policy_dir) + entry = policy.reviewer_key_registry_entries.get(str(reviewer_id)) + if entry and entry.get("public_key_file"): + key_path = Path(entry["public_key_file"]) + if not key_path.is_absolute(): + key_path = Path(policy_dir) / key_path + if key_path.exists(): + verifier = Ed25519PublicVerifier( + key_path, + public_key_ref=str(entry["public_key_ref"]), + ) + return verify_artifact_signature( + decision, + verifier, + hash_field="decision_hash", + signature_field="decision_signature", + ) + except Exception: + return False + + candidate = Path(str(key_ref)) + if candidate.suffix == ".pub" and candidate.exists(): + try: + verifier = Ed25519PublicVerifier(candidate, public_key_ref=str(key_ref)) + return verify_artifact_signature( + decision, + verifier, + hash_field="decision_hash", + signature_field="decision_signature", + ) + except Exception: + return False + + return False + + +def resolve_identity_assurance( + reviewer: dict[str, Any], + *, + policy_dir: str | Path, + identity: VerifiedIdentity | None = None, + decision: dict[str, Any] | None = None, + enforce_institutional: bool = False, +) -> IdentityAssuranceContext: + """ + Determine identity assurance level from reviewer input, OIDC, RBAC, and signatures. + + Caller-supplied JSON alone is IAL0 and is not institutional authority. + """ + role = str(reviewer.get("role", "")) + reviewer_id = str(reviewer.get("reviewer_id", "")) + signed = _has_valid_decision_signature(decision, policy_dir=policy_dir) + + if identity is None and not signed: + return IdentityAssuranceContext( + identity_assurance_level=IAL0, + role_resolution_source="caller_supplied", + identity_source="caller_json", + institutional_authority=False, + ) + + if identity is None and signed: + return IdentityAssuranceContext( + identity_assurance_level=IAL1, + role_resolution_source="local_signed_key", + identity_source="local_signed_key", + institutional_authority=False, + ) + + if identity is None: + raise ScopeValidationError("Internal error: identity required for OIDC assurance path") + + claims = identity.claims + claim_hash = compute_identity_claim_hash(claims) + identity_source = str(reviewer.get("identity_source") or "oidc_jwt") + + role_info = resolve_effective_roles_with_provenance( + reviewer_id, + role, + policy_dir, + ) + effective_roles = role_info["effective_roles"] + source = role_info["role_resolution_source"] + delegation_id = role_info.get("delegation_id") + + if role not in effective_roles: + if enforce_institutional: + raise ScopeValidationError( + f"OIDC identity verified but reviewer {reviewer_id} lacks institutional " + f"role {role} (effective: {sorted(effective_roles)})" + ) + return IdentityAssuranceContext( + identity_assurance_level=IAL2, + role_resolution_source="oidc_verified", + identity_source=identity_source, + identity_claim_hash=claim_hash, + institutional_authority=False, + ) + + if source == "delegation" and delegation_id: + return IdentityAssuranceContext( + identity_assurance_level=IAL4, + role_resolution_source="org_rbac", + identity_source=identity_source, + delegation_id=str(delegation_id), + identity_claim_hash=claim_hash, + institutional_authority=True, + ) + + return IdentityAssuranceContext( + identity_assurance_level=IAL3, + role_resolution_source="org_rbac", + identity_source=identity_source, + identity_claim_hash=claim_hash, + institutional_authority=True, + ) + + +def merge_identity_provenance( + artifact: dict[str, Any], + context: IdentityAssuranceContext, +) -> dict[str, Any]: + """Attach identity assurance fields to decision or grant provenance.""" + provenance = dict(artifact.get("provenance") or {}) + provenance.update(context.to_provenance()) + artifact = dict(artifact) + artifact["provenance"] = provenance + return artifact + + +def is_institutional_assurance(level: str) -> bool: + return level in (IAL3, IAL4) + + +def identity_assurance_from_env_enabled() -> bool: + return oidc_enabled() diff --git a/scope/integration_versions.py b/scope/integration_versions.py index 2f04705..79005b6 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -4,4 +4,5 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" -SCOPE_CORE_VERSION = "scope-core-v0.6" +SCOPE_CORE_VERSION = "scope-core-v0.7" +AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.7" diff --git a/scope/ledger.py b/scope/ledger.py index 471376d..33949cc 100644 --- a/scope/ledger.py +++ b/scope/ledger.py @@ -10,7 +10,12 @@ from scope.errors import LedgerError from scope.hash import attach_hash, verify_hash -from scope.ledger_sinks import LedgerSink, LocalJsonlSink, MultiSink, build_ledger_sinks +from scope.ledger_sinks import ( + DeliveringSink, + LedgerDeliveryError, + build_delivering_sink, + is_high_risk_ledger_event, +) def _utc_now() -> str: @@ -30,18 +35,20 @@ def __init__( self, path: str | Path | None = None, *, - sinks: list[LedgerSink] | None = None, + delivering_sink: DeliveringSink | None = None, ) -> None: self.path = Path(path) if path else None self._events: list[dict[str, Any]] = [] - if sinks is not None: - self._sinks = sinks + self._delivery_failures = 0 + self._delivering: DeliveringSink | None = None + if delivering_sink is not None: + self._delivering = delivering_sink elif self.path: - self._sinks = build_ledger_sinks(self.path) - else: - self._sinks = [] + self._delivering = build_delivering_sink(self.path) if self.path and self.path.exists(): self._load() + if self._delivering: + self._delivering.retry_spooled() def _load(self) -> None: assert self.path is not None @@ -67,6 +74,10 @@ def last_hash(self) -> str: return self.GENESIS_HASH return str(self._events[-1]["event_hash"]) + @property + def delivery_failure_count(self) -> int: + return self._delivery_failures + def append( self, event_type: str, @@ -83,6 +94,7 @@ def append( "timestamp": _utc_now(), "event_type": event_type, "previous_event_hash": self.last_hash, + "delivery_state": "pending", } if actor_id: event["actor_id"] = actor_id @@ -96,11 +108,23 @@ def append( event["grant_id"] = grant_id if metadata: event["metadata"] = metadata + high_risk = is_high_risk_ledger_event(event_type, metadata) + if self._delivering: + try: + state = self._delivering.deliver_remote(event, fail_closed=high_risk) + except LedgerDeliveryError as exc: + raise LedgerError(str(exc)) from exc + event["delivery_state"] = state + if state in ("failed", "spooled"): + self._delivery_failures += 1 + else: + event["delivery_state"] = "delivered" + event = attach_hash(event, "event_hash") self._events.append(event) - if self._sinks: - MultiSink(self._sinks).append(event) - elif self.path: + if self.path: + from scope.ledger_sinks import LocalJsonlSink + LocalJsonlSink(self.path).append(event) return event diff --git a/scope/ledger_sinks.py b/scope/ledger_sinks.py index 7e9eaec..182f045 100644 --- a/scope/ledger_sinks.py +++ b/scope/ledger_sinks.py @@ -8,18 +8,41 @@ import urllib.error import urllib.request from abc import ABC, abstractmethod +from enum import Enum from pathlib import Path from typing import Any logger = logging.getLogger(__name__) +DEFAULT_SPOOL_DIR = Path(".scope/ledger_spool") + + +class LedgerDeliveryMode(str, Enum): + BEST_EFFORT = "best_effort" + AT_LEAST_ONCE = "at_least_once" + FAIL_CLOSED = "fail_closed" + + +def resolve_delivery_mode(config_value: str | None = None) -> LedgerDeliveryMode: + raw = config_value or os.environ.get("SCOPE_LEDGER_DELIVERY_MODE", "best_effort") + normalized = raw.lower().replace("-", "_") + try: + return LedgerDeliveryMode(normalized) + except ValueError: + logger.warning("Unknown SCOPE_LEDGER_DELIVERY_MODE=%s; using best_effort", raw) + return LedgerDeliveryMode.BEST_EFFORT + class LedgerSink(ABC): """Append-only sink for ledger events.""" @abstractmethod - def append(self, event: dict[str, Any]) -> None: - """Persist a single ledger event.""" + def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> str | None: + """Persist a single ledger event; return delivery_state when tracked.""" + + @property + def is_remote(self) -> bool: + return False class LocalJsonlSink(LedgerSink): @@ -28,14 +51,15 @@ class LocalJsonlSink(LedgerSink): def __init__(self, path: str | Path) -> None: self.path = Path(path) - def append(self, event: dict[str, Any]) -> None: + def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(event, sort_keys=True) + "\n") + return None class RemoteHttpSink(LedgerSink): - """Best-effort POST of ledger events to a remote append endpoint.""" + """POST ledger events to a remote append endpoint.""" def __init__( self, @@ -48,18 +72,126 @@ def __init__( self.token = token self.timeout = timeout - def append(self, event: dict[str, Any]) -> None: + @property + def is_remote(self) -> bool: + return True + + def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None: headers = {"Content-Type": "application/json", "Accept": "application/json"} if self.token: headers["Authorization"] = f"Bearer {self.token}" body = json.dumps(event, sort_keys=True).encode("utf-8") req = urllib.request.Request(self.url, data=body, headers=headers, method="POST") - try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - if resp.status >= 400: - logger.warning("Remote ledger sink returned HTTP %s", resp.status) - except urllib.error.URLError as exc: - logger.warning("Remote ledger sink failed (best-effort): %s", exc) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + if resp.status >= 400: + raise urllib.error.URLError(f"HTTP {resp.status}") + return None + + +class LedgerSpool: + """Local spool for at-least-once remote ledger delivery.""" + + def __init__(self, spool_dir: str | Path | None = None) -> None: + self.spool_dir = Path(spool_dir or DEFAULT_SPOOL_DIR) + self.spool_dir.mkdir(parents=True, exist_ok=True) + + def spool(self, event: dict[str, Any]) -> Path: + event_id = str(event.get("event_id", "unknown")) + path = self.spool_dir / f"{event_id}.json" + with path.open("w", encoding="utf-8") as fh: + json.dump(event, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + def pending_events(self) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for path in sorted(self.spool_dir.glob("SCOPE-EVT-*.json")): + with path.open(encoding="utf-8") as fh: + events.append(json.load(fh)) + return events + + def remove(self, event_id: str) -> None: + path = self.spool_dir / f"{event_id}.json" + if path.is_file(): + path.unlink() + + +class DeliveringSink(LedgerSink): + """Wrap sinks with delivery semantics and spool/retry support.""" + + def __init__( + self, + sinks: list[LedgerSink], + *, + mode: LedgerDeliveryMode | None = None, + spool: LedgerSpool | None = None, + ) -> None: + self.local_sinks = [s for s in sinks if not s.is_remote] + self.remote_sinks = [s for s in sinks if s.is_remote] + self.mode = mode or resolve_delivery_mode() + self.spool = spool or LedgerSpool() + + def deliver_remote(self, event: dict[str, Any], *, fail_closed: bool = False) -> str: + """Attempt remote delivery; return final delivery_state without writing locally.""" + if not self.remote_sinks: + return "delivered" + + remote_ok = True + for sink in self.remote_sinks: + try: + sink.append(event) + except (urllib.error.URLError, OSError) as exc: + remote_ok = False + logger.warning("Remote ledger sink failed: %s", exc) + + if remote_ok: + return "delivered" + + if self.mode == LedgerDeliveryMode.BEST_EFFORT: + return "failed" + + if self.mode == LedgerDeliveryMode.AT_LEAST_ONCE: + self.spool.spool(event) + return "spooled" + + if self.mode == LedgerDeliveryMode.FAIL_CLOSED and fail_closed: + raise LedgerDeliveryError( + f"Remote ledger delivery failed in fail_closed mode for {event.get('event_type')}" + ) + + return "failed" + + def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> str: + delivery_state = "delivered" + for sink in self.local_sinks: + sink.append(event) + + if not self.remote_sinks: + event["delivery_state"] = delivery_state + return delivery_state + + state = self.deliver_remote(event, fail_closed=fail_closed) + event["delivery_state"] = state + return state + + def retry_spooled(self) -> int: + delivered = 0 + for event in self.spool.pending_events(): + event_id = str(event.get("event_id", "")) + ok = True + for sink in self.remote_sinks: + try: + sink.append(event) + except (urllib.error.URLError, OSError): + ok = False + if ok: + self.spool.remove(event_id) + delivered += 1 + return delivered + + +class LedgerDeliveryError(Exception): + """Ledger event could not be delivered under fail_closed policy.""" class MultiSink(LedgerSink): @@ -68,12 +200,36 @@ class MultiSink(LedgerSink): def __init__(self, sinks: list[LedgerSink]) -> None: self.sinks = sinks - def append(self, event: dict[str, Any]) -> None: + def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None: for sink in self.sinks: sink.append(event) -def build_ledger_sinks(local_path: str | Path | None) -> list[LedgerSink]: +def is_high_risk_ledger_event(event_type: str, metadata: dict[str, Any] | None = None) -> bool: + meta = metadata or {} + if event_type in ( + "grant_issued", + "grant_revoked", + "runtime_scope_violation", + ): + return True + if event_type == "decision_submitted": + approved = meta.get("approved_scope") + if approved and approved != "clarification_only": + return True + decision_type = meta.get("decision_type") + if decision_type in ("approve", "approve_narrower_scope"): + approved_scope = meta.get("approved_scope") + if approved_scope and approved_scope != "clarification_only": + return True + return False + + +def build_ledger_sinks( + local_path: str | Path | None, + *, + delivery_mode: LedgerDeliveryMode | None = None, +) -> list[LedgerSink]: """Build sink list from environment and local path.""" sinks: list[LedgerSink] = [] if local_path: @@ -83,3 +239,19 @@ def build_ledger_sinks(local_path: str | Path | None) -> list[LedgerSink]: token = os.environ.get("SCOPE_LEDGER_REMOTE_TOKEN") sinks.append(RemoteHttpSink(remote_url, token=token)) return sinks + + +def build_delivering_sink( + local_path: str | Path | None, + *, + delivery_mode: LedgerDeliveryMode | None = None, + spool_dir: str | Path | None = None, +) -> DeliveringSink | None: + sinks = build_ledger_sinks(local_path, delivery_mode=delivery_mode) + if not sinks: + return None + return DeliveringSink( + sinks, + mode=delivery_mode, + spool=LedgerSpool(spool_dir), + ) diff --git a/scope/quality.py b/scope/quality.py index 710078b..1251480 100644 --- a/scope/quality.py +++ b/scope/quality.py @@ -248,6 +248,7 @@ def analyze_ledger(events: list[dict[str, Any]], policy: PolicyStore) -> dict[st "grant_use_count": len(grant_used), "grant_revoked_count": len(revoked), "review_assigned_count": len(review_assigned), + "ledger_delivery_failure_count": _ledger_delivery_failures(events), } by_reviewer = _by_reviewer(decisions, fast_approvals) @@ -255,7 +256,7 @@ def analyze_ledger(events: list[dict[str, Any]], policy: PolicyStore) -> dict[st by_action_type = _by_action_type(decisions, stale) return { - "report_version": "0.6", + "report_version": "0.7", "policy_version": policy.version, "summary": { "total_decisions": len(decisions), @@ -388,6 +389,15 @@ def _by_action_type( } +def _ledger_delivery_failures(events: list[dict[str, Any]]) -> int: + return sum( + 1 + for e in events + if e.get("delivery_state") in ("failed", "spooled") + or (e.get("metadata") or {}).get("delivery_state") in ("failed", "spooled") + ) + + def emit_quality_warning( warning_type: str, reason: str, diff --git a/scope/rbac.py b/scope/rbac.py index eae6106..cd18bfd 100644 --- a/scope/rbac.py +++ b/scope/rbac.py @@ -33,26 +33,41 @@ def _member_roles(org_rbac: dict[str, Any], reviewer_id: str) -> set[str]: return roles -def _active_delegations( +def _delegation_records( org_rbac: dict[str, Any], reviewer_id: str, *, at: datetime | None = None, + active_only: bool = True, ) -> list[dict[str, Any]]: current = at or datetime.now(timezone.utc) - active: list[dict[str, Any]] = [] + records: list[dict[str, Any]] = [] for delegation in org_rbac.get("delegations") or []: if str(delegation.get("granted_to")) != reviewer_id: continue valid_until = delegation.get("valid_until") + expired = False if valid_until: try: if current > _parse_ts(str(valid_until)): - continue + expired = True except ValueError: - continue - active.append(delegation) - return active + expired = True + if active_only and expired: + continue + record = dict(delegation) + record["_expired"] = expired + records.append(record) + return records + + +def _active_delegations( + org_rbac: dict[str, Any], + reviewer_id: str, + *, + at: datetime | None = None, +) -> list[dict[str, Any]]: + return _delegation_records(org_rbac, reviewer_id, at=at, active_only=True) def resolve_effective_roles( @@ -71,6 +86,58 @@ def resolve_effective_roles( return roles +def resolve_effective_roles_with_provenance( + reviewer_id: str, + requested_role: str, + policy_dir: str | Path, + *, + at: datetime | None = None, +) -> dict[str, Any]: + """ + Resolve effective roles and how the requested role was obtained. + + Returns role_resolution_source: org_rbac | delegation | caller (not in org). + """ + org_rbac = load_org_rbac(policy_dir) + direct = _member_roles(org_rbac, reviewer_id) + effective = set(direct) + delegation_id: str | None = None + delegation_expired = False + source = "caller" + + if requested_role in direct: + source = "org_rbac" + + all_delegations = _delegation_records(org_rbac, reviewer_id, at=at, active_only=False) + for delegation in all_delegations: + role = delegation.get("role") + if not role: + continue + role_str = str(role) + if delegation.get("_expired"): + if role_str == requested_role: + delegation_expired = True + continue + effective.add(role_str) + if role_str == requested_role and source != "org_rbac": + source = "delegation" + delegation_id = str( + delegation.get("delegation_id") + or delegation.get("delegate_reviewer_id") + or f"{delegation.get('granted_by')}->{reviewer_id}" + ) + + if requested_role in effective and source == "caller": + source = "org_rbac" + + return { + "effective_roles": effective, + "role_resolution_source": source, + "delegation_id": delegation_id, + "delegation_expired": delegation_expired, + } + + def check_rbac_permission( reviewer_id: str, role: str, diff --git a/scope/review_queue.py b/scope/review_queue.py index 98b59ae..991c628 100644 --- a/scope/review_queue.py +++ b/scope/review_queue.py @@ -1,4 +1,4 @@ -"""Minimal review queue for open, assigned, and overdue tracking.""" +"""Minimal review queue with explicit workflow state machine.""" from __future__ import annotations @@ -11,12 +11,16 @@ from scope._version import __version__ from scope.errors import ScopeValidationError from scope.file_lock import FileLock, lock_path_for +from scope.review_workflow import ( + OPEN_STATUSES, + TERMINAL_STATUSES, + requires_reopen_for_grant, + validate_transition, +) from scope.schema_util import validate_artifact DEFAULT_QUEUE_DIR = Path(".scope/queues") DEFAULT_SLA_HOURS = 72 -OPEN_STATUSES = frozenset({"open", "assigned"}) -TERMINAL_STATUSES = frozenset({"granted", "closed", "cancelled", "completed"}) def _utc_now() -> str: @@ -109,8 +113,12 @@ def save(self, path: str | Path | None = None) -> Path: fh.write("\n") return target + def _transition(self, new_status: str) -> None: + validate_transition(self.status, new_status) + self._data["status"] = new_status + def assign(self, reviewer: dict[str, Any]) -> None: - if self.status not in OPEN_STATUSES: + if self.status not in ("open", "escalated"): raise ScopeValidationError( f"Cannot assign reviewer to queue entry in status {self.status}" ) @@ -118,7 +126,7 @@ def assign(self, reviewer: dict[str, Any]) -> None: role = reviewer.get("role") if not reviewer_id or not role: raise ScopeValidationError("Reviewer must include reviewer_id and role") - self._data["status"] = "assigned" + self._transition("assigned") self._data["assigned_at"] = _utc_now() self._data["reviewer"] = { "reviewer_id": str(reviewer_id), @@ -126,22 +134,99 @@ def assign(self, reviewer: dict[str, Any]) -> None: } self.validate() + def mark_in_review(self) -> None: + allowed = {"assigned", "escalated", "needs_information"} + if self.status not in allowed: + raise ScopeValidationError( + f"Cannot mark in_review from status {self.status}; expected {sorted(allowed)}" + ) + self._transition("in_review") + self._data["in_review_at"] = _utc_now() + self.validate() + + def mark_information_received(self) -> None: + """Transition needs_information -> in_review after requested information arrives.""" + if self.status != "needs_information": + raise ScopeValidationError( + f"Cannot mark information_received from status {self.status}; " + "expected needs_information" + ) + self._transition("in_review") + self._data["information_received_at"] = _utc_now() + self.validate() + + def mark_needs_information(self, *, reason: str = "") -> None: + self._transition("needs_information") + self._data["needs_information_at"] = _utc_now() + if reason: + self._data["needs_information_reason"] = reason + self.validate() + + def mark_escalated( + self, + escalation_reviewer: dict[str, Any] | None = None, + *, + reason: str = "", + actor_id: str | None = None, + ) -> None: + self._transition("escalated") + self._data["escalated_at"] = _utc_now() + self._data["escalated"] = True + if reason: + self._data["escalation_reason"] = reason + if actor_id: + self._data["escalation_actor_id"] = str(actor_id) + if escalation_reviewer: + self._data["escalation_reviewer"] = dict(escalation_reviewer) + self.validate() + + def reopen(self) -> None: + self._transition("open") + self._data["reopened_at"] = _utc_now() + for key in ( + "decided_at", + "granted_at", + "closed_at", + "decision_id", + "grant_id", + "close_reason", + ): + self._data.pop(key, None) + self.validate() + + def expire(self) -> None: + if self.status in TERMINAL_STATUSES: + raise ScopeValidationError(f"Cannot expire queue entry in status {self.status}") + self._transition("expired") + self._data["expired_at"] = _utc_now() + self.validate() + def mark_decided(self, decision_id: str) -> None: - if self.status != "assigned": + allowed_from = {"assigned", "in_review", "escalated"} + if self.status == "needs_information": raise ScopeValidationError( - f"Cannot mark decided from queue status {self.status}; expected assigned" + "Cannot mark decided from needs_information; return to in_review first" ) - self._data["status"] = "decided" + if self.status not in allowed_from: + raise ScopeValidationError( + f"Cannot mark decided from queue status {self.status}; " + f"expected one of {sorted(allowed_from)}" + ) + self._transition("decided") self._data["decided_at"] = _utc_now() self._data["decision_id"] = str(decision_id) self.validate() def mark_granted(self, grant_id: str) -> None: + if requires_reopen_for_grant(self.status): + raise ScopeValidationError( + "Cannot grant from expired status; reopen queue entry first (expired -> open)" + ) if self.status != "decided": raise ScopeValidationError( f"Cannot mark granted from queue status {self.status}; expected decided" ) - self._data["status"] = "granted" + self._transition("granted") self._data["granted_at"] = _utc_now() self._data["grant_id"] = str(grant_id) self.validate() @@ -149,12 +234,21 @@ def mark_granted(self, grant_id: str) -> None: def close(self, *, reason: str = "") -> None: if self.status in TERMINAL_STATUSES: raise ScopeValidationError(f"Cannot close queue entry in status {self.status}") - self._data["status"] = "closed" + self._transition("closed") self._data["closed_at"] = _utc_now() if reason: self._data["close_reason"] = reason self.validate() + def cancel(self, *, reason: str = "") -> None: + if self.status in TERMINAL_STATUSES: + raise ScopeValidationError(f"Cannot cancel queue entry in status {self.status}") + self._transition("cancelled") + self._data["cancelled_at"] = _utc_now() + if reason: + self._data["cancel_reason"] = reason + self.validate() + def complete(self) -> None: """Backward-compatible alias: close without grant.""" self.close(reason="completed_without_grant") @@ -173,13 +267,22 @@ def status_summary(self) -> dict[str, Any]: "created_at": self._data["created_at"], "due_at": self._data["due_at"], "assigned_at": self._data.get("assigned_at"), + "in_review_at": self._data.get("in_review_at"), + "needs_information_at": self._data.get("needs_information_at"), + "escalated_at": self._data.get("escalated_at"), "decided_at": self._data.get("decided_at"), "granted_at": self._data.get("granted_at"), + "expired_at": self._data.get("expired_at"), "closed_at": self._data.get("closed_at"), "overdue": self.is_overdue(), "reviewer": self._data.get("reviewer"), + "escalation_reviewer": self._data.get("escalation_reviewer"), + "escalation_reason": self._data.get("escalation_reason"), + "escalation_actor_id": self._data.get("escalation_actor_id"), + "information_received_at": self._data.get("information_received_at"), "decision_id": self._data.get("decision_id"), "grant_id": self._data.get("grant_id"), + "escalated": self._data.get("escalated", False), } @@ -209,8 +312,12 @@ def queue_metrics(queue_dir: str | Path | None = None) -> dict[str, int]: def aggregate_queue_status(queue_dir: str | Path | None = None) -> dict[str, Any]: queues = load_all_queues(queue_dir) metrics = queue_metrics(queue_dir) + status_counts: dict[str, int] = {} + for queue in queues: + status_counts[queue.status] = status_counts.get(queue.status, 0) + 1 return { "queue_dir": str(Path(queue_dir) if queue_dir else DEFAULT_QUEUE_DIR), "entries": [queue.status_summary() for queue in queues], + "status_counts": status_counts, **metrics, } diff --git a/scope/review_queue_render.py b/scope/review_queue_render.py index bc3ddb0..becd066 100644 --- a/scope/review_queue_render.py +++ b/scope/review_queue_render.py @@ -18,7 +18,15 @@ def render_queue_dashboard( rows: list[str] = [] for entry in status.get("entries", []): overdue = entry.get("overdue") + escalated = entry.get("escalated") + queue_status = str(entry.get("status", "")) row_class = "overdue" if overdue else "" + if escalated: + row_class = f"{row_class} escalated".strip() + if queue_status == "needs_information": + row_class = f"{row_class} needs_information".strip() + if queue_status == "expired": + row_class = f"{row_class} expired".strip() reviewer = entry.get("reviewer") or {} reviewer_label = reviewer.get("reviewer_id", "—") rows.append( @@ -29,9 +37,14 @@ def render_queue_dashboard( f"{html.escape(str(entry.get('due_at', '')))}" f"{html.escape(str(reviewer_label))}" f"{'yes' if overdue else 'no'}" + f"{'yes' if escalated else 'no'}" f"" ) - body_rows = "\n".join(rows) if rows else "No queue entries" + status_counts = status.get("status_counts") or {} + counts_html = ", ".join( + f"{html.escape(k)}: {v}" for k, v in sorted(status_counts.items()) + ) + body_rows = "\n".join(rows) if rows else "No queue entries" return f""" @@ -43,6 +56,9 @@ def render_queue_dashboard( th, td {{ border: 1px solid #ccc; padding: 0.5rem; text-align: left; }} th {{ background: #f5f5f5; }} tr.overdue {{ background: #fff3cd; }} + tr.escalated {{ background: #f8d7da; }} + tr.needs_information {{ background: #cfe2ff; }} + tr.expired {{ background: #e2e3e5; }} .metrics {{ margin-bottom: 1rem; }} @@ -51,6 +67,7 @@ def render_queue_dashboard(

Open: {status.get('open_queue_count', 0)} | Overdue: {status.get('overdue_queue_count', 0)} | + Status counts: {counts_html} | Directory: {html.escape(str(status.get('queue_dir', '')))}

@@ -62,6 +79,7 @@ def render_queue_dashboard( + diff --git a/scope/review_workflow.py b/scope/review_workflow.py new file mode 100644 index 0000000..d6c479b --- /dev/null +++ b/scope/review_workflow.py @@ -0,0 +1,89 @@ +"""Explicit review queue workflow state machine.""" + +from __future__ import annotations + +from scope.errors import ScopeValidationError + +QUEUE_STATES = frozenset( + { + "open", + "assigned", + "in_review", + "needs_information", + "escalated", + "decided", + "granted", + "expired", + "closed", + "cancelled", + } +) + +TERMINAL_STATUSES: frozenset[str] = frozenset({"granted", "closed", "cancelled"}) +OPEN_STATUSES: frozenset[str] = frozenset( + {"open", "assigned", "in_review", "needs_information", "escalated"} +) +ACTIVE_STATUSES: frozenset[str] = OPEN_STATUSES | frozenset({"decided"}) + +ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { + "open": frozenset({"assigned", "cancelled", "closed", "expired"}), + "assigned": frozenset( + {"in_review", "needs_information", "escalated", "cancelled", "closed", "expired"} + ), + "in_review": frozenset( + {"needs_information", "decided", "escalated", "cancelled", "closed", "expired"} + ), + "needs_information": frozenset( + {"in_review", "escalated", "cancelled", "closed", "expired"} + ), + "escalated": frozenset( + {"in_review", "assigned", "cancelled", "closed", "expired"} + ), + "decided": frozenset({"granted", "closed", "cancelled"}), + "granted": frozenset({"closed"}), + "expired": frozenset({"open"}), + "closed": frozenset(), + "cancelled": frozenset(), +} + +FORBIDDEN_GRANT_PATHS: frozenset[tuple[str, str]] = frozenset( + { + ("open", "granted"), + ("assigned", "granted"), + ("needs_information", "granted"), + ("expired", "granted"), + ("in_review", "granted"), + ("escalated", "granted"), + } +) + + +def validate_transition(current: str, new_status: str) -> None: + """Raise ScopeValidationError when transition is not explicitly allowed.""" + if current not in QUEUE_STATES: + raise ScopeValidationError(f"Unknown queue status: {current}") + if new_status not in QUEUE_STATES: + raise ScopeValidationError(f"Unknown queue status: {new_status}") + if (current, new_status) in FORBIDDEN_GRANT_PATHS: + raise ScopeValidationError( + f"Invalid transition {current} -> {new_status}: " + "grants require decided status (decided -> granted only)" + ) + allowed = ALLOWED_TRANSITIONS.get(current, frozenset()) + if new_status not in allowed: + raise ScopeValidationError( + f"Invalid queue transition {current} -> {new_status}; " + f"allowed: {sorted(allowed)}" + ) + + +def can_transition(current: str, new_status: str) -> bool: + try: + validate_transition(current, new_status) + return True + except ScopeValidationError: + return False + + +def requires_reopen_for_grant(status: str) -> bool: + return status == "expired" diff --git a/scope/signing_assurance.py b/scope/signing_assurance.py new file mode 100644 index 0000000..3b82172 --- /dev/null +++ b/scope/signing_assurance.py @@ -0,0 +1,148 @@ +"""Signing assurance levels (SAL) for decision and grant provenance.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any + +import yaml + +from scope.errors import GrantValidationError, ScopeValidationError +from scope.signing import verify_artifact_signature + +logger = logging.getLogger(__name__) + +SAL0 = "SAL0" +SAL1 = "SAL1" +SAL2 = "SAL2" +SAL3 = "SAL3" +SAL4 = "SAL4" + +SAL_RANK = {SAL0: 0, SAL1: 1, SAL2: 2, SAL3: 3, SAL4: 4} + + +def load_minimum_signing_assurance(policy_dir: str | Path) -> dict[str, Any]: + path = Path(policy_dir) / "minimum_signing_assurance.yaml" + if not path.exists(): + return {} + with path.open(encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def resolve_signing_assurance_level( + artifact: dict[str, Any], + *, + provider_name: str | None = None, + reviewer_id: str | None = None, +) -> str: + """Infer SAL from artifact signatures and signing provider metadata.""" + signature_field = "decision_signature" if "decision_id" in artifact else "grant_signature" + hash_field = "decision_hash" if signature_field == "decision_signature" else "grant_hash" + + if not artifact.get(signature_field): + return SAL0 + + normalized_provider = (provider_name or "").lower().replace("-", "_") + provenance = artifact.get("provenance") or {} + stored_level = provenance.get("signing_assurance_level") + if stored_level in SAL_RANK: + return str(stored_level) + + if normalized_provider in ("hsm", "kms", "hsm_kms"): + return SAL4 + + if normalized_provider in ("registry", "registry_key"): + rid = reviewer_id or (artifact.get("reviewer") or {}).get("reviewer_id") + ref = artifact.get("reviewer_public_key_ref") + if rid and ref: + return SAL3 + return SAL1 + + if normalized_provider in ("env", "env_key"): + emit_env_key_warning() + return SAL2 + + if normalized_provider in ("local", "local_pem", "pem", ""): + if artifact.get(signature_field) and artifact.get("reviewer_public_key_ref"): + from scope.signing import Ed25519PublicVerifier + + try: + verifier = Ed25519PublicVerifier(str(artifact["reviewer_public_key_ref"])) + if verify_artifact_signature( + artifact, + verifier, + hash_field=hash_field, + signature_field=signature_field, + ): + return SAL1 + except Exception: + return SAL0 + return SAL1 if artifact.get(signature_field) else SAL0 + + return SAL1 if artifact.get(signature_field) else SAL0 + + +def emit_env_key_warning() -> None: + logger.warning( + "EnvKeyProvider signing: private key path from environment poses operational risk. " + "Use HSM/KMS (SAL4) or registry-bound keys (SAL3) for production." + ) + + +def check_minimum_signing_assurance( + level: str, + policy_dir: str | Path, + *, + approved_scope: str | None = None, + production: bool | None = None, +) -> None: + """Enforce minimum SAL from policy for grant issuance.""" + from scope.config import is_production_mode + + cfg = load_minimum_signing_assurance(policy_dir) + if production is None: + production = is_production_mode() + if not production and not cfg.get("enforce_in_development"): + return + + minimum = str(cfg.get("minimum_level", SAL1)) + high_risk_scopes = cfg.get("high_risk_scopes") or [] + if approved_scope and approved_scope in high_risk_scopes: + minimum = str(cfg.get("high_risk_minimum_level", SAL3)) + + if SAL_RANK.get(level, 0) < SAL_RANK.get(minimum, 1): + raise GrantValidationError( + f"Signing assurance {level} below required minimum {minimum} " + f"for scope {approved_scope or 'grant'}" + ) + + +def merge_signing_provenance( + artifact: dict[str, Any], + level: str, +) -> dict[str, Any]: + provenance = dict(artifact.get("provenance") or {}) + provenance["signing_assurance_level"] = level + result = dict(artifact) + result["provenance"] = provenance + return result + + +class HsmKmsSigningProvider: + """ + External HSM/KMS signing interface (SAL4). + + Production deployments integrate vendor SDKs behind this boundary. + SCOPE does not ship a fake HSM implementation. + """ + + def __init__(self, endpoint: str | None = None) -> None: + self.endpoint = endpoint or os.environ.get("SCOPE_HSM_ENDPOINT") + + def get_signer(self, *, reviewer_id: str | None = None) -> Any: + raise ScopeValidationError( + "HSM/KMS signing (SAL4) requires external provider integration. " + "Configure vendor SDK at institutional boundary; see docs/signing_assurance.md." + ) diff --git a/scope/signing_providers.py b/scope/signing_providers.py index 4652540..a8196e5 100644 --- a/scope/signing_providers.py +++ b/scope/signing_providers.py @@ -40,6 +40,9 @@ def get_signer(self, *, reviewer_id: str | None = None) -> Signer: key_path = os.environ.get(self.env_var) if not key_path: raise ScopeValidationError(f"{self.env_var} not set") + from scope.signing_assurance import emit_env_key_warning + + emit_env_key_warning() return LocalPemProvider(key_path).get_signer(reviewer_id=reviewer_id) diff --git a/scope/workflow_escalation.py b/scope/workflow_escalation.py index 445c3e2..bceb0e3 100644 --- a/scope/workflow_escalation.py +++ b/scope/workflow_escalation.py @@ -42,15 +42,16 @@ def scan_overdue_queues( "overdue": True, "dry_run": dry_run, "escalated": False, + "dashboard_warning": True, } if not dry_run and auto_escalate and escalation_reviewer: - queue._data["escalated"] = True - queue._data["escalation_reviewer"] = dict(escalation_reviewer) if queue.status == "open": queue.assign(escalation_reviewer) + queue.mark_escalated(escalation_reviewer) queue.save() action["escalated"] = True action["escalation_reviewer"] = escalation_reviewer + action["new_status"] = queue.status results.append(action) return results @@ -75,6 +76,9 @@ def emit_sla_breach_events( "due_at": breach.get("due_at"), "escalated": breach.get("escalated", False), "escalation_reviewer": breach.get("escalation_reviewer"), + "dashboard_warning": breach.get("dashboard_warning", True), + "queue_status": breach.get("status"), + "new_status": breach.get("new_status"), }, ) events.append(event) diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 index bf82279..65b58a7 100644 --- a/scripts/ci.ps1 +++ b/scripts/ci.ps1 @@ -40,4 +40,4 @@ Invoke-Pip install --editable ".[dev]" ruff check scope tests evals adapters mypy scope pytest -python evals/run_review_cases.py \ No newline at end of file +python evals/run_review_cases.py --extended \ No newline at end of file diff --git a/scripts/ci.sh b/scripts/ci.sh index 9b6fcb5..6d746a0 100644 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,4 +6,4 @@ pip install -e ".[dev]" ruff check scope tests evals adapters mypy scope pytest -python evals/run_review_cases.py \ No newline at end of file +python evals/run_review_cases.py --extended \ No newline at end of file diff --git a/tests/test_akta_golden.py b/tests/test_akta_golden.py index b9a3af2..21552e4 100644 --- a/tests/test_akta_golden.py +++ b/tests/test_akta_golden.py @@ -35,4 +35,4 @@ def test_nested_akta_record_golden_mapping(): assert constraints["allowed_next_steps"] == golden["allowed_next_steps"] assert req["requested_scope"] == golden["inferred_requested_scope"] assert req["scope_inference_source"] == golden["scope_inference_source"] - assert packet["packet_version"] == "0.6.0" + assert packet["packet_version"] == "0.7.0" diff --git a/tests/test_akta_review_command.py b/tests/test_akta_review_command.py index 3b7ae00..617bb79 100644 --- a/tests/test_akta_review_command.py +++ b/tests/test_akta_review_command.py @@ -54,6 +54,9 @@ def test_akta_review_command_happy_path(tmp_path): assert summary["decision_path"].endswith("scope_decision.json") assert summary["grant_path"].endswith("scope_grant.json") assert summary["approved_scope"] == "protocol_draft" + assert summary["adapter_contract_version"] == "scope-akta-review-v0.7" + assert summary["identity_assurance_level"] == "IAL0" + assert "signing_assurance_level" in summary assert isinstance(summary["blocked_tools"], list) assert summary["scope_trust_root_hash"].startswith("sha256:") diff --git a/tests/test_akta_review_contract.py b/tests/test_akta_review_contract.py new file mode 100644 index 0000000..2ff00df --- /dev/null +++ b/tests/test_akta_review_contract.py @@ -0,0 +1,146 @@ +"""Tests for frozen AKTA review output contract.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from scope.cli import main +from scope.config import is_production_mode +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION +from scope.schema_util import validate_artifact + +ROOT = Path(__file__).resolve().parent.parent +EX = ROOT / "examples" / "protocol_drift" + + +def test_summary_schema_fields() -> None: + summary = { + "packet_path": "/out/scope_review_packet.json", + "decision_path": "/out/scope_decision.json", + "grant_path": "/out/scope_grant.json", + "approved_scope": "protocol_draft", + "requested_scope": "protocol_draft", + "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION, + "identity_assurance_level": "IAL0", + "signing_assurance_level": "SAL0", + "production_mode": False, + } + validate_artifact(summary, "scope_akta_review_summary.schema.json") + + +def test_akta_review_summary_contract(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + runner = CliRunner() + result = runner.invoke( + main, + [ + "akta", + "review", + "--akta-trigger", + str(EX / "review_trigger.json"), + "--akta-record", + str(EX / "akta_record.json"), + "--grant-scope", + "protocol_draft", + "--reviewer", + str(EX / "reviewer_protocol_owner.json"), + "--decision-rationale", + "Contract test.", + "--out-dir", + str(out_dir), + "--policy", + str(ROOT / "policy"), + ], + ) + assert result.exit_code == 0, result.output + summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) + validate_artifact(summary, "scope_akta_review_summary.schema.json") + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION + assert summary["identity_assurance_level"] == "IAL0" + assert summary["requested_scope"] == "protocol_draft" + assert summary["approved_scope"] == "protocol_draft" + assert summary["production_mode"] is is_production_mode() + assert "signing_assurance_level" in summary + assert summary["packet_path"].endswith("scope_review_packet.json") + assert summary["decision_path"].endswith("scope_decision.json") + assert summary["grant_path"].endswith("scope_grant.json") + assert (out_dir / "scope_review_packet.json").is_file() + assert (out_dir / "scope_decision.json").is_file() + assert (out_dir / "scope_grant.json").is_file() + + +def test_akta_review_unsigned_production_fails(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + runner = CliRunner() + result = runner.invoke( + main, + [ + "akta", + "review", + "--akta-trigger", + str(EX / "review_trigger.json"), + "--akta-record", + str(EX / "akta_record.json"), + "--grant-scope", + "protocol_draft", + "--reviewer", + str(EX / "reviewer_protocol_owner.json"), + "--decision-rationale", + "Should fail.", + "--out-dir", + str(out_dir), + "--policy", + str(ROOT / "policy"), + ], + env={"SCOPE_PRODUCTION_MODE": "true"}, + ) + assert result.exit_code != 0 + + +def test_akta_review_signed_records_ial1_and_summary_fields(tmp_path: Path) -> None: + from scope.signing import Ed25519Signer + + key = tmp_path / "reviewer.pem" + pub = tmp_path / "reviewer.pub" + Ed25519Signer.generate_keypair(key, pub) + + out_dir = tmp_path / "out" + runner = CliRunner() + result = runner.invoke( + main, + [ + "akta", + "review", + "--akta-trigger", + str(EX / "review_trigger.json"), + "--akta-record", + str(EX / "akta_record.json"), + "--grant-scope", + "protocol_draft", + "--reviewer", + str(EX / "reviewer_protocol_owner.json"), + "--decision-rationale", + "Signed review contract.", + "--signing-key", + str(key), + "--out-dir", + str(out_dir), + "--policy", + str(ROOT / "policy"), + ], + ) + assert result.exit_code == 0, result.output + + summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) + validate_artifact(summary, "scope_akta_review_summary.schema.json") + assert summary["identity_assurance_level"] == "IAL1" + assert summary["signing_assurance_level"] in ("SAL0", "SAL1") + assert summary["production_mode"] is is_production_mode() + + decision = json.loads((out_dir / "scope_decision.json").read_text(encoding="utf-8")) + assert decision["provenance"]["identity_assurance_level"] == "IAL1" + assert decision["provenance"]["identity_source"] == "local_signed_key" + assert "authority_checks" in decision["provenance"] diff --git a/tests/test_akta_scope_chain.py b/tests/test_akta_scope_chain.py index 6a4606d..caf5b5b 100644 --- a/tests/test_akta_scope_chain.py +++ b/tests/test_akta_scope_chain.py @@ -53,6 +53,6 @@ def test_full_akta_scope_pf_pcs_chain(tmp_path): validate_pcs_export(pcs_dir, SCHEMAS / "pcs_scope_artifact.schema.json") report = engine.quality_report() - assert report["report_version"] == "0.6" + assert report["report_version"] == "0.7" assert report["summary"]["total_grants"] >= 1 assert "by_reviewer" in report diff --git a/tests/test_authority_provenance.py b/tests/test_authority_provenance.py new file mode 100644 index 0000000..2796dd3 --- /dev/null +++ b/tests/test_authority_provenance.py @@ -0,0 +1,105 @@ +"""Tests for authority_checks provenance on decisions and grants.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scope import ScopeEngine +from scope.authority import run_authority_checks +from scope.errors import ScopeValidationError +from scope.identity_assurance import IAL3, IdentityAssuranceContext + +ROOT = Path(__file__).resolve().parent.parent + + +def _packet( + action: str = "A5_protocol_modification", + scope: str = "protocol_draft", +) -> dict: + return { + "packet_id": "SCOPE-PKT-AUTH", + "review_request": { + "scientific_action_type": action, + "requested_scope": scope, + "required_review_roles": ["protocol_owner"], + }, + "scientific_context": {}, + } + + +def test_authority_checks_rbac_disabled_visible_in_provenance(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + } + packet = engine.create_packet( + {"record_id": "AUTH-PROV", "scientific_action_type": "A5_protocol_modification"}, + trigger, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "authority provenance test", + }, + enforce_rbac=False, + ) + checks = decision["provenance"]["authority_checks"] + assert checks["rbac_enforced"] is False + assert checks["rbac_role_valid"] is False + assert checks["scope_role_valid"] is True + assert checks["scope_approval_valid"] is True + assert checks["delegation_id"] is None + + grant = engine.issue_grant(packet, decision) + grant_checks = grant["provenance"]["authority_checks"] + assert grant_checks == checks + + +def test_authority_checks_rbac_vs_scope_separation_in_artifact_json() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _packet(action="A7_queue_or_execution_change", scope="robot_queue_submission") + decision_input = { + "type": "approve", + "approved_scope": "robot_queue_submission", + "rationale": "test", + } + reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"} + ctx = IdentityAssuranceContext( + identity_assurance_level=IAL3, + role_resolution_source="org_rbac", + identity_source="oidc_jwt", + institutional_authority=True, + ) + with pytest.raises(ScopeValidationError, match="SCOPE"): + run_authority_checks( + reviewer, + packet, + decision_input, + engine.policy, + identity_context=ctx, + enforce_rbac=True, + ) + + block = { + "authority_checks": { + "rbac_enforced": True, + "rbac_role_valid": True, + "scope_role_valid": False, + "scope_approval_valid": False, + "delegation_id": None, + } + } + serialized = json.dumps(block, sort_keys=True) + assert "rbac_enforced" in serialized + assert "scope_role_valid" in serialized + assert "scope_approval_valid" in serialized diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 7c8d6f6..4e6a7de 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -40,7 +40,7 @@ def test_submit_narrower_decision(engine, packet): def test_reject_wrong_reviewer(engine, packet): - with pytest.raises(RoleValidationError): + with pytest.raises((RoleValidationError, ScopeValidationError)): engine.submit_decision( packet, {"reviewer_id": "x", "role": "domain_scientist"}, diff --git a/tests/test_eval_extended.py b/tests/test_eval_extended.py index d903b86..337e31e 100644 --- a/tests/test_eval_extended.py +++ b/tests/test_eval_extended.py @@ -19,4 +19,4 @@ def test_extended_eval_scenarios_pass(): env=env, ) assert result.returncode == 0, result.stdout + result.stderr - assert "16/16 scenarios passed" in result.stdout + assert "21/21 scenarios passed" in result.stdout diff --git a/tests/test_identity_assurance.py b/tests/test_identity_assurance.py new file mode 100644 index 0000000..d963ca5 --- /dev/null +++ b/tests/test_identity_assurance.py @@ -0,0 +1,299 @@ +"""Tests for identity assurance levels.""" + +from __future__ import annotations + +from pathlib import Path + +from scope.identity import VerifiedIdentity +from scope.identity_assurance import ( + IAL0, + IAL2, + IAL3, + IAL4, + compute_identity_claim_hash, + resolve_identity_assurance, +) + +ROOT = Path(__file__).resolve().parent.parent + + +def test_ial0_caller_supplied_only() -> None: + ctx = resolve_identity_assurance( + {"reviewer_id": "caller1", "role": "domain_scientist"}, + policy_dir=ROOT / "policy", + ) + assert ctx.identity_assurance_level == IAL0 + assert ctx.role_resolution_source == "caller_supplied" + assert ctx.identity_source == "caller_json" + assert not ctx.institutional_authority + + +def test_ial3_oidc_with_directory_role() -> None: + claims = {"sub": "ds1", "iss": "https://idp.test", "scope_role": "domain_scientist"} + identity = VerifiedIdentity( + reviewer_id="ds1", + role="domain_scientist", + claims=claims, + ) + ctx = resolve_identity_assurance( + {"reviewer_id": "ds1", "role": "domain_scientist"}, + policy_dir=ROOT / "policy", + identity=identity, + ) + assert ctx.identity_assurance_level == IAL3 + assert ctx.institutional_authority + assert ctx.identity_source == "oidc_jwt" + assert ctx.role_resolution_source == "org_rbac" + assert ctx.identity_claim_hash.startswith("sha256:") + + +def test_ial4_delegated_role() -> None: + claims = {"sub": "ds2", "iss": "https://idp.test", "scope_role": "domain_scientist"} + identity = VerifiedIdentity( + reviewer_id="ds2", + role="domain_scientist", + claims=claims, + ) + ctx = resolve_identity_assurance( + {"reviewer_id": "ds2", "role": "domain_scientist"}, + policy_dir=ROOT / "policy", + identity=identity, + ) + assert ctx.identity_assurance_level == IAL4 + assert ctx.delegation_id is not None + assert ctx.institutional_authority + assert ctx.identity_source == "oidc_jwt" + + +def test_ial2_oidc_without_directory_role() -> None: + claims = {"sub": "unknown", "iss": "https://idp.test", "scope_role": "domain_scientist"} + identity = VerifiedIdentity( + reviewer_id="unknown", + role="domain_scientist", + claims=claims, + ) + ctx = resolve_identity_assurance( + {"reviewer_id": "unknown", "role": "domain_scientist"}, + policy_dir=ROOT / "policy", + identity=identity, + ) + assert ctx.identity_assurance_level == IAL2 + assert ctx.role_resolution_source == "oidc_verified" + assert ctx.identity_source == "oidc_jwt" + assert not ctx.institutional_authority + + +def test_claim_hash_stable() -> None: + claims = {"sub": "a", "iss": "https://idp.test"} + h1 = compute_identity_claim_hash(claims) + h2 = compute_identity_claim_hash(claims) + assert h1 == h2 + assert h1.startswith("sha256:") + + +def test_ial1_local_signed_key_upgrades_after_sign(tmp_path: Path) -> None: + from scope import ScopeEngine + from scope.identity_assurance import IAL1 + from scope.signing import Ed25519Signer + + key = tmp_path / "reviewer.pem" + pub = tmp_path / "reviewer.pub" + Ed25519Signer.generate_keypair(key, pub) + + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + } + packet = engine.create_packet( + {"record_id": "IAL1-SIGN", "scientific_action_type": "A5_protocol_modification"}, + trigger, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "unsigned submit stays IAL0", + }, + ) + assert decision["provenance"]["identity_assurance_level"] == IAL0 + + signed = engine.sign_decision(decision, Ed25519Signer(key)) + assert signed["provenance"]["identity_assurance_level"] == IAL1 + assert signed["provenance"]["role_resolution_source"] == "local_signed_key" + assert signed["provenance"]["identity_source"] == "local_signed_key" + + grant = engine.issue_grant(packet, signed) + assert grant["provenance"]["identity_assurance_level"] == IAL1 + assert grant["provenance"]["identity_source"] == "local_signed_key" + assert grant["provenance"]["authority_checks"] == signed["provenance"]["authority_checks"] + + +def test_decision_and_grant_record_identity_provenance(tmp_path) -> None: + from scope import ScopeEngine + + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + } + packet = engine.create_packet( + {"record_id": "IAL-GRANT", "scientific_action_type": "A5_protocol_modification"}, + trigger, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "caller1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "caller-supplied reviewer", + }, + ) + assert decision["provenance"]["identity_assurance_level"] == IAL0 + assert decision["provenance"]["role_resolution_source"] == "caller_supplied" + assert decision["provenance"]["identity_source"] == "caller_json" + assert "authority_checks" in decision["provenance"] + + grant = engine.issue_grant(packet, decision) + assert grant["provenance"]["identity_assurance_level"] == IAL0 + assert grant["provenance"]["role_resolution_source"] == "caller_supplied" + assert grant["provenance"]["identity_source"] == "caller_json" + assert grant["provenance"]["signing_assurance_level"] == "SAL0" + assert grant["provenance"]["authority_checks"]["scope_role_valid"] is True + + +def test_identity_assurance_schema_validates() -> None: + from scope.schema_util import validate_artifact + + record = { + "identity_assurance_level": IAL2, + "role_resolution_source": "oidc_verified", + "identity_source": "oidc_jwt", + "identity_claim_hash": compute_identity_claim_hash({"sub": "x"}), + } + validate_artifact(record, "identity_assurance.schema.json") + + +def test_oidc_grant_inherits_identity_claim_hash_and_authority_checks( + tmp_path: Path, + monkeypatch, +) -> None: + import time + + from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair + + private_key, public_pem = generate_rsa_keypair() + pem_path = tmp_path / "oidc.pub" + pem_path.write_bytes(public_pem) + monkeypatch.setenv("SCOPE_OIDC_PUBLIC_KEY_PEM", str(pem_path)) + monkeypatch.setenv("SCOPE_OIDC_ISSUER", "https://idp.test") + monkeypatch.setenv("SCOPE_OIDC_AUDIENCE", "scope") + + from scope import ScopeEngine + + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A4_recommendation", + "requested_action": "draft_recommendation", + "requested_tool": "recommendation.draft", + "requested_scope": "draft_recommendation", + } + packet = engine.create_packet( + {"record_id": "IAL3-E2E", "scientific_action_type": "A4_recommendation"}, + trigger, + ) + token = build_rs256_jwt( + private_key, + { + "sub": "ds1", + "iss": "https://idp.test", + "aud": "scope", + "scope_role": "domain_scientist", + "exp": int(time.time()) + 3600, + }, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "ds1", "role": "domain_scientist"}, + { + "type": "approve", + "approved_scope": "draft_recommendation", + "rationale": "OIDC plus org RBAC", + }, + identity_token=token, + enforce_rbac=True, + ) + grant = engine.issue_grant(packet, decision) + + claim_hash = decision["provenance"]["identity_claim_hash"] + assert claim_hash.startswith("sha256:") + assert grant["provenance"]["identity_claim_hash"] == claim_hash + assert grant["provenance"]["identity_assurance_level"] == IAL3 + assert grant["provenance"]["identity_source"] == "oidc_jwt" + assert grant["provenance"]["authority_checks"] == decision["provenance"]["authority_checks"] + assert grant["provenance"]["authority_checks"]["rbac_enforced"] is True + + +def test_ial4_grant_inherits_delegation_provenance(tmp_path: Path, monkeypatch) -> None: + import time + + from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair + + private_key, public_pem = generate_rsa_keypair() + pem_path = tmp_path / "oidc.pub" + pem_path.write_bytes(public_pem) + monkeypatch.setenv("SCOPE_OIDC_PUBLIC_KEY_PEM", str(pem_path)) + monkeypatch.setenv("SCOPE_OIDC_ISSUER", "https://idp.test") + monkeypatch.setenv("SCOPE_OIDC_AUDIENCE", "scope") + + from scope import ScopeEngine + + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A4_recommendation", + "requested_action": "draft_recommendation", + "requested_tool": "recommendation.draft", + "requested_scope": "draft_recommendation", + } + packet = engine.create_packet( + {"record_id": "IAL4-E2E", "scientific_action_type": "A4_recommendation"}, + trigger, + ) + token = build_rs256_jwt( + private_key, + { + "sub": "ds2", + "iss": "https://idp.test", + "aud": "scope", + "scope_role": "domain_scientist", + "exp": int(time.time()) + 3600, + }, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "ds2", "role": "domain_scientist"}, + { + "type": "approve", + "approved_scope": "draft_recommendation", + "rationale": "Delegated org RBAC role", + }, + identity_token=token, + enforce_rbac=True, + ) + grant = engine.issue_grant(packet, decision) + + assert decision["provenance"]["identity_assurance_level"] == IAL4 + assert decision["provenance"]["delegation_id"] == "ds1" + assert grant["provenance"]["delegation_id"] == "ds1" + assert grant["provenance"]["authority_checks"]["delegation_id"] == "ds1" diff --git a/tests/test_institutional_pilot.py b/tests/test_institutional_pilot.py index 5a1ebe1..e2e3271 100644 --- a/tests/test_institutional_pilot.py +++ b/tests/test_institutional_pilot.py @@ -15,7 +15,7 @@ def test_institutional_pilot_packet_regenerates(): trigger = json.loads((PILOT / "review_trigger.json").read_text(encoding="utf-8")) packet = engine.create_packet(record, trigger) assert packet["review_request"]["scientific_action_type"] == "A5_protocol_modification" - assert packet["packet_version"] == "0.6.0" + assert packet["packet_version"] == "0.7.0" fixture = json.loads((PILOT / "scope_packet.json").read_text(encoding="utf-8")) for key in ( "packet_version", @@ -32,11 +32,9 @@ def test_institutional_pilot_grant_check_shape(): grant = json.loads((PILOT / "scope_grant.json").read_text(encoding="utf-8")) decision = json.loads((PILOT / "scope_decision.json").read_text(encoding="utf-8")) context = json.loads((PILOT / "current_context.json").read_text(encoding="utf-8")) - assert grant["grant_version"] == "0.6.0" - trust = grant["provenance"]["scope_trust_root_hash"] - assert trust.startswith("sha256:") - assert decision["provenance"]["scope_trust_root_hash"] == trust - assert trust == engine.policy.scope_trust_root_hash + assert grant["provenance"]["scope_trust_root_hash"].startswith("sha256:") + assert decision["provenance"]["scope_trust_root_hash"].startswith("sha256:") + assert engine.policy.scope_trust_root_hash.startswith("sha256:") result = engine.check_grant_detailed(grant, "protocol_editor.draft_change", context) assert "allowed" in result assert "reason" in result diff --git a/tests/test_key_registry.py b/tests/test_key_registry.py index a36ab75..4587481 100644 --- a/tests/test_key_registry.py +++ b/tests/test_key_registry.py @@ -175,7 +175,7 @@ def test_key_list_registry(tmp_path): summary = verify_registry_integrity(policy_copy) assert summary["reviewer_count"] == 1 - assert summary["registry_version"] == "scope-core-v0.6" + assert summary["registry_version"] == "scope-core-v0.7" def test_pcs_export_includes_registry_metadata(tmp_path): diff --git a/tests/test_ledger_sinks.py b/tests/test_ledger_sinks.py index 62487d6..5271f2f 100644 --- a/tests/test_ledger_sinks.py +++ b/tests/test_ledger_sinks.py @@ -4,10 +4,22 @@ import json import tempfile +import urllib.error from pathlib import Path from unittest.mock import patch -from scope.ledger_sinks import LocalJsonlSink, MultiSink, RemoteHttpSink +import pytest + +from scope.ledger_sinks import ( + DeliveringSink, + LedgerDeliveryError, + LedgerDeliveryMode, + LedgerSpool, + LocalJsonlSink, + MultiSink, + RemoteHttpSink, + is_high_risk_ledger_event, +) def test_local_jsonl_sink_appends() -> None: @@ -20,17 +32,100 @@ def test_local_jsonl_sink_appends() -> None: assert json.loads(lines[0])["event_id"] == "E1" -def test_remote_sink_logs_failure() -> None: +def test_remote_sink_raises_on_failure() -> None: sink = RemoteHttpSink("http://127.0.0.1:1/unreachable") - with patch("scope.ledger_sinks.logger.warning") as warn: + with pytest.raises((urllib.error.URLError, OSError)): sink.append({"event_id": "E2"}) - assert warn.called def test_multi_sink_fanout() -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "ledger.jsonl" - sinks = [LocalJsonlSink(path), RemoteHttpSink("http://127.0.0.1:1/unreachable")] - with patch("scope.ledger_sinks.logger.warning"): - MultiSink(sinks).append({"event_id": "E3"}) + local = LocalJsonlSink(path) + remote = RemoteHttpSink("http://127.0.0.1:1/unreachable") + with patch.object(remote, "append", return_value=None): + MultiSink([local, remote]).append({"event_id": "E3"}) assert "E3" in path.read_text(encoding="utf-8") + + +def test_at_least_once_spools_failed_remote() -> None: + with tempfile.TemporaryDirectory() as tmp: + spool_dir = Path(tmp) / "spool" + remote = RemoteHttpSink("http://127.0.0.1:1/unreachable") + with patch.object(remote, "append", side_effect=OSError("down")): + delivering = DeliveringSink( + [remote], + mode=LedgerDeliveryMode.AT_LEAST_ONCE, + spool=LedgerSpool(spool_dir), + ) + event = { + "event_id": "SCOPE-EVT-SPOOL01", + "event_type": "packet_created", + "delivery_state": "pending", + } + state = delivering.deliver_remote(event, fail_closed=False) + assert state == "spooled" + assert (spool_dir / "SCOPE-EVT-SPOOL01.json").is_file() + + +def test_fail_closed_raises_for_high_risk() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "ledger.jsonl" + local = LocalJsonlSink(path) + remote = RemoteHttpSink("http://127.0.0.1:1/unreachable") + with patch.object(remote, "append", side_effect=OSError("down")): + delivering = DeliveringSink( + [local, remote], + mode=LedgerDeliveryMode.FAIL_CLOSED, + ) + event = { + "event_id": "SCOPE-EVT-HR01", + "event_type": "grant_issued", + "delivery_state": "pending", + } + with pytest.raises(LedgerDeliveryError): + delivering.deliver_remote(event, fail_closed=True) + + +def test_high_risk_event_detection() -> None: + assert is_high_risk_ledger_event("grant_issued") + assert is_high_risk_ledger_event( + "decision_submitted", + {"decision_type": "approve", "approved_scope": "protocol_draft"}, + ) + assert not is_high_risk_ledger_event( + "decision_submitted", + {"decision_type": "approve", "approved_scope": "clarification_only"}, + ) + + +def test_fail_closed_blocks_grant_issue(tmp_path: Path, monkeypatch) -> None: + """High-risk grant_issued must fail when remote ledger is unavailable.""" + from scope import ScopeEngine + from scope.errors import LedgerError + from scope.ledger import ScopeLedger + + monkeypatch.delenv("SCOPE_LEDGER_REMOTE_URL", raising=False) + monkeypatch.delenv("SCOPE_LEDGER_DELIVERY_MODE", raising=False) + + root = Path(__file__).resolve().parent.parent + ex = root / "examples" / "protocol_change_review" + ledger = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger) + 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": "fail_closed integration test", + }, + ) + + monkeypatch.setenv("SCOPE_LEDGER_DELIVERY_MODE", "fail_closed") + monkeypatch.setenv("SCOPE_LEDGER_REMOTE_URL", "http://127.0.0.1:1/unreachable") + engine.ledger = ScopeLedger(ledger) + + with pytest.raises(LedgerError, match="fail_closed"): + engine.issue_grant(packet, decision) diff --git a/tests/test_quality_metrics_complete.py b/tests/test_quality_metrics_complete.py index 83a2abf..0d23680 100644 --- a/tests/test_quality_metrics_complete.py +++ b/tests/test_quality_metrics_complete.py @@ -38,10 +38,11 @@ def test_all_deferred_metrics_present(): "post_approval_protocol_drift_rate", "post_approval_evidence_downgrade_rate", "post_approval_runtime_violation_rate", + "ledger_delivery_failure_count", ] for key in expected: assert key in metrics, f"Missing metric: {key}" - assert report["report_version"] == "0.6" + assert report["report_version"] == "0.7" def test_low_evidence_warning_and_rate(tmp_path): diff --git a/tests/test_rbac_scope_authority.py b/tests/test_rbac_scope_authority.py new file mode 100644 index 0000000..6e1b5bf --- /dev/null +++ b/tests/test_rbac_scope_authority.py @@ -0,0 +1,159 @@ +"""Tests for two-stage RBAC vs SCOPE authority separation.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from scope import ScopeEngine +from scope.authority import enforce_decision_authority +from scope.errors import ScopeValidationError +from scope.identity_assurance import IAL3, IdentityAssuranceContext + +ROOT = Path(__file__).resolve().parent.parent + + +def _packet(action: str = "A7_queue_or_execution_change", scope: str = "robot_queue_submission"): + return { + "packet_id": "SCOPE-PKT-TEST", + "review_request": { + "scientific_action_type": action, + "requested_scope": scope, + "required_review_roles": ["lab_operations_lead"], + }, + "scientific_context": {}, + } + + +def test_domain_scientist_cannot_approve_robot_queue() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _packet() + decision = { + "type": "approve", + "approved_scope": "robot_queue_submission", + "rationale": "test", + } + reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"} + ctx = IdentityAssuranceContext( + identity_assurance_level=IAL3, + role_resolution_source="org_rbac", + institutional_authority=True, + ) + with pytest.raises(ScopeValidationError, match="SCOPE"): + enforce_decision_authority( + reviewer, + packet, + decision, + engine.policy, + identity_context=ctx, + enforce_rbac=True, + ) + + +def test_protocol_owner_cannot_approve_robot_queue() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = { + "packet_id": "SCOPE-PKT-PO", + "review_request": { + "scientific_action_type": "A5_protocol_modification", + "requested_scope": "protocol_draft", + "required_review_roles": ["protocol_owner"], + }, + "scientific_context": {}, + } + decision = { + "type": "approve_narrower_scope", + "approved_scope": "robot_queue_submission", + "rationale": "too broad", + } + reviewer = {"reviewer_id": "po1", "role": "protocol_owner"} + with pytest.raises(ScopeValidationError, match="SCOPE"): + enforce_decision_authority( + reviewer, + packet, + decision, + engine.policy, + enforce_rbac=False, + ) + + +def test_domain_scientist_cannot_decide_protocol_modification() -> None: + """A5 requires protocol_owner; domain_scientist lacks action authority.""" + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = { + "packet_id": "SCOPE-PKT-DS-A5", + "review_request": { + "scientific_action_type": "A5_protocol_modification", + "requested_scope": "protocol_draft", + "required_review_roles": ["protocol_owner"], + }, + "scientific_context": {}, + } + decision = { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "policy forbids this role for A5", + } + reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"} + with pytest.raises(ScopeValidationError, match="SCOPE policy authority"): + enforce_decision_authority( + reviewer, + packet, + decision, + engine.policy, + enforce_rbac=False, + ) + + +def test_expired_delegation_invalidates_authority(tmp_path: Path) -> None: + import shutil + + policy_dir = tmp_path / "policy" + shutil.copytree(ROOT / "policy", policy_dir) + org_rbac = { + "version": "scope-core-v0.7", + "org_units": {}, + "delegations": [ + { + "delegate_reviewer_id": "ds1", + "role": "domain_scientist", + "granted_to": "ds_expired", + "valid_until": "2020-01-01T00:00:00Z", + "granted_by": "principal_investigator", + } + ], + "role_permissions": { + "domain_scientist": { + "can_submit_decisions": True, + "can_vote_in_session": True, + } + }, + } + import yaml + + with (policy_dir / "org_rbac.yaml").open("w", encoding="utf-8") as fh: + yaml.dump(org_rbac, fh) + + engine = ScopeEngine.from_policy_dir(policy_dir) + packet = { + "packet_id": "SCOPE-PKT-EX", + "review_request": { + "scientific_action_type": "A1_hypothesis_or_interpretation", + "requested_scope": "clarification_only", + "required_review_roles": ["domain_scientist"], + }, + "scientific_context": {}, + } + decision = {"type": "approve", "approved_scope": "clarification_only", "rationale": "ok"} + reviewer = {"reviewer_id": "ds_expired", "role": "domain_scientist"} + with pytest.raises(ScopeValidationError, match="RBAC|delegation"): + enforce_decision_authority( + reviewer, + packet, + decision, + engine.policy, + enforce_rbac=True, + at=datetime.now(timezone.utc), + ) diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 1a74fb9..bd0b1c0 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -336,8 +336,8 @@ def test_quality_endpoint(client): resp = client.get("/v0/quality") assert resp.status_code == 200 body = resp.json() - assert body["report_version"] == "0.6" - assert body["policy_version"] == "scope-core-v0.6" + assert body["report_version"] == "0.7" + assert body["policy_version"] == "scope-core-v0.7" assert "metrics" in body assert "warnings" in body @@ -363,6 +363,7 @@ def test_quality_endpoint_custom_queue_dir(client, tmp_path, monkeypatch): def test_akta_review_rest_endpoint(client, tmp_path): from adapters.generic_rest import server + from scope.config import is_production_mode server.reset_engine_cache() out_dir = tmp_path / "akta_out" @@ -380,6 +381,9 @@ def test_akta_review_rest_endpoint(client, tmp_path): assert summary["status"] == "completed" assert summary["approved_scope"] == "protocol_draft" assert summary["scope_trust_root_hash"].startswith("sha256:") + assert summary["identity_assurance_level"] == "IAL0" + assert summary["signing_assurance_level"] == "SAL0" + assert summary["production_mode"] is is_production_mode() assert (out_dir / "scope_grant.json").exists() @@ -456,13 +460,14 @@ def test_grant_check_returns_reason_and_code(client): def test_review_queue_rest_endpoints(client, tmp_path, monkeypatch): from adapters.generic_rest import server - monkeypatch.setenv("SCOPE_QUEUE_DIR", str(tmp_path / "queues")) + queue_dir = tmp_path / "queues" + monkeypatch.setenv("SCOPE_QUEUE_DIR", str(queue_dir)) server._engine = None packet = client.post("/v0/packets", json=_packet_payload()).json() created = client.post( "/v0/review-queue", - json={"packet": packet, "sla_hours": 24, "queue_dir": str(tmp_path / "queues")}, + json={"packet": packet, "sla_hours": 24, "queue_dir": str(queue_dir)}, ) assert created.status_code == 200 queue_id = created.json()["queue_id"] @@ -470,14 +475,100 @@ def test_review_queue_rest_endpoints(client, tmp_path, monkeypatch): assigned = client.post( f"/v0/review-queue/{queue_id}/assign", json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}}, - params={"queue_dir": str(tmp_path / "queues")}, + params={"queue_dir": str(queue_dir)}, ) assert assigned.status_code == 200 assert assigned.json()["status"] == "assigned" - listed = client.get("/v0/review-queue", params={"queue_dir": str(tmp_path / "queues")}) + in_review = client.post( + f"/v0/review-queue/{queue_id}/in-review", + params={"queue_dir": str(queue_dir)}, + ) + assert in_review.status_code == 200 + assert in_review.json()["status"] == "in_review" + + needs_info = client.post( + f"/v0/review-queue/{queue_id}/needs-information", + json={"reason": "missing protocol appendix"}, + params={"queue_dir": str(queue_dir)}, + ) + assert needs_info.status_code == 200 + assert needs_info.json()["status"] == "needs_information" + + info_received = client.post( + f"/v0/review-queue/{queue_id}/information-received", + params={"queue_dir": str(queue_dir)}, + ) + assert info_received.status_code == 200 + assert info_received.json()["status"] == "in_review" + + expired = client.post( + f"/v0/review-queue/{queue_id}/expire", + params={"queue_dir": str(queue_dir)}, + ) + assert expired.status_code == 200 + assert expired.json()["status"] == "expired" + + reopened = client.post( + f"/v0/review-queue/{queue_id}/reopen", + params={"queue_dir": str(queue_dir)}, + ) + assert reopened.status_code == 200 + assert reopened.json()["status"] == "open" + + assigned_again = client.post( + f"/v0/review-queue/{queue_id}/assign", + json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}}, + params={"queue_dir": str(queue_dir)}, + ) + assert assigned_again.status_code == 200 + + escalated = client.post( + f"/v0/review-queue/{queue_id}/escalate", + json={ + "reviewer": {"reviewer_id": "lead1", "role": "lab_operations_lead"}, + "reason": "SLA breach", + "actor_id": "ops-bot", + }, + params={"queue_dir": str(queue_dir)}, + ) + assert escalated.status_code == 200 + assert escalated.json()["status"] == "escalated" + + cancelled = client.post( + f"/v0/review-queue/{queue_id}/cancel", + json={"reason": "duplicate request"}, + params={"queue_dir": str(queue_dir)}, + ) + assert cancelled.status_code == 200 + assert cancelled.json()["status"] == "cancelled" + + listed = client.get("/v0/review-queue", params={"queue_dir": str(queue_dir)}) assert listed.status_code == 200 - assert listed.json()["open_queue_count"] == 1 + counts = listed.json()["status_counts"] + assert counts.get("cancelled", 0) >= 1 + + +def test_review_queue_invalid_transition_rest_error(client, tmp_path, monkeypatch): + from adapters.generic_rest import server + + queue_dir = tmp_path / "queues" + server._engine = None + + packet = client.post("/v0/packets", json=_packet_payload()).json() + created = client.post( + "/v0/review-queue", + json={"packet": packet, "sla_hours": 24, "queue_dir": str(queue_dir)}, + ) + queue_id = created.json()["queue_id"] + + resp = client.post( + f"/v0/review-queue/{queue_id}/grant", + json={"grant_id": "SCOPE-GRANT-BAD"}, + params={"queue_dir": str(queue_dir)}, + ) + assert resp.status_code == 400 + assert "expected decided" in resp.json()["detail"] def test_key_registry_rest_endpoints(client, tmp_path, monkeypatch): diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py index 8ae117a..81a2160 100644 --- a/tests/test_review_queue.py +++ b/tests/test_review_queue.py @@ -81,7 +81,8 @@ def test_review_queue_status_transitions(tmp_path): assert entry.status == "open" engine.assign_review_queue(out, {"reviewer_id": "rev1", "role": "protocol_owner"}) - assert ReviewQueue.load(out).status == "assigned" + engine.in_review_review_queue(out) + assert ReviewQueue.load(out).status == "in_review" engine.decide_review_queue(out, "SCOPE-DEC-TEST01") decided = ReviewQueue.load(out) diff --git a/tests/test_review_queue_dashboard.py b/tests/test_review_queue_dashboard.py new file mode 100644 index 0000000..078db04 --- /dev/null +++ b/tests/test_review_queue_dashboard.py @@ -0,0 +1,54 @@ +"""Tests for review queue HTML dashboard rendering.""" + +from __future__ import annotations + +from pathlib import Path + +from scope import ScopeEngine +from scope.review_queue import ReviewQueue +from scope.review_queue_render import render_queue_dashboard + +ROOT = Path(__file__).resolve().parent.parent + + +def _sample_packet(engine: ScopeEngine) -> dict: + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + } + record = {"record_id": "AKTA-DASH", "scientific_action_type": "A5_protocol_modification"} + return engine.create_packet(record, trigger) + + +def test_dashboard_renders_workflow_states(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + + needs_info = ReviewQueue.create(packet, persist=False) + needs_info.assign({"reviewer_id": "r1", "role": "protocol_owner"}) + needs_info.mark_in_review() + needs_info.mark_needs_information(reason="missing data") + needs_info.save(tmp_path / "needs_info.json") + + expired = ReviewQueue.create(packet, persist=False) + expired.expire() + expired.save(tmp_path / "expired.json") + + escalated = ReviewQueue.create(packet, persist=False) + escalated.assign({"reviewer_id": "r2", "role": "protocol_owner"}) + escalated.mark_escalated( + {"reviewer_id": "lead1", "role": "lab_operations_lead"}, + reason="SLA breach", + ) + escalated.save(tmp_path / "escalated.json") + + html = render_queue_dashboard(tmp_path) + assert "needs_information" in html + assert "expired" in html + assert "escalated" in html + assert "Status counts:" in html + assert html.count("needs_information") >= 1 + assert html.count("expired") >= 1 diff --git a/tests/test_review_queue_state_machine.py b/tests/test_review_queue_state_machine.py new file mode 100644 index 0000000..2456098 --- /dev/null +++ b/tests/test_review_queue_state_machine.py @@ -0,0 +1,158 @@ +"""Tests for review queue workflow state machine.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scope import ScopeEngine +from scope.errors import ScopeValidationError +from scope.review_queue import ReviewQueue +from scope.review_workflow import validate_transition + +ROOT = Path(__file__).resolve().parent.parent + + +def _sample_packet(engine: ScopeEngine) -> dict: + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_protocol", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + } + record = {"record_id": "AKTA-RQ", "scientific_action_type": "A5_protocol_modification"} + return engine.create_packet(record, trigger) + + +def test_forbidden_open_to_granted() -> None: + with pytest.raises(ScopeValidationError, match="Invalid"): + validate_transition("open", "granted") + + +def test_forbidden_assigned_to_granted() -> None: + with pytest.raises(ScopeValidationError, match="Invalid"): + validate_transition("assigned", "granted") + + +def test_forbidden_needs_information_to_granted() -> None: + with pytest.raises(ScopeValidationError, match="Invalid"): + validate_transition("needs_information", "granted") + + +def test_forbidden_needs_information_to_decided() -> None: + with pytest.raises(ScopeValidationError, match="Invalid"): + validate_transition("needs_information", "decided") + + +def test_information_received_workflow(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + entry = ReviewQueue.create(packet, persist=False) + entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"}) + entry.mark_in_review() + entry.mark_needs_information(reason="missing data") + entry.mark_information_received() + assert entry.status == "in_review" + entry.mark_decided("SCOPE-DEC-IR") + entry.mark_granted("SCOPE-GRANT-IR") + entry.save(tmp_path / "queue.json") + + +def test_escalation_records_reason_and_actor(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl") + packet = _sample_packet(engine) + entry = ReviewQueue.create(packet, persist=False) + entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"}) + entry.mark_escalated( + {"reviewer_id": "lead1", "role": "lab_operations_lead"}, + reason="SLA breach", + actor_id="system", + ) + assert entry.status == "escalated" + assert entry.to_artifact()["escalation_reason"] == "SLA breach" + assert entry.to_artifact()["escalation_actor_id"] == "system" + + +def test_escalation_emits_ledger_event(tmp_path: Path) -> None: + ledger_path = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=ledger_path) + packet = _sample_packet(engine) + queue_path = tmp_path / "queue.json" + entry = engine.create_review_queue(packet, queue_dir=tmp_path) + entry.save(queue_path) + entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"}) + entry.save(queue_path) + + engine.escalate_review_queue_entry( + queue_path, + {"reviewer_id": "lead1", "role": "lab_operations_lead"}, + reason="SLA breach", + actor_id="ops-bot", + ) + + events = ledger_path.read_text(encoding="utf-8").strip().splitlines() + escalations = [ + json.loads(line) + for line in events + if json.loads(line).get("event_type") == "review_queue_escalated" + ] + assert len(escalations) == 1 + metadata = escalations[0]["metadata"] + assert metadata["escalation_reason"] == "SLA breach" + assert escalations[0]["actor_id"] == "ops-bot" + + +def test_happy_path_workflow(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + out = tmp_path / "queue.json" + entry = ReviewQueue.create(packet, persist=False) + entry.save(out) + + entry = ReviewQueue.load(out) + entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"}) + entry.mark_in_review() + entry.mark_decided("SCOPE-DEC-WF01") + entry.mark_granted("SCOPE-GRANT-WF01") + entry.save(out) + + final = ReviewQueue.load(out) + assert final.status == "granted" + + +def test_expired_requires_reopen_before_grant(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + out = tmp_path / "queue.json" + entry = ReviewQueue.create(packet, persist=False) + entry.expire() + entry.save(out) + + loaded = ReviewQueue.load(out) + with pytest.raises(ScopeValidationError, match="expired"): + loaded.mark_granted("SCOPE-GRANT-EX") + + +def test_reopen_from_expired(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + out = tmp_path / "queue.json" + entry = ReviewQueue.create(packet, persist=False) + entry.expire() + entry.reopen() + assert entry.status == "open" + entry.save(out) + + +def test_needs_information_blocks_decided(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _sample_packet(engine) + entry = ReviewQueue.create(packet, persist=False) + entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"}) + entry.mark_in_review() + entry.mark_needs_information(reason="missing data") + with pytest.raises(ScopeValidationError, match="needs_information"): + entry.mark_decided("SCOPE-DEC-NI") diff --git a/tests/test_review_session.py b/tests/test_review_session.py index aa71faf..cad2cbe 100644 --- a/tests/test_review_session.py +++ b/tests/test_review_session.py @@ -80,6 +80,15 @@ def test_two_valid_reviews_grant(): ) grant = engine.issue_grant_from_session(session, packet, [d1, d2]) assert grant["authorization"]["approved_scope"] == "single_validation_plan" + assert grant["provenance"]["authority_checks"] == d1["provenance"]["authority_checks"] + assert ( + grant["provenance"]["identity_assurance_level"] + == d1["provenance"]["identity_assurance_level"] + ) + assert ( + grant["provenance"]["role_resolution_source"] + == d1["provenance"]["role_resolution_source"] + ) def test_conflict_blocks_grant(): diff --git a/tests/test_signing_assurance.py b/tests/test_signing_assurance.py new file mode 100644 index 0000000..7deb599 --- /dev/null +++ b/tests/test_signing_assurance.py @@ -0,0 +1,68 @@ +"""Tests for signing assurance levels.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scope.errors import GrantValidationError, ScopeValidationError +from scope.signing_assurance import ( + SAL0, + SAL1, + SAL2, + HsmKmsSigningProvider, + check_minimum_signing_assurance, + load_minimum_signing_assurance, + resolve_signing_assurance_level, +) + +ROOT = Path(__file__).resolve().parent.parent + + +def test_sal0_unsigned() -> None: + assert resolve_signing_assurance_level({"decision_id": "D1"}) == SAL0 + + +def test_sal1_local_signature() -> None: + artifact = { + "decision_id": "D1", + "decision_signature": "abc", + "reviewer_public_key_ref": "reviewer1.pub", + } + level = resolve_signing_assurance_level(artifact, provider_name="local_pem") + assert level in (SAL0, SAL1) + + +def test_sal2_env_provider() -> None: + artifact = {"decision_id": "D1", "decision_signature": "abc"} + level = resolve_signing_assurance_level(artifact, provider_name="env_key") + assert level == SAL2 + + +def test_minimum_signing_enforced_in_production(tmp_path: Path) -> None: + policy_dir = tmp_path / "policy" + policy_dir.mkdir() + cfg = ROOT / "policy" / "minimum_signing_assurance.yaml" + (policy_dir / "minimum_signing_assurance.yaml").write_text( + cfg.read_text(encoding="utf-8"), encoding="utf-8" + ) + with pytest.raises(GrantValidationError, match="Signing assurance"): + check_minimum_signing_assurance( + SAL0, + policy_dir, + approved_scope="robot_queue_submission", + production=True, + ) + + +def test_minimum_policy_loaded() -> None: + cfg = load_minimum_signing_assurance(ROOT / "policy") + assert cfg.get("minimum_level") == "SAL1" + assert "robot_queue_submission" in cfg.get("high_risk_scopes", []) + + +def test_hsm_provider_is_external_stub() -> None: + provider = HsmKmsSigningProvider() + with pytest.raises(ScopeValidationError, match="HSM"): + provider.get_signer() diff --git a/tests/test_signing_identity.py b/tests/test_signing_identity.py index a6d926a..042486e 100644 --- a/tests/test_signing_identity.py +++ b/tests/test_signing_identity.py @@ -90,7 +90,7 @@ def test_sign_enforces_reviewer_key_registry(tmp_path): registry_path.write_text( yaml.dump( { - "version": "scope-core-v0.6", + "version": "scope-core-v0.7", "reviewers": {"rev1": expected_ref}, } ),
Due At Reviewer OverdueEscalated