From 833aa35f89a8585481c7a678de2964be5e6d23f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 09:15:29 +0900 Subject: [PATCH 01/21] Add human review receipt foundation --- ...6-07-20-ai-native-figure-integrity-plan.md | 680 ++++++++++++++++++ hub_core/human_review_receipt.py | 67 ++ hub_core/human_review_receipt_json.py | 208 ++++++ hub_core/human_review_receipt_parts.py | 212 ++++++ hub_core/human_review_receipt_schema.py | 153 ++++ hub_core/human_review_receipt_types.py | 72 ++ hub_core/human_review_receipt_verification.py | 181 +++++ tests/human_review_receipt_helpers.py | 113 +++ tests/test_human_review_receipt.py | 204 ++++++ tests/test_human_review_receipt_policy.py | 209 ++++++ 10 files changed, 2099 insertions(+) create mode 100644 docs/specs/2026-07-20-ai-native-figure-integrity-plan.md create mode 100644 hub_core/human_review_receipt.py create mode 100644 hub_core/human_review_receipt_json.py create mode 100644 hub_core/human_review_receipt_parts.py create mode 100644 hub_core/human_review_receipt_schema.py create mode 100644 hub_core/human_review_receipt_types.py create mode 100644 hub_core/human_review_receipt_verification.py create mode 100644 tests/human_review_receipt_helpers.py create mode 100644 tests/test_human_review_receipt.py create mode 100644 tests/test_human_review_receipt_policy.py diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md new file mode 100644 index 0000000..2f0a1a3 --- /dev/null +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -0,0 +1,680 @@ +# FigOps AI-Native Figure Integrity Plan + +**Status:** canonical successor plan; implementation has not started. + +**Date:** 2026-07-20 + +**Contract targets:** `figops-human-review/1` and `figops-promotion-gate/1` +**Baseline:** `v0.20.0` is published on PyPI and as a GitHub Release. This plan +governs a later additive increment; it does not reopen or relabel that release. + +## 1. Authority and relationship to current release work + +This is the single source of truth for the next integrity increment: a closed +human-review/signoff receipt, a promotion gate that consumes it, one policy +resolution path, and the associated workflow and release governance. It is an +additive successor, not a rewrite of the published-release corrective work. + +The [2026-07-15 project-structure and runtime-integrity plan](2026-07-15-project-structure-runtime-integrity-plan.md) +is the historical corrective baseline for PR #224 and the published `v0.20.0` +release. Its role contract, runtime/result boundary, durable receipts, raw +integrity, copy-only organization, acceptance matrix, and release-gate evidence +remain binding foundation facts. This plan does not weaken, duplicate, or move +those requirements. + +The [architecture inventory](../architecture.md) and [roadmap](../ROADMAP.md) +contain the contemporaneous `v0.20.0` release-candidate implementation record; +their historical status wording does not supersede the published-release fact +above. The earlier [AI-native rearchitecture plan](2026-07-14-ai-native-figops-rearchitecture.md) +remains implemented context for bounded evidence, previews, policy projections, +and the v2/compatibility surface. Where those documents describe a current +implementation, they are evidence for this plan; where this plan specifies a +future approval lifecycle, this plan controls that new scope. + +The governing rule is: + +> Tools prove bounded facts and enforce declared boundaries. Models assemble +> evidence and propose work. A named accountable human alone accepts the +> scientific and communicative claim; promotion records that acceptance but +> never manufactures it. + +## 2. Purpose and success condition + +FigOps must remain useful to an AI-native research workflow without converting +an LLM's persuasive summary, a green renderer, or `manual_review_needed=false` +into a false publication claim. The target state is a reproducible, reviewable +chain from declared source data and scripts through a rendered artifact to an +explicit, revocable human decision that is bound to exactly those bytes and +their evidence. + +At completion, a protected publication promotion is possible only when all of +the following are true: + +1. Existing structural, provenance, claim, policy, and no-replace promotion + invariants pass. +2. The candidate has a closed, schema-valid review receipt whose subject digest + recomputes from the exact durable artifact and referenced evidence. +3. The receipt is an affirmative decision by an authorized human reviewer for + the requested decision scope, has not expired or been superseded, and + records no unresolved required concerns. +4. A single deterministic promotion-gate evaluator records every satisfied, + failed, unavailable, and explicitly waived gate before the existing durable + promotion primitive is invoked. + +This is evidence of a FigOps-controlled promotion decision, not a guarantee of +scientific truth, ethical approval, co-author consent, institutional approval, +or publisher acceptance. + +## 3. Responsibility boundary + +The terms **tool**, **LLM**, and **human** identify responsibility, not a +particular process. An LLM may be used by a human; it must still not be credited +as the accountable reviewer. A tool may make a deterministic recommendation; +it must still not claim scientific approval. + +| Decision area | Tool/kernel responsibility | LLM responsibility | Human responsibility | +|---|---|---|---| +| Inputs and execution | Contain paths; preserve raw identity; prefetch/verify authorized external raw; execute declared producers; hash inputs, scripts, config, environment, and outputs | Propose input mapping and run plan; explain failures | Confirm data selection is scientifically appropriate and authorized | +| Figure construction | Enforce declared I/O, timeouts, format facts, policy measurement, and immutable candidate identity | Select encodings; author/revise code; inspect bounded previews; propose targeted changes | Decide whether the figure communicates the intended result honestly | +| Evidence | Produce/recompute facts, typed lineage, policy projections, and durable receipts; reject missing or malformed evidence | Correlate evidence; identify uncertainty; prepare a review brief; never fill missing evidence by inference | Assess whether evidence supports the scientific claim and whether caveats are adequate | +| Policy | Resolve versioned policy inputs deterministically and disclose defaults, inheritance, and opt-outs | Recommend a policy only when supplied constraints support it | Choose lab/venue policy and approve a justified scoped exception where policy permits | +| Review and release | Verify review-receipt closure, subject binding, expiry/revocation, and gate precedence; perform no-replace promotion | Request review and summarize what changed; never self-sign or assert approval | Record the decision, identity/role assertion, scope, concerns, and authorization to promote or decline | + +### 3.1 Three classes of conclusions + +1. **Enforceable invariants** are binary system promises. They include + containment, role resolution, raw/source identity, artifact hashes, + schema validity, declared producer/output binding, runtime/result + disjointness, no-replace promotion, receipt closure, review subject digest, + signature/identity verification when configured, and gate precedence. A + missing, malformed, stale, or unverifiable invariant fails closed. +2. **Assistive evidence** is objective but incomplete information: visual + preflight, geometry diagnostics, regression deltas, policy measurements, + claim-inventory candidates, bounded previews, and LLM-written review briefs. + It may request revision or review. It cannot become an approval solely by + aggregation, confidence score, or absence of warnings. +3. **Human scientific judgement** covers causal interpretation, statistical + appropriateness in context, claim strength, visual honesty, accessibility + tradeoffs, authorship/consent, ethical or legal obligations, venue fit, and + submission. FigOps records an explicit decision about this judgement; it + neither computes nor substitutes for it. + +`needs_review` remains a queue state, not approval. `manual_review_needed=false` +means only that its producing checks did not request manual review; it is never +a signoff, a publishability verdict, or a bypass of an explicit review policy. + +## 4. Canonical research object model + +The human-facing project model is intentionally simple; declared roles, not +directory spelling or file extension, give it meaning. + +```text +project/ +├─ raw/ immutable or externally governed scientific inputs +├─ hub_scripts/ tracked analysis, figure, and shared source +├─ results/ durable derived data, tables, figures, evidence, publication +└─ (external runtime) jobs, snapshots, cache, logs, manifests, previews, temp +``` + +`raw`, `hub_scripts`, and `results` retain the `figops-project-v1.1` role +semantics specified by the 2026-07-15 SSOT. `runtime.*` remains external, +disposable, and path-disjoint from project and durable result roots. A runtime +manifest is operational evidence; a receipt is a compact durable projection. +No review receipt may embed a runtime path, raw data content, secret, or preview +blob. It may refer only to allow-listed durable logical IDs, content hashes, +policy IDs/versions, opaque manifest IDs, and compact evidence digests. + +The new review receipt is a `result.evidence` artifact. It does not replace the +existing durable lineage receipt: + +| Record | Owner and lifetime | Purpose | +|---|---|---| +| Runtime manifest | runtime; disposable | Detailed execution, diagnostics, logs, and preview references | +| Durable lineage receipt | `results/evidence`; durable | Binds raw/scripts/config/environment/input/output/claim lineage while remaining independent of runtime deletion | +| Human review receipt | `results/evidence`; durable and append-only | Binds a human decision to a candidate subject digest and the reviewed evidence set | +| Promotion-gate receipt | `results/evidence` and, when promoted, publication bundle; durable | Records deterministic admission/denial from the candidate, policy, lineage, and human review receipt | +| Publication bundle | `results/publication`; immutable after promotion | Frozen promoted figure(s) plus the minimum verified receipts and manifest | + +An existing result figure can remain reviewable without a signoff. It becomes +**promotion eligible** only through the existing machine eligibility plus the +new gate when the selected policy requires human signoff. It becomes +**promoted** only after the gate receipt and existing native no-replace +publication mechanism succeed. These terms are deliberately not synonyms for +scientific correctness or publisher acceptance. + +## 5. Ranked gap register + +This register records the observed delta from the published `v0.20.0` baseline, +not an assertion that current controls are absent. Existing safeguards named in +the evidence column are retained. + +| Rank | Gap | Why it matters | Current evidence/control | Required resolution | +|---:|---|---|---|---| +| P0 | No closed human review/signoff receipt or subject-bound approval lifecycle | A future promotion surface could mistake readiness or a model statement for approval | `publication_readiness/1` ends at `needs_review`; `artifact_audit` is explicitly non-approving | Add a closed review receipt, verifier, revocation/supersession semantics, and mandatory gate consumption | +| P0 | Existing result promotion admits machine-eligible project renders before an explicit review-policy gate | Machine eligibility is necessary but cannot represent human scientific acceptance | `hub_core/result_promotion.py` checks claim/policy/manifest eligibility and writes durable lineage receipt | Put a fail-closed promotion-gate admission boundary before promotion; keep the existing primitive unchanged | +| P1 | Policy resolution is distributed across render selection, artifact measurement, research-ops evidence, and readiness | Defaults, inheritances, and opt-outs can become inconsistent or impossible to audit as one decision | `artifact_policy_measurement.py`, `provenance_inputs.py`, and evidence `resolved_policy` snapshots | Add one canonical resolver/provenance model; migrate callers through compatibility adapters | +| P1 | No first-class distinction between exploratory artifacts and execution candidates | Draft work can be over-read as reproducible or promotable | Runtime boundary, durable receipt, and read-only readiness already distinguish evidence availability | Add declared workflow intent/state and promote only execution candidates with complete evidence | +| P1 | Review scope, reviewer authority, and conflict/exception handling are not encoded | A generic yes/no comment cannot establish what was reviewed or whether a waiver is authorized | Existing `manual_review_needed` and readiness findings surface uncertainty | Specify narrow decision scopes, role assertions, concern disposition, and policy-governed exception receipts | +| P2 | Legacy projects are readable/render-disabled but have no migration path into signoff-gated promotion | Forcing a schema upgrade would damage compatibility; silently exempting them would weaken trust | `legacy_structure_resolver.py`; legacy render refusal in CLI/MCP | Preserve legacy behavior; require explicit v1.1 migration plus fresh execution for new promotion workflows | +| P2 | CI proves code behavior but does not yet run approval-lifecycle or stale-review adversarial matrices | A new human-facing state machine needs resistance to replay, tampering, expiry, and profile drift | Current CI gates platform containment, actual-R, tests, Ruff; release discipline checks exist | Add deterministic lifecycle tests and release evidence gates without expanding ordinary PR workload unnecessarily | +| P3 | AI review briefs lack a stable non-authoritative exchange contract | Useful summaries can obscure missing evidence or sound like an approval | Bounded previews/evidence and non-approval language are present | Define an optional, redacted review-brief schema that tools label as assistive only | + +P0 blocks any release that exposes signoff-gated promotion. P1 blocks the +default-on policy or execution workflow. P2 may ship only with an explicit +owner and compatibility evidence. P3 is not a prerequisite for the core gate. + +## 6. Target architecture + +### 6.1 Closed human review receipt + +`figops-human-review/1` is a closed, canonical JSON DTO. Unknown keys, +duplicate JSON keys, non-finite values, absolute paths, raw values, runtime +paths, and mutable external references are rejected. It is an append-only +evidence record; corrections create a new receipt that explicitly supersedes an +older receipt. No mutation in place is allowed. + +The minimum receipt shape is conceptually: + +```json +{ + "schema_version": "figops-human-review/1", + "receipt_id": "review:sha256:...", + "decision": "approve_for_promotion", + "decision_scope": "figure_scientific_and_communication", + "subject": { + "project_id": "opaque-project-id", + "artifact_id": "result.figure:...", + "artifact_sha256": "...", + "lineage_receipt_sha256": "...", + "evidence_digest": "...", + "resolved_policy_digest": "...", + "subject_digest": "..." + }, + "reviewer": { + "principal_id": "configured-opaque-id", + "role": "scientific_reviewer", + "authority_assertion": "lab-policy/1" + }, + "reviewed_at": "2026-07-20T00:00:00Z", + "expires_at": "2026-10-18T00:00:00Z", + "concerns": [], + "waivers": [], + "supersedes": null, + "integrity": {"canonical_sha256": "..."} +} +``` + +The concrete schema may add allow-listed fields only through a schema-version +change. Its canonicalization is fixed, not left to an implementation: + +1. Parse UTF-8 without a BOM; reject duplicate object keys, non-finite numbers, + unsupported JSON values, and text that is not Unicode NFC. Normalize every + accepted string to Unicode NFC before validation. Times are RFC 3339 UTC with + a `Z` suffix and seconds precision. SHA-256 strings are lowercase hexadecimal. +2. Form the **review payload** by removing exactly the top-level `receipt_id` + and top-level `integrity` members. Validate the remaining closed DTO, + including its nested objects and ordered arrays, before hashing it. +3. Serialize that payload as UTF-8 JSON with lexicographically sorted object + keys by Unicode code point, compact separators `,` and `:`, no insignificant + whitespace, no escaping of non-ASCII characters, and no trailing newline. + Array order is semantic and is never sorted. Let `D` be the lowercase + SHA-256 digest of these bytes. +4. Require `receipt_id` to be exactly `review:sha256:` and `integrity` to be + exactly `{ "canonical_sha256": "" }` after canonical JSON parsing. The + two fields are derived witnesses of the same payload, not independent input. +5. Any local attestation or future signature signs the review-payload bytes + from step 3. It is stored outside the receipt payload or in a separately + versioned, non-self-signed envelope. A verifier recomputes `D` before it + considers the attestation. + +This makes the self-reference rule exact: neither `receipt_id` nor +`integrity.canonical_sha256` participates in `D`; all scientific, policy, +reviewer, scope, concern, waiver, expiry, and supersession content does. + +The receipt must also define these exact semantics: + +- Decisions are `approve_for_promotion`, `request_revision`, or `decline`; + only the first can satisfy a signoff-required gate. +- A receipt has one immutable subject digest. It binds the durable artifact + SHA-256, durable lineage receipt digest, normalized evidence digest, resolved + policy digest, project/figure logical identity, and decision scope. Any + changed input produces a different subject and requires a fresh review. +- Reviewer identity is a verified principal when an organization identity + provider is configured; otherwise it is a clearly labeled local attestation. + A local attestation can satisfy only policies that explicitly allow it. Free + text names alone are not an authorization mechanism. +- `authority_assertion` names the policy/role binding that permits the recorded + scope. It is a verifiable policy fact, not an LLM-produced label. +- Concerns are closed typed records. Each is resolved, waived by an authorized + exception, or blocks approval. A waiver contains policy rule, rationale, + authorized principal/role, subject digest, and expiry. A waiver never + suppresses an invariant, a P0/P1 security failure, or a required scientific + signoff. +- Revocation and supersession are durable, append-only records. A revoked, + expired, non-current, malformed, or mismatched receipt cannot pass the gate. + The first delivery may support supersession by local receipt index; remote + revocation synchronization is an optional later integration, not an assumed + capability. +- The tool verifies receipt closure, `D`, and the applicable attestation before + use. A verifier never trusts a supplied `receipt_id` or integrity digest. + +The receipt records an accountable decision but intentionally cannot prove the +reviewer actually looked at pixels, understood the experiment, had all required +coauthor consent, or satisfied an external journal process. Those remain human +and organizational responsibilities. + +### 6.2 Deterministic promotion gate + +`figops-promotion-gate/1` is a pure domain evaluator plus a narrow admission +integration. Its inputs are: declared workflow intent, candidate artifact and +hash, existing verified runtime manifest/evidence, existing durable lineage +receipt, canonical resolved policy, optional valid review receipt, and requested +destination. The pure evaluator returns a complete stable list of gates and +exactly one of: `blocked`, `needs_revision`, `needs_review`, or `eligible`. + +Precedence is fixed: + +1. malformed, missing, untrusted, escaped, stale, or mismatched evidence; + failed invariant; invalid policy; or invalid receipt => `blocked`; +2. failed required automated or unresolved non-waivable finding => + `needs_revision`; +3. selected policy requires signoff and no matching current affirmative receipt + exists => `needs_review`; +4. all required gates and required signoff pass => `eligible`. + +The gate does not copy artifacts, modify reviews, or infer waivers. The +integration calls the current `result_promotion`/`durable_promotion` path only +after the pure gate emits `eligible`; it then persists a promotion-gate receipt +whose subject and evidence digests match the reviewed candidate. The promotion +operation, not the evaluator, may report `promoted` only after the existing +native no-replace durable promotion returns success. A failed or raced +destination is never presented as promoted. + +### 6.3 Central policy resolution and opt-outs + +All policy decisions must flow through a single domain resolver. The proposed +new `hub_core/policy_resolution.py` owns a versioned `ResolvedPolicySet` with +one canonical digest. It evaluates inputs in this decreasing authority order: + +1. immutable kernel invariants (not opt-out capable); +2. trusted launcher/operator policy; +3. repository/lab policy, when configured and verified; +4. declared project policy; +5. explicit per-render/per-promotion selection. + +This order is executable rather than advisory. Every shipped policy parameter +declares one merge operator in its schema: `require` (boolean OR), `minimum` +(numeric maximum), `maximum` (numeric minimum), `allowed_set` (set +intersection), `exact` (all specified values must be equal), or `selection` +(the requested value must belong to the intersection of allowed values). The +resolver rejects an unknown parameter or operator. It accumulates constraints +from every applicable layer; an empty intersection, an impossible numeric range, +an unequal `exact` value, a non-membership selection, or a type/version mismatch +is `POLICY_CONFLICT` and blocks execution/promotion. It never chooses a winner +by source order. + +An opt-out is a typed request only for a parameter whose schema declares +`opt_out_allowed=true`. It resolves to disabled only when no higher layer emits +`require=true` for that parameter and no applicable `minimum`, `maximum`, +`allowed_set`, or `exact` constraint is violated. Exceptions and waivers do not +participate in policy resolution and cannot change a resolved parameter value. +They are instead gate inputs: an exception must name one emitted policy finding +code, the exact candidate subject digest, an authorized principal/role, +rationale, and expiry; the gate accepts it only when that finding's parameter +schema declares `waivable=true`. Kernel-invariant parameters declare both +`opt_out_allowed=false` and `waivable=false`. + +The resolved output lists every candidate constraint and records `value`, +`merge_operator`, `source`, `policy_id`, `version`, `opt_out_requested`, and +`opt_out_accepted`. It is canonicalized with the same JSON rules stated for +review payloads, except it has no derived receipt fields; its SHA-256 is the +policy-set digest. Ambiguity, unknown policy versions, or an invalid policy +source therefore fail closed. + +Existing module defaults and explicit `false` research-ops opt-outs remain +backward compatible. They migrate as explicit resolved facts, preserving the +current source distinctions (for example `module-default`, `project_config`, +and `explicit_project_opt_out` where already emitted). The following are never +opt-out capable: path containment, raw/producer verification where selected, +schema/receipt integrity, no-replace promotion, runtime/result disjointness, +or a human signoff demanded by the resolved promotion policy. A policy may +choose that a class of exploratory result does not require signoff, but it must +say so explicitly and it cannot call that result promoted or publication-ready. + +During migration, the existing singular evidence `resolved_policy` field stays +readable. It becomes a compatibility projection of the canonical policy set for +render-policy consumers; it must not be duplicated under competing names. + +### 6.4 Exploration versus execution + +The workflow has explicit intent rather than accidental status inferred from a +directory: + +| Mode | Permitted work | Required evidence | Promotion meaning | +|---|---|---|---| +| `exploration` | Inspect data, author scripts, render drafts, revise from bounded previews, use advisory diagnostics | Containment and safe render evidence; incomplete provenance is surfaced, not invented | Never eligible or promotable; no signoff is requested as a substitute for missing execution evidence | +| `execution` | Run declared project producer against declared inputs under the role/runtime contract | Complete required provenance, claim/measurement policy evidence, candidate identity, and durable lineage receipt | May become `needs_review`, `eligible`, then `promoted` only through the full gate | +| `review` | Read exact candidate/evidence, record decision/concerns/waivers | Closed review receipt bound to an execution candidate | Cannot alter candidate bytes or evidence; a new candidate requires a new review | +| `promotion` | Evaluate admission and invoke existing no-replace result publication | Passed deterministic gate and, where required, current signoff receipt | Produces immutable publication bundle and promotion receipt, or fails without replacement | + +Exploration may produce durable draft outputs if the current result contract +allows them, but the workflow marks them `non_promotable` and preserves that +fact in the evidence. Execution does not make a figure scientifically correct; +it makes it a well-bound candidate for review. + +For compatibility, workflow intent resolves by the operation rather than by a +silent global default: + +| Existing path without `workflow.intent` | Resolved intent | Source and effect | +|---|---|---| +| `orchestrator.py --project ... --step all`, `analysis`, or `plot` on an active project | `execution` | `compatibility-project-execution`; preserves the current declared project-pipeline behavior and remains subject to all current evidence gates | +| MCP `figops.render_project_script` or `figops.render_project_figure` on an active project | `execution` | `compatibility-project-execution`; preserves current project-render and machine-promotion eligibility behavior until a selected policy requires the new review gate | +| MCP one-call render paths (`figops.render_basic_csv`, `figops.render_csv_graph`, and `figops.render_csv_multipanel`), previews, and direct draft render helpers | `exploration` | `compatibility-direct-exploration`; output is non-promotable unless the caller moves to an explicit declared project execution | +| Read, inspect, audit, validation, and readiness paths | no execution intent | read-only; they create no candidate and cannot make a result promotable | +| `project.status: legacy` or an existing refused legacy render path | unchanged legacy behavior | rendering stays disabled where currently disabled; no compatibility intent bypass exists | + +New scaffolded/configured projects must write `workflow.intent: execution` +explicitly. A caller may select `exploration` explicitly for a project draft, +but no direct-render or legacy compatibility route may select `execution` merely +by supplying a review receipt. Every resolved intent and its source enters the +policy/evidence projection. + +### 6.5 Legacy behavior + +`project.status: legacy` retains the existing render-disabled behavior. The +in-memory legacy structure resolver remains read-only. Legacy aliases and +compatibility defaults continue to behave as documented; no new aliases are +added solely for approval or promotion. + +A legacy project cannot obtain a new signoff-gated publication promotion from +historical artifacts. The migration is explicit: adopt the v1.1 role contract, +declare policy/workflow intent, run a fresh contained execution, produce current +lineage/evidence, and review the resulting candidate. The migration never +moves, rewrites, or retroactively certifies legacy bytes. Read-only readiness +evaluation of legacy evidence stays available and reports its limits. + +## 7. Phased work packages + +Each package is a coherent change with a named owner and an independent review. +No package changes the published `v0.20.0` corrective baseline or its release +state. Module names below distinguish existing targets from planned new modules. + +### Phase 0 — Contract lock and adversarial fixtures + +**Goal:** freeze public semantics before a write surface exists. + +- Record this plan's schema-closure decisions in a Phase 0 implementation + receipt that explicitly references this SSOT, and add machine-readable + fixtures under `tests/fixtures/figure_integrity/` (planned). The receipt + carries evidence only; it does not become a competing authority. +- Inventory current producer and receipt shapes in existing + `hub_core/evidence_contract.py`, `hub_core/durable_receipt.py`, + `hub_core/result_promotion.py`, `hub_core/publication_readiness.py`, and + `hub_core/provenance_inputs.py`; do not change producers in this phase. +- Establish fixture cases for canonicalization, duplicate keys, absolute paths, + malformed SHA-256, stale/expired/withdrawn/superseded review, subject mismatch, + policy drift, reviewer-role mismatch, replay to another figure, blocked + waiver, and no-signoff exploratory results. + +**Likely tests:** new `tests/test_human_review_receipt.py`, new +`tests/test_promotion_gate.py`, plus existing `tests/test_durable_receipt.py`, +`tests/test_evidence_contract.py`, `tests/test_claim_boundaries.py`, and +`tests/test_release_discipline.py`. + +**Exit:** an approved schema/precedence fixture matrix exists; each field has an +owner, privacy classification, and canonicalization rule; no runtime path or +approval claim can enter an existing receipt accidentally. + +### Phase 1 — Closed review receipt domain + +**Goal:** implement and verify `figops-human-review/1` without promotion writes. + +- Add planned `hub_core/human_review_receipt.py` for DTO construction, + canonical bytes/digest, strict parsing, subject binding, expiry, concern, + supersession, and revocation-index validation. +- Add planned `hub_core/human_review_identity.py` for a minimal verifier + interface. The first implementation supports a local policy-controlled + attestation and a test verifier; it must not silently claim federated identity + verification. +- Reuse `hub_core/durable_receipt.py` opaque-ID/redaction discipline and + `hub_core/evidence_contract.py` closed-envelope validation rather than + serializing arbitrary mappings. +- Add a read-only review inspection surface only after the domain contract is + stable, likely through existing `hub_core/mcp/tools/readiness_tools.py` and + `hub_core/mcp/schemas.py`; do not add a `graphhub.*` alias. + +**Likely tests:** new receipt tests above; existing +`tests/test_mcp_publication_readiness.py`, `tests/test_mcp_preview_resources.py`, +`tests/test_workflow_security.py`, and `tests/test_claim_boundaries.py` for +non-approval wording. + +**Exit:** receipt validation rejects all hostile fixtures; subject digest +recomputes; a clean automatic readiness report cannot be transformed into an +approval; no write tool or promotion behavior changes. + +### Phase 2 — Canonical policy resolver and workflow intent + +**Goal:** make policy selection and allowed opt-outs explainable from one +digestible source. + +- Add planned `hub_core/policy_resolution.py` and + `hub_core/workflow_intent.py`; provide a compatibility adapter for existing + `resolved_policy` evidence. +- Refactor only through focused callers: existing + `hub_core/artifact_policy_measurement.py`, `hub_core/render_evidence.py`, + `hub_core/provenance_inputs.py`, `hub_core/research_ops_enforcement.py`, + `hub_core/publication_readiness.py`, and + `hub_core/mcp/tools/render_project.py`. +- Extend `project_config_template.yaml` and + `hub_core/templates/project_config_template.yaml` together after the resolver + contract is locked. Preserve existing module defaults and `false` opt-outs. +- Teach `hub_core/config_parser.py` validation and migration to recognize the + additive workflow/policy fields. Unknown future policy versions fail closed + for execution/promotion but remain inspectable. + +**Likely tests:** existing `tests/test_render_evidence.py`, +`tests/test_render_project_policy_integration.py`, +`tests/test_wp2_integrity_readiness.py`, `tests/test_evidence_contract.py`, +`tests/test_research_ops_render_gates.py`, `tests/test_config_placeholders.py`, +and new `tests/test_policy_resolution.py` / `tests/test_workflow_intent.py`. + +**Exit:** all call paths emit equivalent policy decisions for equivalent input; +source and opt-out provenance are stable; exploration cannot be marked +promotable; no existing render/default behavior changes without an explicit +compatibility test. + +### Phase 3 — Pure promotion-gate evaluator + +**Goal:** decide eligibility deterministically without performing promotion. + +- Add planned `hub_core/promotion_gate.py` and + `hub_core/promotion_gate_receipt.py`. +- Consume, but do not reimplement, existing `hub_core/result_promotion.py` + eligibility facts, `hub_core/publication_evidence.py` normalization, + `hub_core/publication_readiness.py` state/finding evidence, + `hub_core/claim_inventory.py`, `hub_core/calculation_evidence.py`, and + `hub_core/durable_receipt.py`. +- Specify gate code ownership and stable precedence. The evaluator returns a + deterministic report/receipt candidate and has no filesystem mutation API. +- Add a read-only evaluation route alongside existing publication readiness, + likely in `hub_core/publication_cli.py`, `orchestrator.py`, and + `hub_core/mcp/tools/readiness_tools.py`, once its public schema is reviewed. + +**Likely tests:** new `tests/test_promotion_gate.py`; existing +`tests/test_publication_readiness.py`, `tests/test_publication_cli.py`, +`tests/test_mcp_publication_readiness.py`, `tests/test_result_promotion_integration.py`, +and `tests/test_calculation_evidence_lineage.py`. + +**Exit:** the exact same inputs yield byte-stable gate reports through domain, +CLI, and MCP; invalid review/lineage/policy evidence blocks; `needs_review` +never becomes `eligible` without a valid required receipt. + +### Phase 4 — Narrow signoff and promotion integration + +**Goal:** expose an explicitly write-gated review-recording path and interpose +the gate before the existing durable promotion primitive. + +- Add planned `hub_core/review_recording.py` to create append-only review + records below the declared evidence role using the same contained/no-clobber + standards as durable results. +- Integrate `hub_core/result_promotion.py` with `promotion_gate.py` only at its + admission boundary. Keep `hub_core/durable_promotion.py` and + `hub_core/atomic_no_clobber.py` as the only byte-publication primitives. +- Extend existing `hub_core/mcp/security.py`, `hub_core/mcp/schemas.py`, and + focused handler modules under `hub_core/mcp/tools/` for deliberate write + authorization. Read-only inspection stays available with writes disabled. +- Persist a promotion-gate receipt with the result and include it in the frozen + publication bundle. Failures leave no competing destination overwritten and + never backfill a review decision. + +**Likely tests:** existing `tests/test_durable_promotion.py`, +`tests/test_result_promotion_integration.py`, `tests/test_mcp_write_gating.py` +(if present at implementation time; otherwise add it), `tests/test_workflow_security.py`, +`tests/test_symlink_policy.py`, `tests/test_structure_path_security.py`, and +new end-to-end lifecycle fixtures. + +**Exit:** write-disabled MCP cannot record signoff or promote; a valid review +for a different hash cannot promote; concurrent destinations preserve the race +winner; runtime deletion leaves review and gate receipts verifiable; existing +non-review promotion behavior is unchanged until a policy explicitly selects +the new gate. + +### Phase 5 — Migration, release governance, and operational dogfood + +**Goal:** make the new lifecycle trustworthy in supported workflows without +making normal development CI perform expensive visual work. + +- Publish migration examples for active v1.1 projects and a read-only legacy + explanation. Add deprecation warnings only after compatibility evidence. +- Update generated tool references through the live registry process; preserve + v2/compatibility surface counts unless an explicit release decision changes + them. +- Extend `.github/workflows/ci.yml` with deterministic receipt/gate tests in + existing gating jobs. Keep full render-pack/model visual dogfood manually + dispatched, local, or path-filtered as [the roadmap](../ROADMAP.md) requires. +- Extend `tests/test_release_discipline.py`, `tests/test_public_release_check.py`, + and packaging/release decision records with a gate that prevents a release + from claiming signoff-gated promotion before lifecycle and platform witnesses + exist. + +**Exit:** migration and downgrade behavior are documented and tested; CI runs +the lifecycle adversarial matrix on supported platforms; release review has an +exact-commit, signed/attested human decision record where the selected release +policy requires one. + +## 8. Compatibility and migration rules + +1. The existing public evidence schemas, readiness states, role contract, + `manual_review_needed`, `promotion_eligible`, CLI commands, and frozen MCP + aliases remain readable. New fields and tools are additive during the first + release that contains this work. +2. Existing `publication_readiness/1` retains exactly `blocked`, + `needs_revision`, and `needs_review`. The promotion-gate state machine is a + separate contract; it does not redefine readiness. +3. Existing `result_promotion` callers retain their current machine eligibility + behavior until a selected policy explicitly requires the new gate. A release + must document when the default changes, provide a migration example, and + preserve an explicit compatibility selection for at least two minor releases + unless a security issue requires faster removal. +4. Existing research-ops `false` opt-outs remain honored only for their current + scoped rules. Migration must not broaden them into a signoff or invariant + bypass. The resolved policy records exactly why each default or opt-out was + used. +5. Legacy projects remain read-only/disabled as specified in the 2026-07-15 + SSOT. No automatic folder move, receipt backfill, or retrospective approval + occurs. +6. Receipt schemas are versioned and migratable for reading. A migration may + normalize/annotate legacy data but cannot invent a reviewer, signature, + affirmative decision, expiry, or missing subject binding. + +## 9. Non-goals + +- Automatic scientific approval, authorship attribution, coauthor consent, + IRB/ethics determination, legal review, or publisher acceptance. +- An arbitrary-code API, a broad external identity/SSO integration, a hosted + approval service, blockchain/notarization, or remote approval synchronization + in the first implementation. +- Replacing the `figops-project-v1.1` structure contract, external runtime + boundary, native no-replace promotion primitive, durable lineage receipt, or + current v2/legacy MCP compatibility policy. +- Treating an LLM visual inspection, a model score, a preview read, or a + generated narrative as a human signoff. +- Silently changing scientific/visual policy defaults, mutating historical + results, moving legacy projects, or adding a mandatory cloud provider/DVC. +- Running costly render packs, live-model evaluation, or external publication + actions as an automatic result of ordinary source changes. + +## 10. Acceptance criteria and release gates + +### 10.1 Product acceptance + +- A closed review receipt binds its decision to exact durable artifact, + lineage/evidence/policy subject digests and fails verification on any mismatch. +- Unknown fields, duplicate keys, bad encoding, path/secret leaks, non-finite + values, invalid hash/ID, stale receipts, replay, revoked/superseded receipt, + unclosed concern, unauthorized reviewer role, and invalid waiver fail closed. +- A policy resolver emits one canonical policy-set digest and explains every + applied default, inheritance, opt-out, and exception; immutable invariants + cannot be disabled. +- Exploratory renders are visibly non-promotable. Execution candidates require + complete current evidence before a review receipt can satisfy promotion. +- Readiness remains non-approving. An affirmative human receipt is insufficient + if automatic invariants or required policy gates fail. +- The promotion gate evaluates identically through pure domain, CLI, and MCP + surfaces. It does not mutate state; only the dedicated write-gated integration + records receipts or calls promotion. +- A successful promotion uses the existing native no-replace path, writes + durable lineage/review/gate receipts, and remains verifiable after runtime + deletion. All failed paths preserve existing artifacts and do not create a + false promoted state. +- Legacy and compatibility behaviors retain their documented read-only/default + semantics and receive no invented approval. + +### 10.2 CI and release gates + +Before any release exposes signoff-gated promotion, all are required on the +exact release commit: + +1. Locked full pytest and Ruff are green, including the current macOS path + identity, Windows containment/symlink zero-skip, and actual-R gates required + by the 2026-07-15 SSOT. +2. Receipt and promotion-gate fixture matrices pass on Windows and macOS, with + zero skipped security/lifecycle cases. At minimum they cover tampering, + stale/revoked/superseded state, role/authority mismatch, policy drift, + cross-project/hash replay, write-disabled MCP, and no-replace race behavior. +3. Domain/CLI/MCP parity and deterministic canonical-byte/golden-report tests + pass; generated schemas and tool documentation show no unreviewed drift. +4. Compatibility and legacy regression suites pass. A release note identifies + every new default, migration step, and remaining policy-limited capability. +5. One operational dogfood run records a real execution candidate, bounded + review evidence, an explicit human decision, and a successful or safely + denied promotion. It is evidence of workflow operation, not a scientific or + publisher acceptance claim. +6. Required repository, legal, and release approvals are recorded separately + from figure-review receipts. The release process rechecks the technical gates + on the exact commit and follows the `v0.20.0` corrective baseline's release + discipline until its successor release policy is formally adopted. + +## 11. Decision log and review questions + +The following decisions are fixed for implementation unless a later dated SSOT +explicitly changes them: + +- Automatic quality evidence is never human approval. +- Review is bound to immutable subject digests, not a filename, job ID, or + mutable manifest path. +- Human approval records a limited decision scope; it does not claim universal + scientific or publication truth. +- Kernel invariants are not waivable. Policy exceptions are narrow, attributable, + expiring, and cannot convert missing evidence into a pass. +- Legacy artifacts are not retroactively approved. +- The existing durable promotion primitive remains the sole mechanism that can + publish bytes into the protected result/publication destination. + +Implementation must resolve the following before Phase 4 through an approved +Phase 0/1 receipt that explicitly points back to this SSOT: whether the first +supported human identity is local attestation only or a specific verified +identity provider; what review scopes/roles each shipped policy recognizes; the +default receipt expiry; and the operational owner of a local revocation index. +Until resolved, the system must expose read-only evaluation only and fail closed +for policies requiring verified signoff. diff --git a/hub_core/human_review_receipt.py b/hub_core/human_review_receipt.py new file mode 100644 index 0000000..008b134 --- /dev/null +++ b/hub_core/human_review_receipt.py @@ -0,0 +1,67 @@ +"""Compatible facade for the closed human review receipt domain.""" + +from __future__ import annotations + +from .human_review_receipt_json import ( + opaque_concern_id, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + opaque_waiver_id, +) +from .human_review_receipt_parts import build_review_subject, build_reviewer, calculate_subject_digest +from .human_review_receipt_schema import ( + build_human_review_receipt, + canonical_human_review_receipt_bytes, + canonical_review_payload_bytes, + human_review_receipt_digest, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + CONCERN_STATUSES, + DECISION_SCOPES, + DECISIONS, + REVIEWER_ROLES, + SCHEMA_VERSION, + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewVerificationResult, + HumanReviewWaiverBinding, +) +from .human_review_receipt_verification import verify_human_review_receipt + +__all__ = [ + "CONCERN_CATEGORIES", + "CONCERN_SEVERITIES", + "CONCERN_STATUSES", + "DECISIONS", + "DECISION_SCOPES", + "HumanReviewAuthorityBinding", + "HumanReviewReceiptError", + "HumanReviewReceiptIndex", + "HumanReviewVerificationPolicy", + "HumanReviewVerificationResult", + "HumanReviewWaiverBinding", + "REVIEWER_ROLES", + "SCHEMA_VERSION", + "build_human_review_receipt", + "build_review_subject", + "build_reviewer", + "calculate_subject_digest", + "canonical_human_review_receipt_bytes", + "canonical_review_payload_bytes", + "human_review_receipt_digest", + "opaque_concern_id", + "opaque_figure_artifact_id", + "opaque_principal_id", + "opaque_project_id", + "opaque_waiver_id", + "parse_human_review_receipt_bytes", + "validate_human_review_receipt", + "verify_human_review_receipt", +] diff --git a/hub_core/human_review_receipt_json.py b/hub_core/human_review_receipt_json.py new file mode 100644 index 0000000..93e2457 --- /dev/null +++ b/hub_core/human_review_receipt_json.py @@ -0,0 +1,208 @@ +"""JSON, canonicalization, and scalar helpers for human review receipts.""" + +from __future__ import annotations + +import hashlib +import json +import re +import unicodedata +from datetime import UTC, datetime +from typing import Any, Mapping, NoReturn + +from .human_review_receipt_types import HumanReviewReceiptError + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_32_RE = re.compile(r"^[0-9a-f]{32}$") +_RECEIPT_ID_RE = re.compile(r"^review:sha256:([0-9a-f]{64})$") +_AUTHORITY_ASSERTION_RE = re.compile(r"^[a-z][a-z0-9-]*/[1-9][0-9]*$") +_RFC3339_UTC_SECONDS_RE = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +_ABSOLUTE_OR_MUTABLE_REF_RE = re.compile( + r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", + re.IGNORECASE, +) +_DOMAIN_PREFIX = b"figops-human-review-v1\0" + + +def fail(message: str) -> NoReturn: + raise HumanReviewReceiptError(f"human review receipt {message}") + + +def closed_mapping(value: Any, allowed: set[str] | frozenset[str], field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + fail(f"{field} must be a mapping") + keys = set(value) + non_string = [key for key in keys if not isinstance(key, str)] + if non_string: + fail(f"{field} keys must be strings") + missing = sorted(allowed - keys) + unknown = sorted(keys - allowed) + if missing or unknown: + detail = [] + if missing: + detail.append(f"missing {', '.join(missing)}") + if unknown: + detail.append(f"unsupported {', '.join(unknown)}") + fail(f"{field} contains {' and '.join(detail)}") + return value + + +def text(value: Any, field: str, *, max_length: int) -> str: + if not isinstance(value, str) or not value: + fail(f"{field} must be a non-empty string") + value = unicodedata.normalize("NFC", value) + if value != value.strip() or len(value) > max_length: + fail(f"{field} must be canonical and at most {max_length} characters") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + fail(f"{field} may not contain control characters") + return value + + +def sha256(value: Any, field: str) -> str: + value = text(value, field, max_length=64) + if _SHA256_RE.fullmatch(value) is None: + fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def receipt_id(value: Any, field: str) -> str: + value = text(value, field, max_length=78) + if _RECEIPT_ID_RE.fullmatch(value) is None: + fail(f"{field} must be review:sha256:") + return value + + +def opaque_id(value: Any, field: str, namespace: str) -> str: + value = text(value, field, max_length=len(namespace) + 33) + prefix, separator, suffix = value.partition(":") + if prefix != namespace or separator != ":" or _OPAQUE_32_RE.fullmatch(suffix) is None: + fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + return value + + +def enum_value(value: Any, field: str, allowed: frozenset[str]) -> str: + value = text(value, field, max_length=64) + if value not in allowed: + fail(f"{field} has an unsupported value") + return value + + +def timestamp(value: Any, field: str) -> str: + value = text(value, field, max_length=20) + if _RFC3339_UTC_SECONDS_RE.fullmatch(value) is None: + fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError as exc: + raise HumanReviewReceiptError(f"human review receipt {field} must be a real timestamp") from exc + return value + + +def parse_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def authority_assertion(value: Any, field: str = "reviewer.authority_assertion") -> str: + value = text(value, field, max_length=64) + if _AUTHORITY_ASSERTION_RE.fullmatch(value) is None: + fail(f"{field} must be a versioned policy binding token") + return value + + +def reject_path_like_subject(value: str, field: str) -> None: + if _ABSOLUTE_OR_MUTABLE_REF_RE.search(value) or ".." in value or "\\" in value or "/" in value: + fail(f"{field} must not contain an absolute path, runtime/raw URI, or mutable path-like reference") + + +def canonical_json_bytes(value: Mapping[str, Any]) -> bytes: + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode( + "utf-8" + ) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise HumanReviewReceiptError(f"human review receipt must be finite JSON: {exc}") from exc + + +def digest(domain: str, payload: bytes) -> str: + return hashlib.sha256(_DOMAIN_PREFIX + domain.encode("ascii") + b"\0" + payload).hexdigest() + + +def opaque_domain_id(namespace: str, value: Any) -> str: + value = text(value, f"{namespace}_source_id", max_length=1024) + return f"{namespace}:{digest(f'opaque:{namespace}', value.encode('utf-8'))[:32]}" + + +def opaque_project_id(value: Any) -> str: + return opaque_domain_id("project", value) + + +def opaque_principal_id(value: Any) -> str: + return opaque_domain_id("principal", value) + + +def opaque_figure_artifact_id(value: Any) -> str: + return opaque_domain_id("result.figure", value) + + +def opaque_concern_id(value: Any) -> str: + return opaque_domain_id("concern", value) + + +def opaque_waiver_id(value: Any) -> str: + return opaque_domain_id("waiver", value) + + +def _normalize_json_value(value: Any) -> Any: + if isinstance(value, str): + return unicodedata.normalize("NFC", value) + if isinstance(value, list): + return [_normalize_json_value(item) for item in value] + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + fail("JSON object keys must be strings") + normalized_key = unicodedata.normalize("NFC", key) + if normalized_key in result: + raise HumanReviewReceiptError( + f"human review receipt contains duplicate JSON key after NFC normalization {normalized_key!r}" + ) + result[normalized_key] = _normalize_json_value(child) + return result + if value is None or isinstance(value, bool): + return value + fail("may contain only strings, booleans, null, arrays, and objects") + + +def _json_pairs_no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise HumanReviewReceiptError(f"human review receipt contains duplicate JSON key {key!r}") + result[key] = value + return result + + +def _json_constant(value: str) -> NoReturn: + raise HumanReviewReceiptError(f"human review receipt contains non-finite JSON value {value}") + + +def parse_json_bytes(data: bytes | bytearray | memoryview) -> Mapping[str, Any]: + try: + raw = bytes(data) + except (TypeError, ValueError) as exc: + raise HumanReviewReceiptError("human review receipt JSON input must be bytes") from exc + if raw.startswith(b"\xef\xbb\xbf"): + fail("JSON bytes must be UTF-8 without a BOM") + try: + decoded = raw.decode("utf-8") + parsed = json.loads(decoded, object_pairs_hook=_json_pairs_no_duplicates, parse_constant=_json_constant) + except UnicodeDecodeError as exc: + raise HumanReviewReceiptError("human review receipt JSON bytes must be valid UTF-8") from exc + except json.JSONDecodeError as exc: + raise HumanReviewReceiptError(f"human review receipt JSON is invalid: {exc.msg}") from exc + normalized = _normalize_json_value(parsed) + if not isinstance(normalized, Mapping): + fail("top-level JSON value must be an object") + return normalized diff --git a/hub_core/human_review_receipt_parts.py b/hub_core/human_review_receipt_parts.py new file mode 100644 index 0000000..2b3f2cc --- /dev/null +++ b/hub_core/human_review_receipt_parts.py @@ -0,0 +1,212 @@ +"""Subject, reviewer, concern, and waiver validation for review receipts.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from .human_review_receipt_json import ( + authority_assertion, + canonical_json_bytes, + closed_mapping, + digest, + enum_value, + fail, + opaque_id, + parse_timestamp, + reject_path_like_subject, + sha256, + timestamp, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + CONCERN_STATUSES, + DECISION_SCOPES, + IDENTITY_KINDS, + REVIEWER_ROLES, +) + +SUBJECT_FIELDS = { + "project_id", + "artifact_id", + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", +} +REVIEWER_FIELDS = {"principal_id", "role", "authority_assertion", "identity_kind"} +CONCERN_FIELDS = {"concern_id", "category", "severity", "status", "subject_digest", "finding_digest"} +WAIVER_FIELDS = { + "waiver_id", + "concern_id", + "policy_rule", + "rationale_digest", + "authorized_principal_id", + "authorized_role", + "subject_digest", + "expires_at", +} + + +def calculate_subject_digest(subject: Mapping[str, Any], decision_scope: str) -> str: + scope = enum_value(decision_scope, "decision_scope", DECISION_SCOPES) + partial = closed_mapping(subject, SUBJECT_FIELDS - {"subject_digest"}, "subject") + normalized = { + "project_id": opaque_id(partial["project_id"], "subject.project_id", "project"), + "artifact_id": opaque_id(partial["artifact_id"], "subject.artifact_id", "result.figure"), + "artifact_sha256": sha256(partial["artifact_sha256"], "subject.artifact_sha256"), + "lineage_receipt_sha256": sha256(partial["lineage_receipt_sha256"], "subject.lineage_receipt_sha256"), + "evidence_digest": sha256(partial["evidence_digest"], "subject.evidence_digest"), + "resolved_policy_digest": sha256(partial["resolved_policy_digest"], "subject.resolved_policy_digest"), + } + for field, value in normalized.items(): + reject_path_like_subject(value, f"subject.{field}") + return digest("subject", canonical_json_bytes({"decision_scope": scope, "subject": normalized})) + + +def build_review_subject( + *, + project_id: str, + artifact_id: str, + artifact_sha256: str, + lineage_receipt_sha256: str, + evidence_digest: str, + resolved_policy_digest: str, + decision_scope: str, +) -> dict[str, str]: + subject = { + "project_id": project_id, + "artifact_id": artifact_id, + "artifact_sha256": artifact_sha256, + "lineage_receipt_sha256": lineage_receipt_sha256, + "evidence_digest": evidence_digest, + "resolved_policy_digest": resolved_policy_digest, + } + return {**subject, "subject_digest": calculate_subject_digest(subject, decision_scope)} + + +def build_reviewer( + *, + principal_id: str, + role: str, + authority_assertion: str, + identity_kind: str = "local_attestation", +) -> dict[str, str]: + return validate_reviewer( + { + "principal_id": principal_id, + "role": role, + "authority_assertion": authority_assertion, + "identity_kind": identity_kind, + } + ) + + +def validate_subject(value: Any, decision_scope: str) -> dict[str, str]: + subject = closed_mapping(value, SUBJECT_FIELDS, "subject") + normalized = { + "project_id": opaque_id(subject["project_id"], "subject.project_id", "project"), + "artifact_id": opaque_id(subject["artifact_id"], "subject.artifact_id", "result.figure"), + "artifact_sha256": sha256(subject["artifact_sha256"], "subject.artifact_sha256"), + "lineage_receipt_sha256": sha256(subject["lineage_receipt_sha256"], "subject.lineage_receipt_sha256"), + "evidence_digest": sha256(subject["evidence_digest"], "subject.evidence_digest"), + "resolved_policy_digest": sha256(subject["resolved_policy_digest"], "subject.resolved_policy_digest"), + "subject_digest": sha256(subject["subject_digest"], "subject.subject_digest"), + } + for field, value in normalized.items(): + reject_path_like_subject(value, f"subject.{field}") + partial = {key: normalized[key] for key in SUBJECT_FIELDS - {"subject_digest"}} + if normalized["subject_digest"] != calculate_subject_digest(partial, decision_scope): + fail("subject.subject_digest does not match the normalized subject and decision scope") + return normalized + + +def validate_reviewer(value: Any) -> dict[str, str]: + reviewer = closed_mapping(value, REVIEWER_FIELDS, "reviewer") + return { + "principal_id": opaque_id(reviewer["principal_id"], "reviewer.principal_id", "principal"), + "role": enum_value(reviewer["role"], "reviewer.role", REVIEWER_ROLES), + "authority_assertion": authority_assertion(reviewer["authority_assertion"]), + "identity_kind": enum_value(reviewer["identity_kind"], "reviewer.identity_kind", IDENTITY_KINDS), + } + + +def validate_concerns(value: Any, subject_digest: str) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + fail("concerns must be an array") + normalized: list[dict[str, str]] = [] + seen: set[str] = set() + for index, raw in enumerate(value): + field = f"concerns[{index}]" + concern = closed_mapping(raw, CONCERN_FIELDS, field) + item = { + "concern_id": opaque_id(concern["concern_id"], f"{field}.concern_id", "concern"), + "category": enum_value(concern["category"], f"{field}.category", CONCERN_CATEGORIES), + "severity": enum_value(concern["severity"], f"{field}.severity", CONCERN_SEVERITIES), + "status": enum_value(concern["status"], f"{field}.status", CONCERN_STATUSES), + "subject_digest": sha256(concern["subject_digest"], f"{field}.subject_digest"), + "finding_digest": sha256(concern["finding_digest"], f"{field}.finding_digest"), + } + if item["subject_digest"] != subject_digest: + fail(f"{field}.subject_digest must match subject.subject_digest") + if item["concern_id"] in seen: + fail("concerns contains duplicate concern_id values") + seen.add(item["concern_id"]) + normalized.append(item) + return tuple(normalized) + + +def validate_waivers(value: Any, subject_digest: str, reviewed_at: str) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + fail("waivers must be an array") + normalized: list[dict[str, str]] = [] + reviewed = parse_timestamp(reviewed_at) + seen: set[str] = set() + for index, raw in enumerate(value): + field = f"waivers[{index}]" + waiver = closed_mapping(raw, WAIVER_FIELDS, field) + item = { + "waiver_id": opaque_id(waiver["waiver_id"], f"{field}.waiver_id", "waiver"), + "concern_id": opaque_id(waiver["concern_id"], f"{field}.concern_id", "concern"), + "policy_rule": authority_assertion(waiver["policy_rule"], f"{field}.policy_rule"), + "rationale_digest": sha256(waiver["rationale_digest"], f"{field}.rationale_digest"), + "authorized_principal_id": opaque_id( + waiver["authorized_principal_id"], f"{field}.authorized_principal_id", "principal" + ), + "authorized_role": enum_value(waiver["authorized_role"], f"{field}.authorized_role", REVIEWER_ROLES), + "subject_digest": sha256(waiver["subject_digest"], f"{field}.subject_digest"), + "expires_at": timestamp(waiver["expires_at"], f"{field}.expires_at"), + } + if item["subject_digest"] != subject_digest: + fail(f"{field}.subject_digest must match subject.subject_digest") + if parse_timestamp(item["expires_at"]) <= reviewed: + fail(f"{field}.expires_at must be later than reviewed_at") + if item["waiver_id"] in seen: + fail("waivers contains duplicate waiver_id values") + seen.add(item["waiver_id"]) + normalized.append(item) + return tuple(normalized) + + +def validate_concern_waiver_links( + decision: str, + concerns: Sequence[Mapping[str, str]], + waivers: Sequence[Mapping[str, str]], +) -> None: + concern_by_id = {concern["concern_id"]: concern for concern in concerns} + waiver_ids = [waiver["concern_id"] for waiver in waivers] + if len(waiver_ids) != len(set(waiver_ids)): + fail("waivers contains duplicate concern_id values") + if set(waiver_ids) - set(concern_by_id): + fail("waivers must reference existing concerns") + for concern in concerns: + has_waiver = concern["concern_id"] in waiver_ids + if concern["status"] == "waived" and not has_waiver: + fail("waived concerns require a matching waiver") + if concern["status"] != "waived" and has_waiver: + fail("waivers may only reference waived concerns") + if concern["category"] == "scientific" and concern["severity"] == "required" and has_waiver: + fail("required scientific concerns cannot be waived") + if decision == "approve_for_promotion" and concern["status"] == "unresolved": + fail("approve_for_promotion cannot record unresolved concerns") diff --git a/hub_core/human_review_receipt_schema.py b/hub_core/human_review_receipt_schema.py new file mode 100644 index 0000000..c0690fb --- /dev/null +++ b/hub_core/human_review_receipt_schema.py @@ -0,0 +1,153 @@ +"""Closed schema construction and canonicalization for human review receipts.""" + +from __future__ import annotations + +import hashlib +from typing import Any, Mapping, Sequence + +from .human_review_receipt_json import ( + canonical_json_bytes, + closed_mapping, + enum_value, + fail, + parse_json_bytes, + parse_timestamp, + receipt_id, + sha256, + timestamp, +) +from .human_review_receipt_parts import ( + SUBJECT_FIELDS, + calculate_subject_digest, + validate_concern_waiver_links, + validate_concerns, + validate_reviewer, + validate_subject, + validate_waivers, +) +from .human_review_receipt_types import DECISION_SCOPES, DECISIONS, SCHEMA_VERSION + +TOP_LEVEL_FIELDS = { + "schema_version", + "receipt_id", + "decision", + "decision_scope", + "subject", + "reviewer", + "reviewed_at", + "expires_at", + "concerns", + "waivers", + "supersedes", + "integrity", +} +PAYLOAD_FIELDS = TOP_LEVEL_FIELDS - {"receipt_id", "integrity"} + + +def validated_payload(value: Mapping[str, Any]) -> dict[str, Any]: + payload = closed_mapping(value, PAYLOAD_FIELDS, "payload") + if payload["schema_version"] != SCHEMA_VERSION: + fail(f"schema_version must be {SCHEMA_VERSION!r}") + decision = enum_value(payload["decision"], "decision", DECISIONS) + decision_scope = enum_value(payload["decision_scope"], "decision_scope", DECISION_SCOPES) + subject = validate_subject(payload["subject"], decision_scope) + reviewer = validate_reviewer(payload["reviewer"]) + reviewed_at = timestamp(payload["reviewed_at"], "reviewed_at") + expires_at = timestamp(payload["expires_at"], "expires_at") + if parse_timestamp(expires_at) <= parse_timestamp(reviewed_at): + fail("expires_at must be later than reviewed_at") + concerns = validate_concerns(payload["concerns"], subject["subject_digest"]) + waivers = validate_waivers(payload["waivers"], subject["subject_digest"], reviewed_at) + validate_concern_waiver_links(decision, concerns, waivers) + supersedes = payload["supersedes"] + if supersedes is not None: + supersedes = receipt_id(supersedes, "supersedes") + return { + "schema_version": SCHEMA_VERSION, + "decision": decision, + "decision_scope": decision_scope, + "subject": subject, + "reviewer": reviewer, + "reviewed_at": reviewed_at, + "expires_at": expires_at, + "concerns": [dict(item) for item in concerns], + "waivers": [dict(item) for item in waivers], + "supersedes": supersedes, + } + + +def canonical_review_payload_bytes(receipt_or_payload: Mapping[str, Any]) -> bytes: + if not isinstance(receipt_or_payload, Mapping): + fail("must be a mapping") + payload = {key: receipt_or_payload[key] for key in receipt_or_payload if key not in {"receipt_id", "integrity"}} + return canonical_json_bytes(validated_payload(payload)) + + +def human_review_receipt_digest(receipt_or_payload: Mapping[str, Any]) -> str: + return hashlib.sha256(canonical_review_payload_bytes(receipt_or_payload)).hexdigest() + + +def validate_human_review_receipt(value: Mapping[str, Any]) -> dict[str, Any]: + receipt = closed_mapping(value, TOP_LEVEL_FIELDS, "receipt") + payload = validated_payload({key: receipt[key] for key in PAYLOAD_FIELDS}) + payload_digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + full_receipt_id = receipt_id(receipt["receipt_id"], "receipt_id") + integrity = closed_mapping(receipt["integrity"], {"canonical_sha256"}, "integrity") + canonical_sha256 = sha256(integrity["canonical_sha256"], "integrity.canonical_sha256") + if full_receipt_id != f"review:sha256:{payload_digest}": + fail("receipt_id does not match canonical payload digest") + if canonical_sha256 != payload_digest: + fail("integrity.canonical_sha256 does not match canonical payload digest") + return {**payload, "receipt_id": full_receipt_id, "integrity": {"canonical_sha256": canonical_sha256}} + + +def build_human_review_receipt( + *, + decision: str, + decision_scope: str, + subject: Mapping[str, Any], + reviewer: Mapping[str, Any], + reviewed_at: str, + expires_at: str, + concerns: Sequence[Mapping[str, Any]] = (), + waivers: Sequence[Mapping[str, Any]] = (), + supersedes: str | None = None, +) -> dict[str, Any]: + payload = validated_payload( + { + "schema_version": SCHEMA_VERSION, + "decision": decision, + "decision_scope": decision_scope, + "subject": subject, + "reviewer": reviewer, + "reviewed_at": reviewed_at, + "expires_at": expires_at, + "concerns": concerns, + "waivers": waivers, + "supersedes": supersedes, + } + ) + payload_digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + return { + **payload, + "receipt_id": f"review:sha256:{payload_digest}", + "integrity": {"canonical_sha256": payload_digest}, + } + + +def canonical_human_review_receipt_bytes(receipt: Mapping[str, Any]) -> bytes: + return canonical_json_bytes(validate_human_review_receipt(receipt)) + + +def parse_human_review_receipt_bytes(data: bytes | bytearray | memoryview) -> dict[str, Any]: + return validate_human_review_receipt(parse_json_bytes(data)) + + +def normalize_expected_subject(expected_subject: Mapping[str, Any], decision_scope: str) -> dict[str, str]: + if not isinstance(expected_subject, Mapping): + fail("expected_subject must be a mapping") + if set(expected_subject) == SUBJECT_FIELDS - {"subject_digest"}: + subject = dict(expected_subject) + subject["subject_digest"] = calculate_subject_digest(subject, decision_scope) + return validate_subject(subject, decision_scope) + return validate_subject(expected_subject, decision_scope) diff --git a/hub_core/human_review_receipt_types.py b/hub_core/human_review_receipt_types.py new file mode 100644 index 0000000..511ba92 --- /dev/null +++ b/hub_core/human_review_receipt_types.py @@ -0,0 +1,72 @@ +"""Shared types for the closed human review receipt domain.""" + +from __future__ import annotations + +from dataclasses import dataclass + +SCHEMA_VERSION = "figops-human-review/1" + +DECISIONS = frozenset({"approve_for_promotion", "request_revision", "decline"}) +DECISION_SCOPES = frozenset( + { + "figure_scientific_and_communication", + "figure_visual_communication", + "scientific_claim_support", + } +) +REVIEWER_ROLES = frozenset({"scientific_reviewer", "principal_investigator", "corresponding_author"}) +CONCERN_CATEGORIES = frozenset({"scientific", "communication", "accessibility", "policy", "provenance"}) +CONCERN_SEVERITIES = frozenset({"required", "advisory"}) +CONCERN_STATUSES = frozenset({"resolved", "waived", "unresolved"}) +IDENTITY_KINDS = frozenset({"local_attestation"}) + + +class HumanReviewReceiptError(ValueError): + """Raised when a human review receipt fails the closed contract.""" + + +@dataclass(frozen=True, slots=True) +class HumanReviewAuthorityBinding: + """Exact policy binding that authorizes a reviewer for one decision scope.""" + + decision_scope: str + reviewer_role: str + authority_assertion: str + + +@dataclass(frozen=True, slots=True) +class HumanReviewWaiverBinding: + """Exact policy binding that authorizes one class of concern waiver.""" + + policy_rule: str + authorized_role: str + concern_category: str + concern_severity: str + + +@dataclass(frozen=True, slots=True) +class HumanReviewVerificationPolicy: + """Narrow verification policy for identity, reviewer authority, and waivers.""" + + allow_local_attestation: bool + reviewer_bindings: frozenset[HumanReviewAuthorityBinding] = frozenset() + waiver_bindings: frozenset[HumanReviewWaiverBinding] = frozenset() + + +@dataclass(frozen=True, slots=True) +class HumanReviewReceiptIndex: + """Storage-independent revocation/currentness facts supplied by a caller.""" + + revoked_receipt_ids: frozenset[str] = frozenset() + superseded_receipt_ids: frozenset[str] = frozenset() + current_receipt_ids: frozenset[str] | None = None + + +@dataclass(frozen=True, slots=True) +class HumanReviewVerificationResult: + """Result of fail-closed human review receipt verification.""" + + valid: bool + reason: str + receipt_id: str | None = None + canonical_sha256: str | None = None diff --git a/hub_core/human_review_receipt_verification.py b/hub_core/human_review_receipt_verification.py new file mode 100644 index 0000000..c372cb8 --- /dev/null +++ b/hub_core/human_review_receipt_verification.py @@ -0,0 +1,181 @@ +"""Fail-closed verification for closed human review receipts.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping + +from .human_review_receipt_json import authority_assertion, fail, parse_timestamp, receipt_id, sha256, timestamp +from .human_review_receipt_schema import ( + normalize_expected_subject, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + REVIEWER_ROLES, + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewVerificationResult, + HumanReviewWaiverBinding, +) + + +def verify_human_review_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, + *, + policy: HumanReviewVerificationPolicy, + now: datetime | str | None = None, + expected_subject: Mapping[str, Any] | None = None, + expected_subject_digest: str | None = None, + receipt_index: HumanReviewReceiptIndex | None = None, + require_approval: bool = True, +) -> HumanReviewVerificationResult: + try: + receipt = _load_receipt(receipt_or_bytes) + digest = receipt["integrity"]["canonical_sha256"] + full_receipt_id = receipt["receipt_id"] + _validate_policy(policy) + if require_approval and receipt["decision"] != "approve_for_promotion": + return HumanReviewVerificationResult(False, "decision_not_approval", full_receipt_id, digest) + if not _reviewer_authorized(receipt, policy): + return HumanReviewVerificationResult(False, "reviewer_not_authorized", full_receipt_id, digest) + if receipt["reviewer"]["identity_kind"] == "local_attestation" and not policy.allow_local_attestation: + return HumanReviewVerificationResult(False, "local_attestation_not_allowed", full_receipt_id, digest) + time_result = _verify_times(receipt, _coerce_now(now), full_receipt_id, digest) + if time_result is not None: + return time_result + if not _waivers_authorized(receipt, policy): + return HumanReviewVerificationResult(False, "waiver_not_authorized", full_receipt_id, digest) + subject_result = _verify_subjects(receipt, expected_subject, expected_subject_digest, full_receipt_id, digest) + if subject_result is not None: + return subject_result + index_result = _verify_index(receipt_index, full_receipt_id, digest) + return index_result or HumanReviewVerificationResult(True, "valid", full_receipt_id, digest) + except (HumanReviewReceiptError, TypeError, ValueError, AttributeError) as exc: + return HumanReviewVerificationResult(False, str(exc)) + + +def _load_receipt(receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview) -> dict[str, Any]: + if isinstance(receipt_or_bytes, (bytes, bytearray, memoryview)): + return parse_human_review_receipt_bytes(receipt_or_bytes) + return validate_human_review_receipt(receipt_or_bytes) + + +def _validate_policy(policy: HumanReviewVerificationPolicy) -> None: + if not isinstance(policy, HumanReviewVerificationPolicy): + fail("verification policy must be a HumanReviewVerificationPolicy") + if not isinstance(policy.allow_local_attestation, bool): + fail("verification policy allow_local_attestation must be boolean") + for binding in policy.reviewer_bindings: + if not isinstance(binding, HumanReviewAuthorityBinding): + fail("verification policy reviewer_bindings must contain HumanReviewAuthorityBinding entries") + authority_assertion(binding.authority_assertion, "verification policy reviewer authority_assertion") + for binding in policy.waiver_bindings: + if not isinstance(binding, HumanReviewWaiverBinding): + fail("verification policy waiver_bindings must contain HumanReviewWaiverBinding entries") + authority_assertion(binding.policy_rule, "verification policy waiver policy_rule") + if binding.authorized_role not in REVIEWER_ROLES: + fail("verification policy waiver authorized_role is unsupported") + if binding.concern_category not in CONCERN_CATEGORIES or binding.concern_severity not in CONCERN_SEVERITIES: + fail("verification policy waiver concern binding is unsupported") + + +def _reviewer_authorized(receipt: Mapping[str, Any], policy: HumanReviewVerificationPolicy) -> bool: + reviewer = receipt["reviewer"] + actual = HumanReviewAuthorityBinding( + decision_scope=receipt["decision_scope"], + reviewer_role=reviewer["role"], + authority_assertion=reviewer["authority_assertion"], + ) + return actual in policy.reviewer_bindings + + +def _waivers_authorized(receipt: Mapping[str, Any], policy: HumanReviewVerificationPolicy) -> bool: + concerns = {concern["concern_id"]: concern for concern in receipt["concerns"]} + for waiver in receipt["waivers"]: + concern = concerns[waiver["concern_id"]] + actual = HumanReviewWaiverBinding( + policy_rule=waiver["policy_rule"], + authorized_role=waiver["authorized_role"], + concern_category=concern["category"], + concern_severity=concern["severity"], + ) + if actual not in policy.waiver_bindings: + return False + return True + + +def _verify_times( + receipt: Mapping[str, Any], + check_time: datetime, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if check_time < parse_timestamp(receipt["reviewed_at"]): + return HumanReviewVerificationResult(False, "reviewed_at_in_future", full_receipt_id, digest) + if check_time >= parse_timestamp(receipt["expires_at"]): + return HumanReviewVerificationResult(False, "expired", full_receipt_id, digest) + for waiver in receipt["waivers"]: + if check_time >= parse_timestamp(waiver["expires_at"]): + return HumanReviewVerificationResult(False, "waiver_expired", full_receipt_id, digest) + return None + + +def _verify_subjects( + receipt: Mapping[str, Any], + expected_subject: Mapping[str, Any] | None, + expected_subject_digest: str | None, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if expected_subject is not None: + expected = normalize_expected_subject(expected_subject, receipt["decision_scope"]) + if receipt["subject"] != expected: + return HumanReviewVerificationResult(False, "subject_mismatch", full_receipt_id, digest) + if expected_subject_digest is not None: + expected_digest = sha256(expected_subject_digest, "expected_subject_digest") + if receipt["subject"]["subject_digest"] != expected_digest: + return HumanReviewVerificationResult(False, "subject_digest_mismatch", full_receipt_id, digest) + return None + + +def _verify_index( + index: HumanReviewReceiptIndex | None, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if index is None: + return None + _validate_index(index) + if full_receipt_id in index.revoked_receipt_ids: + return HumanReviewVerificationResult(False, "revoked", full_receipt_id, digest) + if full_receipt_id in index.superseded_receipt_ids: + return HumanReviewVerificationResult(False, "superseded", full_receipt_id, digest) + if index.current_receipt_ids is not None and full_receipt_id not in index.current_receipt_ids: + return HumanReviewVerificationResult(False, "not_current", full_receipt_id, digest) + return None + + +def _coerce_now(now: datetime | str | None) -> datetime: + if now is None: + return datetime.now(UTC).replace(microsecond=0) + if isinstance(now, str): + return parse_timestamp(timestamp(now, "now")) + if now.tzinfo is None or now.utcoffset() is None: + fail("now must be timezone-aware") + return now.astimezone(UTC).replace(microsecond=0) + + +def _validate_index(index: HumanReviewReceiptIndex) -> None: + if not isinstance(index, HumanReviewReceiptIndex): + fail("receipt_index must be a HumanReviewReceiptIndex") + for collection_name in ("revoked_receipt_ids", "superseded_receipt_ids"): + for item in getattr(index, collection_name): + receipt_id(item, f"receipt_index.{collection_name}[]") + if index.current_receipt_ids is not None: + for item in index.current_receipt_ids: + receipt_id(item, "receipt_index.current_receipt_ids[]") diff --git a/tests/human_review_receipt_helpers.py b/tests/human_review_receipt_helpers.py new file mode 100644 index 0000000..a165e4e --- /dev/null +++ b/tests/human_review_receipt_helpers.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any + +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewVerificationPolicy, + HumanReviewWaiverBinding, + build_human_review_receipt, + build_review_subject, + build_reviewer, + opaque_concern_id, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + opaque_waiver_id, +) + +DEFAULT_SCOPE = "figure_scientific_and_communication" +DEFAULT_AUTHORITY = "lab-policy/1" + + +def subject(decision_scope: str = DEFAULT_SCOPE) -> dict[str, str]: + return build_review_subject( + project_id=opaque_project_id("project-17"), + artifact_id=opaque_figure_artifact_id("figure-1.svg"), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope=decision_scope, + ) + + +def reviewer( + principal_source: str = "reviewer@example.invalid", + *, + role: str = "scientific_reviewer", +) -> dict[str, str]: + return build_reviewer( + principal_id=opaque_principal_id(principal_source), + role=role, + authority_assertion=DEFAULT_AUTHORITY, + ) + + +def policy( + *, + allow_local_attestation: bool = True, + role: str = "scientific_reviewer", + authority: str = DEFAULT_AUTHORITY, + waiver_bindings: frozenset[HumanReviewWaiverBinding] = frozenset(), +) -> HumanReviewVerificationPolicy: + return HumanReviewVerificationPolicy( + allow_local_attestation=allow_local_attestation, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope=DEFAULT_SCOPE, + reviewer_role=role, + authority_assertion=authority, + ) + } + ), + waiver_bindings=waiver_bindings, + ) + + +def receipt(**overrides: object) -> dict[str, Any]: + decision_scope = str(overrides.pop("decision_scope", DEFAULT_SCOPE)) + payload: dict[str, Any] = { + "decision": "approve_for_promotion", + "decision_scope": decision_scope, + "subject": overrides.pop("subject", subject(decision_scope)), + "reviewer": reviewer(), + "reviewed_at": "2026-07-20T00:00:00Z", + "expires_at": "2026-10-18T00:00:00Z", + "concerns": [], + "waivers": [], + "supersedes": None, + } + payload.update(overrides) + return build_human_review_receipt(**payload) + + +def advisory_waiver_fixture() -> tuple[dict[str, str], dict[str, str], HumanReviewWaiverBinding]: + review_subject = subject() + concern_id = opaque_concern_id("color-waiver") + concern = { + "concern_id": concern_id, + "category": "accessibility", + "severity": "advisory", + "status": "waived", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + waiver = { + "waiver_id": opaque_waiver_id("color-waiver-approval"), + "concern_id": concern_id, + "policy_rule": DEFAULT_AUTHORITY, + "rationale_digest": "6" * 64, + "authorized_principal_id": opaque_principal_id("pi@example.invalid"), + "authorized_role": "principal_investigator", + "subject_digest": review_subject["subject_digest"], + "expires_at": "2026-09-01T00:00:00Z", + } + binding = HumanReviewWaiverBinding( + policy_rule=DEFAULT_AUTHORITY, + authorized_role="principal_investigator", + concern_category="accessibility", + concern_severity="advisory", + ) + return concern, waiver, binding diff --git a/tests/test_human_review_receipt.py b/tests/test_human_review_receipt.py new file mode 100644 index 0000000..81a9f30 --- /dev/null +++ b/tests/test_human_review_receipt.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import unicodedata +from datetime import UTC, datetime + +import pytest + +from hub_core.human_review_receipt import ( + HumanReviewReceiptError, + build_review_subject, + build_reviewer, + canonical_human_review_receipt_bytes, + canonical_review_payload_bytes, + human_review_receipt_digest, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + parse_human_review_receipt_bytes, + validate_human_review_receipt, + verify_human_review_receipt, +) +from tests.human_review_receipt_helpers import policy, receipt, reviewer, subject + + +def test_happy_path_constructs_canonical_bytes_digest_and_verifies() -> None: + review = receipt() + + payload_bytes = canonical_review_payload_bytes(review) + full_bytes = canonical_human_review_receipt_bytes(review) + digest = human_review_receipt_digest(review) + verification = verify_human_review_receipt( + full_bytes, + policy=policy(), + now="2026-07-21T00:00:00Z", + expected_subject=review["subject"], + expected_subject_digest=review["subject"]["subject_digest"], + ) + + assert b"receipt_id" not in payload_bytes + assert b"integrity" not in payload_bytes + assert json.loads(full_bytes)["integrity"] == {"canonical_sha256": digest} + assert review["receipt_id"] == f"review:sha256:{digest}" + assert verification.valid + + +def test_parser_rejects_duplicate_json_keys() -> None: + review = receipt() + duplicate = ( + b'{"schema_version":"figops-human-review/1","schema_version":"figops-human-review/1",' + + canonical_human_review_receipt_bytes(review).lstrip(b"{") + ) + + with pytest.raises(HumanReviewReceiptError, match="duplicate JSON key"): + parse_human_review_receipt_bytes(duplicate) + + +def test_parser_rejects_bom_non_finite_json_and_non_bytes() -> None: + with pytest.raises(HumanReviewReceiptError, match="without a BOM"): + parse_human_review_receipt_bytes(b"\xef\xbb\xbf{}") + with pytest.raises(HumanReviewReceiptError, match="non-finite"): + parse_human_review_receipt_bytes(b'{"schema_version":NaN}') + with pytest.raises(HumanReviewReceiptError, match="input must be bytes"): + parse_human_review_receipt_bytes(None) # type: ignore[arg-type] + + +def test_unknown_top_level_and_nested_fields_fail_closed() -> None: + review = receipt() + review["unexpected"] = "value" + with pytest.raises(HumanReviewReceiptError, match="unsupported unexpected"): + validate_human_review_receipt(review) + + review = receipt() + review["reviewer"] = {**review["reviewer"], "display_name": "Dr. Reviewer"} + with pytest.raises(HumanReviewReceiptError, match="reviewer contains unsupported display_name"): + validate_human_review_receipt(review) + + +def test_tampering_with_payload_or_integrity_breaks_verification() -> None: + review = receipt() + review["decision"] = "decline" + result = verify_human_review_receipt(review, policy=policy(), now="2026-07-21T00:00:00Z") + assert not result.valid + assert "receipt_id does not match" in result.reason + + review = receipt() + review["integrity"] = {"canonical_sha256": "0" * 64} + with pytest.raises(HumanReviewReceiptError, match="integrity.canonical_sha256 does not match"): + validate_human_review_receipt(review) + + +def test_subject_mismatch_and_replay_are_rejected() -> None: + review = receipt() + replay_subject = build_review_subject( + project_id=opaque_project_id("different-project"), + artifact_id=review["subject"]["artifact_id"], + artifact_sha256=review["subject"]["artifact_sha256"], + lineage_receipt_sha256=review["subject"]["lineage_receipt_sha256"], + evidence_digest=review["subject"]["evidence_digest"], + resolved_policy_digest=review["subject"]["resolved_policy_digest"], + decision_scope=review["decision_scope"], + ) + + result = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + expected_subject=replay_subject, + ) + + assert not result.valid + assert result.reason == "subject_mismatch" + + +def test_subject_digest_binds_decision_scope() -> None: + review_subject = subject("figure_visual_communication") + with pytest.raises(HumanReviewReceiptError, match="subject.subject_digest does not match"): + receipt(decision_scope="figure_scientific_and_communication", subject=review_subject) + + +def test_malformed_ids_hashes_and_path_like_subject_values_are_rejected() -> None: + with pytest.raises(HumanReviewReceiptError, match="subject.artifact_sha256"): + build_review_subject( + project_id=opaque_project_id("project-17"), + artifact_id=opaque_figure_artifact_id("figure-1"), + artifact_sha256="A" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + + with pytest.raises(HumanReviewReceiptError, match="subject.project_id"): + build_review_subject( + project_id="C:/research/project", + artifact_id=opaque_figure_artifact_id("figure-1"), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + + +def test_timestamp_requires_utc_seconds_and_real_expiry_order() -> None: + with pytest.raises(HumanReviewReceiptError, match="reviewed_at"): + receipt(reviewed_at="2026-07-20T00:00:00+09:00") + with pytest.raises(HumanReviewReceiptError, match="reviewed_at"): + receipt(reviewed_at="2026-07-20T00:00:00.123Z") + with pytest.raises(HumanReviewReceiptError, match="expires_at must be later"): + receipt(expires_at="2026-07-20T00:00:00Z") + + +def test_nfc_string_normalization_is_used_for_canonical_payloads() -> None: + decomposed = "reviewer-e\u0301@example.invalid" + normalized_reviewer = reviewer(unicodedata.normalize("NFD", decomposed)) + review = receipt(reviewer=normalized_reviewer) + parsed = parse_human_review_receipt_bytes( + json.dumps(review, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + + assert unicodedata.is_normalized("NFC", parsed["reviewer"]["principal_id"]) + assert parsed == review + + +def test_direct_malformed_mapping_inputs_raise_domain_errors_or_invalid_results() -> None: + with pytest.raises(HumanReviewReceiptError, match="must be a mapping"): + canonical_review_payload_bytes(None) # type: ignore[arg-type] + with pytest.raises(HumanReviewReceiptError, match="keys must be strings"): + validate_human_review_receipt({1: "bad"}) + result = verify_human_review_receipt(None, policy=policy(), now="2026-07-21T00:00:00Z") # type: ignore[arg-type] + assert not result.valid + assert "must be a mapping" in result.reason + + +def test_local_attestation_cannot_be_mislabeled_as_verified_identity() -> None: + with pytest.raises(HumanReviewReceiptError, match="reviewer.identity_kind"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy/1", + identity_kind="federated_verified", + ) + + +def test_authority_assertion_requires_explicit_positive_numeric_version() -> None: + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy", + ) + + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy/0", + ) + + +def test_timezone_aware_datetime_now_is_supported() -> None: + result = verify_human_review_receipt(receipt(), policy=policy(), now=datetime(2026, 7, 21, tzinfo=UTC)) + assert result.valid diff --git a/tests/test_human_review_receipt_policy.py b/tests/test_human_review_receipt_policy.py new file mode 100644 index 0000000..3b13f58 --- /dev/null +++ b/tests/test_human_review_receipt_policy.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import pytest + +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewWaiverBinding, + opaque_concern_id, + opaque_principal_id, + opaque_waiver_id, + verify_human_review_receipt, +) +from tests.human_review_receipt_helpers import DEFAULT_AUTHORITY, advisory_waiver_fixture, policy, receipt, subject + + +def test_local_attestation_allowed_is_not_sufficient_without_reviewer_binding() -> None: + review = receipt() + denied = verify_human_review_receipt( + review, + policy=HumanReviewVerificationPolicy(allow_local_attestation=True), + now="2026-07-21T00:00:00Z", + ) + + assert not denied.valid + assert denied.reason == "reviewer_not_authorized" + + +def test_reviewer_role_scope_and_authority_must_match_policy_binding() -> None: + review = receipt() + wrong_scope = HumanReviewVerificationPolicy( + allow_local_attestation=True, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope="figure_visual_communication", + reviewer_role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ) + } + ), + ) + wrong_role = policy(role="principal_investigator") + wrong_authority = policy(authority="publication-policy/1") + + assert verify_human_review_receipt(review, policy=wrong_scope, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_role, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_authority, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + + +def test_local_attestation_permission_is_still_required_after_reviewer_binding() -> None: + review = receipt() + denied = verify_human_review_receipt( + review, + policy=policy(allow_local_attestation=False), + now="2026-07-21T00:00:00Z", + ) + allowed = verify_human_review_receipt(review, policy=policy(), now="2026-07-21T00:00:00Z") + + assert denied.reason == "local_attestation_not_allowed" + assert not denied.valid + assert allowed.valid + + +def test_expired_non_approval_and_index_states_fail_verification() -> None: + expired = verify_human_review_receipt( + receipt(expires_at="2026-07-21T00:00:00Z"), + policy=policy(), + now="2026-07-21T00:00:00Z", + ) + revision = verify_human_review_receipt( + receipt(decision="request_revision"), + policy=policy(), + now="2026-07-21T00:00:00Z", + ) + review = receipt() + revoked = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + receipt_index=HumanReviewReceiptIndex(revoked_receipt_ids=frozenset({review["receipt_id"]})), + ) + + assert expired.reason == "expired" + assert revision.reason == "decision_not_approval" + assert revoked.reason == "revoked" + + +def test_advisory_waiver_requires_policy_authorized_binding() -> None: + concern, waiver, binding = advisory_waiver_fixture() + review = receipt(subject=subject(), concerns=[concern], waivers=[waiver]) + no_waiver_policy = policy() + wrong_rule_policy = policy( + waiver_bindings=frozenset( + { + HumanReviewWaiverBinding( + policy_rule="publication-policy/1", + authorized_role="principal_investigator", + concern_category="accessibility", + concern_severity="advisory", + ) + } + ) + ) + authorized_policy = policy(waiver_bindings=frozenset({binding})) + + assert verify_human_review_receipt(review, policy=no_waiver_policy, now="2026-08-01T00:00:00Z").reason == ( + "waiver_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_rule_policy, now="2026-08-01T00:00:00Z").reason == ( + "waiver_not_authorized" + ) + assert verify_human_review_receipt(review, policy=authorized_policy, now="2026-08-01T00:00:00Z").valid + assert verify_human_review_receipt(review, policy=authorized_policy, now="2026-09-01T00:00:00Z").reason == ( + "waiver_expired" + ) + + +def test_waiver_policy_rule_requires_explicit_positive_numeric_version() -> None: + concern, waiver, binding = advisory_waiver_fixture() + waiver["policy_rule"] = "lab-policy" + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + receipt(subject=subject(), concerns=[concern], waivers=[waiver]) + + review = receipt() + malformed_policy = policy( + waiver_bindings=frozenset( + { + HumanReviewWaiverBinding( + policy_rule="lab-policy", + authorized_role=binding.authorized_role, + concern_category=binding.concern_category, + concern_severity=binding.concern_severity, + ) + } + ) + ) + result = verify_human_review_receipt(review, policy=malformed_policy, now="2026-07-21T00:00:00Z") + assert not result.valid + assert "verification policy waiver policy_rule" in result.reason + + +def test_required_scientific_concern_cannot_be_waived_at_receipt_validation() -> None: + review_subject = subject() + concern_id = opaque_concern_id("required-scientific") + concern = { + "concern_id": concern_id, + "category": "scientific", + "severity": "required", + "status": "waived", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + waiver = { + "waiver_id": opaque_waiver_id("blocked-required-scientific-waiver"), + "concern_id": concern_id, + "policy_rule": DEFAULT_AUTHORITY, + "rationale_digest": "6" * 64, + "authorized_principal_id": opaque_principal_id("pi@example.invalid"), + "authorized_role": "principal_investigator", + "subject_digest": review_subject["subject_digest"], + "expires_at": "2026-09-01T00:00:00Z", + } + + with pytest.raises(HumanReviewReceiptError, match="required scientific concerns cannot be waived"): + receipt(subject=review_subject, concerns=[concern], waivers=[waiver]) + + +def test_unresolved_approval_concern_is_rejected() -> None: + review_subject = subject() + concern = { + "concern_id": opaque_concern_id("axis-label-units"), + "category": "communication", + "severity": "required", + "status": "unresolved", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + + with pytest.raises(HumanReviewReceiptError, match="unresolved concerns"): + receipt(subject=review_subject, concerns=[concern]) + + +def test_malformed_policy_and_index_return_invalid_verification_results() -> None: + review = receipt() + bad_policy = verify_human_review_receipt( + review, + policy={"allow_local_attestation": True}, # type: ignore[arg-type] + now="2026-07-21T00:00:00Z", + ) + bad_index = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + receipt_index={"revoked_receipt_ids": []}, # type: ignore[arg-type] + ) + + assert not bad_policy.valid + assert "verification policy" in bad_policy.reason + assert not bad_index.valid + assert "receipt_index" in bad_index.reason From dda76abcfe03e25b2b7d484e5550255a6dfdf998 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 19:57:03 +0900 Subject: [PATCH 02/21] Add policy and workflow integrity contracts --- docs/architecture.md | 23 +- docs/packaging/public-release-status.md | 11 +- docs/tools-compatibility.md | 227 +++++++++ docs/tools-v2.md | 227 +++++++++ docs/tools.md | 454 ++++++++++++++++++ hub_core/artifact_policy_measurement.py | 160 +++++- hub_core/config_contract_defaults.py | 18 + hub_core/config_parser.py | 27 +- hub_core/config_workflow_intent.py | 149 ++++++ hub_core/evidence_contract.py | 49 +- hub_core/mcp/phase2_render_schemas.py | 109 +++++ .../mcp/render_project_integrity_context.py | 89 ++++ hub_core/mcp/render_response.py | 9 + hub_core/mcp/schemas.py | 4 + hub_core/mcp/tools/render_project.py | 87 ++-- hub_core/mcp/v2_tool_schemas.py | 3 + hub_core/policy_resolution.py | 271 +++++++++++ hub_core/policy_resolution_json.py | 115 +++++ hub_core/policy_resolution_types.py | 92 ++++ hub_core/render_evidence.py | 39 ++ .../templates/project_config_template.yaml | 4 + hub_core/workflow_intent.py | 280 +++++++++++ project_config_template.yaml | 4 + .../ai_native_agent_eval/final-v2.json | 16 +- tests/test_config_workflow_intent.py | 111 +++++ tests/test_evidence_contract.py | 78 +++ tests/test_mcp_rendering.py | 22 + tests/test_mcp_v2_tools.py | 19 + tests/test_policy_resolution.py | 222 +++++++++ tests/test_release_discipline.py | 47 +- tests/test_render_evidence.py | 192 ++++++++ .../test_render_project_policy_integration.py | 99 ++++ tests/test_workflow_intent.py | 230 +++++++++ 33 files changed, 3385 insertions(+), 102 deletions(-) create mode 100644 hub_core/config_contract_defaults.py create mode 100644 hub_core/config_workflow_intent.py create mode 100644 hub_core/mcp/phase2_render_schemas.py create mode 100644 hub_core/mcp/render_project_integrity_context.py create mode 100644 hub_core/policy_resolution.py create mode 100644 hub_core/policy_resolution_json.py create mode 100644 hub_core/policy_resolution_types.py create mode 100644 hub_core/workflow_intent.py create mode 100644 tests/test_config_workflow_intent.py create mode 100644 tests/test_policy_resolution.py create mode 100644 tests/test_workflow_intent.py diff --git a/docs/architecture.md b/docs/architecture.md index ba8f536..86146e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,9 @@ # FigOps - Architecture -> Companion to `docs/ROADMAP.md`. Describes the current v0.20.0 release-candidate -> architecture after the v0.19.0 release, including the AI-native v2 agent -> surface and the PR #224 declared-project/runtime-result integrity path. +> Companion to `docs/ROADMAP.md`. Describes the v0.20.0 published architecture +> baseline plus current Phase 2 follow-up/Draft PR context, including the +> AI-native v2 agent surface and the PR #224 declared-project/runtime-result +> integrity path. ## Layers and dependency direction @@ -88,7 +89,7 @@ policy-only; there is no import-linter contract in `.github/workflows/ci.yml` as of v0.20.0. Remaining over-budget files should be handled as scoped maintenance tracks rather than broad rewrites. -Current files over the approximate 800-line budget, measured on 2026-07-16 with +Current files over the approximate 800-line budget, measured on 2026-07-23 with the architecture inventory helper: ```bash @@ -101,14 +102,19 @@ python hub_uv.py run python scripts/architecture_inventory.py --format markdown No Python module in the tracked architecture roots (`hub_core`, `plotting`, and -`themes`) currently exceeds the 800-line split signal. Overlay -normalization now lives in `plotting/renderers/annotation_normalization.py`, +`themes`) currently exceeds the 800-line split signal. Render-project +workflow/policy integrity decisions now live in +`hub_core/mcp/render_project_integrity_context.py`, while the project-render MCP +tool preserves its compatibility imports. Overlay normalization now lives in `plotting/renderers/annotation_normalization.py`, while the public overlay façade and compatibility imports remain stable. Structure-plan destination binding now lives in `hub_core/structure_role_binding.py`, while private-stage and directory-lease cleanup lives in `hub_core/structure_stage_cleanup.py`; `hub_core/structure_apply.py` retains its private compatibility aliases while focusing on transactional execution and config compare-and-swap. +Workflow-intent config defaults, validation, and inspectable report assembly now +live in `hub_core/config_workflow_intent.py`, while `hub_core/config_parser.py` +keeps the public compatibility imports. The 2026-06-29 decomposition wave reduced the previous primary hotspots below 1000 lines while preserving compatibility shims: @@ -188,8 +194,9 @@ Windows safety smoke; source vector bytes are never substituted for a preview. The AI-native façade split remains intact after the structure work. Shared tool schema primitives live in `hub_core/mcp/tool_schema_common.py`, and the v1.1 project-structure tool schema lives in `hub_core/mcp/structure_schemas.py`, -while the registry façade continues to feed validation, discovery, and -generated references. Overlay normalization remains in +while Phase 2 project-render policy and workflow response schemas live in +`hub_core/mcp/phase2_render_schemas.py`. The registry façade continues to feed +validation, discovery, and generated references. Overlay normalization remains in `plotting/renderers/annotation_normalization.py`, with compatibility exports in the public overlay façade. diff --git a/docs/packaging/public-release-status.md b/docs/packaging/public-release-status.md index fdd338c..1ce5817 100644 --- a/docs/packaging/public-release-status.md +++ b/docs/packaging/public-release-status.md @@ -2,14 +2,14 @@ - Inventory valid: yes - Package distribution allowed: yes -- Repository technically eligible for public release: yes +- Repository technically eligible for public release: no - Repository publication authorized: yes -- Repository release allowed: yes +- Repository release allowed: no - Authorization evidence references: 1 -- Technical release gate: ok -- Technical blockers: 0 +- Technical release gate: blocked +- Technical blockers: 1 - Auto-fixable technical blockers: 0 -- Confirmation-required technical blockers: 0 +- Confirmation-required technical blockers: 1 Repository publication authorization is recorded in the authoritative inventory approval fields with validated HTTPS evidence references. The technical gate remains independent evidence; a release is allowed only when both authorization and technical eligibility are yes. @@ -19,3 +19,4 @@ Decision record: [public-release-decision-record.md](./public-release-decision-r | Family | Count | Status | Confirmation | Action | | --- | ---: | --- | --- | --- | +| post_tag_metadata | 1 | requires_release_decision | yes | Choose the next release version, then bump pyproject and changelog together. | diff --git a/docs/tools-compatibility.md b/docs/tools-compatibility.md index 83e3a1e..bf0d92b 100644 --- a/docs/tools-compatibility.md +++ b/docs/tools-compatibility.md @@ -4905,6 +4905,94 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_resources": { "items": { "maxLength": 256, @@ -4979,6 +5067,145 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/docs/tools-v2.md b/docs/tools-v2.md index 565acd2..de9b506 100644 --- a/docs/tools-v2.md +++ b/docs/tools-v2.md @@ -951,6 +951,94 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_uri": { "type": [ "string", @@ -982,6 +1070,145 @@ Render one configured project-local .py or .R figure; code and command strings a "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/docs/tools.md b/docs/tools.md index c7e61d9..ba6a7e8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -4905,6 +4905,94 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_resources": { "items": { "maxLength": 256, @@ -4979,6 +5067,145 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" @@ -6440,6 +6667,94 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_uri": { "type": [ "string", @@ -6471,6 +6786,145 @@ Render one configured project-local .py or .R figure; code and command strings a "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/hub_core/artifact_policy_measurement.py b/hub_core/artifact_policy_measurement.py index b6eab9f..84ae9d9 100644 --- a/hub_core/artifact_policy_measurement.py +++ b/hub_core/artifact_policy_measurement.py @@ -19,10 +19,12 @@ from .journal_geometry_policy import geometry_minimum_results from .journal_specs import get_preflight_spec from .output_verification import verify_output_file +from .policy_resolution import PolicyResolutionError, resolve_policy_set MEASUREMENT_IMPLEMENTATION: Final = "figops-artifact-policy-measurement" MEASUREMENT_VERSION: Final = "3" RULE_VERSION: Final = "3" +RENDER_POLICY_CONTEXT_SCHEMA: Final = "figops-render-policy-context/1" _SHA256: Final = re.compile(r"^[0-9a-f]{64}$") _RASTER_SUFFIXES: Final = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"} @@ -50,18 +52,78 @@ def resolve_render_policy_selection( the historical Nature effective default. """ - explicit = isinstance(style_policy, str) and bool(style_policy.strip()) - selected = str(style_policy).strip().lower() if explicit else "nature" if compatibility else "neutral" - if selected not in _RENDER_POLICIES: - raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + return resolve_render_policy_context( + {"style_policy": style_policy} if style_policy is not None else {}, + compatibility=compatibility, + )["render_policy"] + + +def resolve_render_policy_context( + arguments: Mapping[str, Any] | None = None, + *, + target_format: str | None = None, + compatibility: bool | None = None, + policy_layers: Sequence[Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + """Resolve render/validation policy through the canonical policy core. + + The returned context is additive for new callers: legacy callers may still + consume the singular ``render_policy`` and ``validation_target`` values, + while provenance-aware callers can bind to ``policy_set_sha256``. + """ + + if arguments is None: + arguments = {} + if not isinstance(arguments, Mapping): + raise ArtifactPolicyMeasurementError("render policy arguments must be a mapping") + v2_contract = arguments.get("v2_policy_contract") is True + compatibility_mode = (not v2_contract) if compatibility is None else bool(compatibility) + selected, source, raw_render_policy = _selected_render_policy( + arguments, + target_format=target_format, + compatibility=compatibility_mode, + ) + validation_target, validation_source = _selected_validation_target( + arguments, + target_format=target_format, + compatibility=compatibility_mode, + ) + layer_parameters: dict[str, Any] = {} + if source != "v2-default": + layer_parameters["render_policy"] = selected + if validation_target: + layer_parameters["validation_target"] = validation_target + layers = [dict(layer) for layer in policy_layers or ()] + if layer_parameters: + layers.append( + { + "source": "render", + "policy_id": f"render-context-{selected}", + "version": "1", + "parameters": layer_parameters, + } + ) + try: + policy_set = resolve_policy_set(layers) + except PolicyResolutionError as exc: + raise ArtifactPolicyMeasurementError(str(exc)) from exc + resolved_render = str(policy_set.value("render_policy").value) + if resolved_render not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {resolved_render}") + resolved_validation = policy_set.value("validation_target").value + render_policy = ( + dict(raw_render_policy) + if raw_render_policy is not None + else _legacy_render_policy(resolved_render, source) + ) return { - "id": f"render-{selected}", - "version": "1", - "source": "explicit-render-policy" if explicit else "compatibility-default" if compatibility else "v2-default", - "parameters": { - "style_policy": selected, - "mutates_journal_aesthetics": selected != "neutral", - }, + "schema_version": RENDER_POLICY_CONTEXT_SCHEMA, + "source": source, + "validation_source": validation_source, + "policy_set_sha256": policy_set.canonical_sha256(), + "policy_set": policy_set.to_json(), + "render_policy": render_policy, + "validation_target": resolved_validation, } @@ -72,24 +134,77 @@ def resolve_render_validation_policies( ) -> tuple[str, dict[str, Any]]: """Resolve separate validation/render policies for a render call site.""" - v2_contract = arguments.get("v2_policy_contract") is True + context = resolve_render_policy_context(arguments, target_format=target_format) + return str(context["validation_target"] or ""), dict(context["render_policy"]) + + +def _selected_render_policy( + arguments: Mapping[str, Any], + *, + target_format: str | None, + compatibility: bool, +) -> tuple[str, str, Mapping[str, Any] | None]: + raw_render_policy = arguments.get("resolved_render_policy") + if isinstance(raw_render_policy, Mapping): + return _style_from_resolved_policy(raw_render_policy), "explicit-render-policy", raw_render_policy + raw = arguments.get("style_policy") + if not isinstance(raw, str) or not raw.strip(): + raw = target_format + explicit = isinstance(raw, str) and bool(raw.strip()) + selected = str(raw).strip().lower() if explicit else "nature" if compatibility else "neutral" + if selected not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + source = "explicit-render-policy" if explicit else "compatibility-default" if compatibility else "v2-default" + return selected, source, None + + +def _selected_validation_target( + arguments: Mapping[str, Any], + *, + target_format: str | None, + compatibility: bool, +) -> tuple[str | None, str]: validation_target = str(arguments.get("validation_target") or "").strip().lower() - if not validation_target and not v2_contract: + source = "explicit-validation-target" if validation_target else "none" + if not validation_target and compatibility: + candidate = str(target_format or arguments.get("target_format") or "").strip().lower() try: - get_preflight_spec(target_format) + get_preflight_spec(candidate) except ValueError: pass else: - validation_target = target_format + validation_target = candidate + source = "compatibility-target-inference" if validation_target: get_preflight_spec(validation_target) - raw_render_policy = arguments.get("resolved_render_policy") - render_policy = ( - dict(raw_render_policy) - if isinstance(raw_render_policy, Mapping) - else resolve_render_policy_selection(target_format, compatibility=not v2_contract) - ) - return validation_target, render_policy + return validation_target or None, source + + +def _style_from_resolved_policy(policy: Mapping[str, Any]) -> str: + parameters = policy.get("parameters") + raw = parameters.get("style_policy") if isinstance(parameters, Mapping) else None + if not isinstance(raw, str) or not raw.strip(): + raw = parameters.get("render_policy") if isinstance(parameters, Mapping) else None + if not isinstance(raw, str) or not raw.strip(): + raw = policy.get("id") + selected = str(raw or "").strip().lower() + if selected.startswith("render-"): + selected = selected.removeprefix("render-") + if selected not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + return selected + + +def _legacy_render_policy(selected: str, source: str) -> dict[str, Any]: + return { + "id": f"render-{selected}", + "version": "1", + "source": source, + "parameters": { + "style_policy": selected, + "mutates_journal_aesthetics": selected != "neutral", + }, + } def measure_artifact_policy( @@ -633,6 +748,7 @@ def _canonical_sha256(value: Any) -> str: "MEASUREMENT_VERSION", "RULE_VERSION", "measure_artifact_policy", + "resolve_render_policy_context", "resolve_render_policy_selection", "resolve_render_validation_policies", "verify_artifact_policy_projection", diff --git a/hub_core/config_contract_defaults.py b/hub_core/config_contract_defaults.py new file mode 100644 index 0000000..92c9e99 --- /dev/null +++ b/hub_core/config_contract_defaults.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from .project_roles import DEFAULT_PROJECT_ROLE, project_role + + +def data_contract_bool(config: dict, key: str) -> bool | None: + data_contract = config.get("data_contract", {}) if isinstance(config, dict) else {} + if not isinstance(data_contract, dict): + return None + value = data_contract.get(key) + return value if isinstance(value, bool) else None + + +def module_default_contract_bool(config: dict, key: str) -> bool: + explicit = data_contract_bool(config, key) + if explicit is not None: + return explicit + return project_role(config) == DEFAULT_PROJECT_ROLE diff --git a/hub_core/config_parser.py b/hub_core/config_parser.py index 6c848fa..f65cae0 100644 --- a/hub_core/config_parser.py +++ b/hub_core/config_parser.py @@ -9,6 +9,7 @@ from . import config_visual_style as _config_visual_style from .config_adapter_validation import validate_named_adapter as _validate_named_adapter from .config_assemblies import validate_assemblies as _validate_assemblies_impl +from .config_contract_defaults import data_contract_bool, module_default_contract_bool # noqa: F401 from .config_language_policy import ALLOWED_LANGUAGE_POLICY_MODES as ALLOWED_LANGUAGE_POLICY_MODES from .config_language_policy import get_language_policy as _get_language_policy_impl from .config_language_policy import normalize_lang as normalize_lang @@ -46,6 +47,10 @@ from .config_top_level_keys import top_level_key_suggestion as _top_level_key_suggestion # noqa: F401 from .config_top_level_keys import validate_top_level_key_near_misses as _validate_top_level_key_near_misses from .config_visual_outputs import validate_visual_outputs as _validate_visual_outputs_impl +from .config_workflow_intent import normalize_workflow_defaults as normalize_workflow_defaults +from .config_workflow_intent import validate_workflow_intent_config as _validate_workflow_intent_config +from .config_workflow_intent import workflow_intent as workflow_intent +from .config_workflow_intent import workflow_intent_report as workflow_intent_report from .domain_analysis import DOMAIN_HELPER_NAMES from .execution_security import is_positive_finite_timeout from .logging import get_logger @@ -117,21 +122,6 @@ def find_config_path(project_dir): return None -def data_contract_bool(config: dict, key: str) -> bool | None: - data_contract = config.get("data_contract", {}) if isinstance(config, dict) else {} - if not isinstance(data_contract, dict): - return None - value = data_contract.get(key) - return value if isinstance(value, bool) else None - - -def module_default_contract_bool(config: dict, key: str) -> bool: - explicit = data_contract_bool(config, key) - if explicit is not None: - return explicit - return project_role(config) == DEFAULT_PROJECT_ROLE - - def _load_project_metadata(config_path, fallback_name): metadata = { "name": fallback_name, @@ -158,6 +148,7 @@ def _load_project_metadata(config_path, fallback_name): metadata["errors"] = [str(exc)] return metadata conf_data = normalize_project_defaults(conf_data) + conf_data = normalize_workflow_defaults(conf_data) project_section = conf_data.get("project") if not isinstance(project_section, dict): @@ -217,6 +208,7 @@ def validate_config(config, *, project_root=None): project = config.get("project") role = DEFAULT_PROJECT_ROLE + status = DEFAULT_PROJECT_STATUS if not isinstance(project, dict): errors.append("Missing or invalid 'project' section (must be a mapping).") else: @@ -233,6 +225,10 @@ def validate_config(config, *, project_root=None): if not isinstance(raw_status, str) or raw_status.strip().lower() not in ALLOWED_PROJECT_STATUSES: allowed = ", ".join(sorted(ALLOWED_PROJECT_STATUSES)) errors.append(f"Invalid project.status: '{raw_status}'. Allowed values: {allowed}.") + else: + status = raw_status.strip().lower() or DEFAULT_PROJECT_STATUS + + _validate_workflow_intent_config(errors, config.get("workflow", {}), project_status=status) modules = config.get("modules", []) if modules is None: @@ -676,6 +672,7 @@ def load_config(project_dir): logger.error(" └─ Compare with the scaffold template or fix the listed fields and rerun.") return None, None, None config = normalize_project_defaults(config) + config = normalize_workflow_defaults(config) errors = validate_config(config, project_root=project_dir) if errors: diff --git a/hub_core/config_workflow_intent.py b/hub_core/config_workflow_intent.py new file mode 100644 index 0000000..c04e4f7 --- /dev/null +++ b/hub_core/config_workflow_intent.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any + +from .project_roles import project_status +from .workflow_intent import ( + INTENT_EXECUTION, + INTENT_PROMOTION, + INTENT_REVIEW, + WORKFLOW_INTENTS, + WorkflowIntentError, + infer_workflow_intent, + parse_workflow_intent, +) + +ALLOWED_WORKFLOW_INTENTS = set(WORKFLOW_INTENTS) + + +def compatibility_project_workflow_intent(config: dict) -> str: + if project_status(config) == "legacy": + return INTENT_REVIEW + return INTENT_EXECUTION + + +def normalize_workflow_defaults(config): + """Add in-memory workflow defaults without rewriting existing project files.""" + + if not isinstance(config, dict): + return config + workflow = config.get("workflow") + default_intent = compatibility_project_workflow_intent(config) + if workflow is None: + config["workflow"] = {"intent": default_intent} + return config + if not isinstance(workflow, dict): + return config + if "intent" not in workflow: + workflow["intent"] = default_intent + return config + try: + workflow["intent"] = parse_workflow_intent(workflow["intent"]) + except WorkflowIntentError: + pass + return config + + +def workflow_intent(config) -> str | None: + """Return the normalized declared/default workflow intent, or None when invalid.""" + + return workflow_intent_report(config)["intent"] + + +def workflow_intent_report( + config, + *, + active: bool = False, + step: object = None, + tool_name: object = None, + source: object = None, +) -> dict[str, Any]: + """Return inspectable workflow intent state with fail-closed execution flags.""" + + provenance: dict[str, object] = {"config_source": "compatibility-project-execution"} + fail_closed = False + issues: list[str] = [] + intent: str | None + + if not isinstance(config, dict): + intent = None + fail_closed = True + provenance["config_source"] = "invalid-config" + issues.append("Config root must be a YAML mapping/object.") + else: + workflow = config.get("workflow") + if workflow is None: + intent = compatibility_project_workflow_intent(config) + elif not isinstance(workflow, dict): + intent = None + fail_closed = True + provenance["config_source"] = "invalid-config-workflow" + issues.append("Invalid 'workflow' section (must be a mapping).") + elif "intent" not in workflow: + intent = compatibility_project_workflow_intent(config) + else: + provenance["config_source"] = "declared" + try: + intent = parse_workflow_intent(workflow["intent"]) + except WorkflowIntentError as exc: + intent = None + fail_closed = True + provenance["config_source"] = "declared-invalid" + issues.append(str(exc)) + + if isinstance(config, dict) and project_status(config) == "legacy": + if intent in {INTENT_EXECUTION, INTENT_PROMOTION}: + issues.append("legacy projects are read-only, non-promotable, and never execution-enabled") + intent = INTENT_REVIEW + provenance["config_source"] = "legacy" + fail_closed = True + + if intent is not None and not issues and any(value is not None for value in (step, tool_name, source)): + resolved = infer_workflow_intent( + active=active, + step=step, + tool_name=tool_name, + source=source, + requested_intent=intent, + project_status=project_status(config), + ).to_dict() + resolved["provenance"]["config_source"] = provenance["config_source"] + return resolved + + execution_allowed = intent == INTENT_EXECUTION and not fail_closed + promotion_allowed = intent == INTENT_PROMOTION and not fail_closed + return { + "schema_version": "figops-workflow-intent/1", + "intent": intent, + "provenance": provenance, + "fail_closed": fail_closed, + "execution_allowed": execution_allowed, + "promotion_allowed": promotion_allowed, + "read_only": intent == INTENT_REVIEW or fail_closed, + "promotable": promotion_allowed, + "issues": issues, + } + + +def validate_workflow_intent_config(errors: list[str], workflow: object, *, project_status: str) -> None: + if workflow is None: + workflow = {} + if not isinstance(workflow, dict): + errors.append("Invalid 'workflow' section (must be a mapping).") + return + if "intent" not in workflow: + return + try: + intent = parse_workflow_intent(workflow.get("intent")) + except WorkflowIntentError: + allowed = ", ".join(sorted(ALLOWED_WORKFLOW_INTENTS)) + errors.append( + f"Invalid workflow.intent: '{workflow.get('intent')}'. Allowed values: {allowed}. " + "Unknown intents fail closed for execution and remain inspectable." + ) + return + if project_status == "legacy" and intent in {INTENT_EXECUTION, INTENT_PROMOTION}: + errors.append( + f"project.status 'legacy' cannot declare workflow.intent '{intent}'; " + "legacy projects are read-only and non-promotable." + ) diff --git a/hub_core/evidence_contract.py b/hub_core/evidence_contract.py index d6b6eef..a54752a 100644 --- a/hub_core/evidence_contract.py +++ b/hub_core/evidence_contract.py @@ -30,6 +30,7 @@ _AVAILABILITY = {"available", "unavailable", "not_applicable", "unknown"} _POLICY_KEYS = {"passed", "severity", "outcome", "hard", "blocked"} _SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") +_RENDER_POLICY_CONTEXT_SCHEMA = "figops-render-policy-context/1" class EvidenceContractError(ValueError): @@ -286,16 +287,48 @@ def _validate_provenance(value: Any, *, require_hashes: bool) -> None: _nonempty_string(provenance.get("reason"), "evidence.provenance.reason") -def _validate_resolved_policy(value: Any) -> None: +def _validate_resolved_policy(value: Any, path: str = "evidence.resolved_policy") -> None: if value is None: return - policy = _mapping(value, "evidence.resolved_policy") - _closed(policy, {"id", "version", "source", "parameters"}, "evidence.resolved_policy") - _nonempty_string(policy.get("id"), "evidence.resolved_policy.id") - _nonempty_string(policy.get("version"), "evidence.resolved_policy.version") - _nonempty_string(policy.get("source"), "evidence.resolved_policy.source") + policy = _mapping(value, path) + _closed(policy, {"id", "version", "source", "parameters"}, path) + _nonempty_string(policy.get("id"), f"{path}.id") + _nonempty_string(policy.get("version"), f"{path}.version") + _nonempty_string(policy.get("source"), f"{path}.source") if "parameters" in policy: - _mapping(policy["parameters"], "evidence.resolved_policy.parameters") + _mapping(policy["parameters"], f"{path}.parameters") + + +def _validate_policy_context(value: Any) -> None: + if value is None: + return + context = _mapping(value, "evidence.policy_context") + _closed( + context, + {"schema_version", "source", "policy_set_sha256", "render_policy", "validation_target"}, + "evidence.policy_context", + ) + if context.get("schema_version") != _RENDER_POLICY_CONTEXT_SCHEMA: + _fail( + "POLICY_CONTEXT_SCHEMA_INVALID", + "evidence.policy_context.schema_version", + f"must be {_RENDER_POLICY_CONTEXT_SCHEMA!r}", + ) + _nonempty_string(context.get("source"), "evidence.policy_context.source") + _sha256(context.get("policy_set_sha256"), "evidence.policy_context.policy_set_sha256") + render_policy = _mapping( + context.get("render_policy"), + "evidence.policy_context.render_policy", + ) + _validate_resolved_policy( + render_policy, + "evidence.policy_context.render_policy", + ) + if "validation_target" not in context: + _fail("FIELD_REQUIRED", "evidence.policy_context.validation_target", "is required") + target = context.get("validation_target") + if target is not None: + _nonempty_string(target, "evidence.policy_context.validation_target") def _validate_summary(value: Any, path: str) -> None: @@ -446,6 +479,7 @@ def validate_evidence_envelope(envelope: Any) -> None: "artifacts", "provenance", "resolved_policy", + "policy_context", "mutation_ledger", "exact_reproducibility", "visual_comparison", @@ -501,6 +535,7 @@ def validate_evidence_envelope(envelope: Any) -> None: require_hashes=artifact_status in {"passed", "warning"} and artifacts_exist, ) _validate_resolved_policy(root.get("resolved_policy")) + _validate_policy_context(root.get("policy_context")) _validate_mutation_ledger(root.get("mutation_ledger")) if "exact_reproducibility" not in root: _fail("FIELD_REQUIRED", "evidence.exact_reproducibility", "is required") diff --git a/hub_core/mcp/phase2_render_schemas.py b/hub_core/mcp/phase2_render_schemas.py new file mode 100644 index 0000000..b7f9fa8 --- /dev/null +++ b/hub_core/mcp/phase2_render_schemas.py @@ -0,0 +1,109 @@ +"""Closed MCP response schemas for Phase 2 project-render metadata.""" + +from __future__ import annotations + +from hub_core.mcp.tool_schema_common import object_schema + +RESOLVED_POLICY_SET_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-resolved-policy-set/1"}, + "parameters": {"type": "object", "maxProperties": 64}, + }, + required=["schema_version", "parameters"], +) + +RENDER_POLICY_SCHEMA = object_schema( + { + "id": {"type": "string"}, + "version": {"type": "string"}, + "source": {"type": "string"}, + "parameters": {"type": "object", "maxProperties": 32}, + }, + required=["id", "version", "source", "parameters"], +) + +RENDER_POLICY_CONTEXT_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-render-policy-context/1"}, + "source": { + "type": "string", + "enum": ["explicit-render-policy", "compatibility-default", "v2-default"], + }, + "validation_source": { + "type": "string", + "enum": ["explicit-validation-target", "compatibility-target-inference", "none"], + }, + "policy_set_sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, + "policy_set": RESOLVED_POLICY_SET_SCHEMA, + "render_policy": RENDER_POLICY_SCHEMA, + "validation_target": {"type": ["string", "null"]}, + }, + required=[ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target", + ], +) + +WORKFLOW_INTENT_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-workflow-intent/1"}, + "intent": {"type": ["string", "null"], "enum": ["exploration", "execution", "review", "promotion", None]}, + "source": { + "type": ["string", "null"], + "enum": ["explicit", "orchestrator", "mcp", "direct_csv", "read_only", "readiness", "legacy", None], + }, + "provenance": object_schema( + { + "active": {"type": "boolean"}, + "step": {"type": ["string", "null"]}, + "tool_name": {"type": "string"}, + "requested_intent": { + "type": ["string", "null"], + "enum": ["exploration", "execution", "review", "promotion", None], + }, + "requested_source": { + "type": ["string", "null"], + "enum": ["explicit", "orchestrator", "mcp", "direct_csv", "read_only", "readiness", "legacy", None], + }, + "project_status": {"type": "string"}, + "config_source": {"type": "string"}, + }, + required=[ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source", + ], + ), + "fail_closed": {"type": "boolean"}, + "legacy": {"type": "boolean"}, + "execution_allowed": {"type": "boolean"}, + "promotion_allowed": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "promotable": {"type": "boolean"}, + "issues": {"type": "array", "items": {"type": "string"}, "maxItems": 64}, + }, + required=[ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues", + ], +) + +__all__ = ["RENDER_POLICY_CONTEXT_SCHEMA", "WORKFLOW_INTENT_SCHEMA"] diff --git a/hub_core/mcp/render_project_integrity_context.py b/hub_core/mcp/render_project_integrity_context.py new file mode 100644 index 0000000..8099932 --- /dev/null +++ b/hub_core/mcp/render_project_integrity_context.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Any + + +def resolve_project_render_workflow_intent( + config: Mapping[str, Any], + *, + workflow_intent_report_fn: Callable[..., dict[str, Any]], +) -> dict[str, Any]: + """Resolve the workflow intent for the MCP project-render execution surface.""" + + return workflow_intent_report_fn( + config, + active=True, + tool_name="figops.render_project_figure", + ) + + +def resolve_project_render_policy_context( + arguments: Mapping[str, Any], + *, + target_format: str, + resolve_render_policy_context_fn: Callable[..., Mapping[str, Any]], +) -> dict[str, Any]: + """Resolve the render policy context and keep it mutable for manifest assembly.""" + + return dict( + resolve_render_policy_context_fn( + arguments, + target_format=target_format, + ) + ) + + +def apply_project_render_policy_context( + style_summary: dict[str, Any], + policy_context: Mapping[str, Any], +) -> tuple[str, dict[str, Any]]: + """Bind resolved policy identity onto the public style summary.""" + + validation_target = str(policy_context.get("validation_target") or "") + render_policy = dict(policy_context["render_policy"]) + style_summary["render_policy"] = render_policy["id"] + style_summary["validation_target"] = validation_target or None + return validation_target, render_policy + + +def decide_project_render_promotion_eligibility( + *, + claim_inventory: Mapping[str, Any], + policy_projections: Sequence[Mapping[str, Any]], + validation_target: str, + workflow_intent: Mapping[str, Any], + manual_review_needed: bool, +) -> dict[str, Any]: + """Decide review and promotion state after evidence policy projection.""" + + projection_ready = ( + len(policy_projections) == 1 + and policy_projections[0].get("status") == "informational" + ) + policy_review_needed = bool(validation_target) and not projection_ready + workflow_execution_allowed = workflow_intent.get("execution_allowed") is True + workflow_review_needed = not workflow_execution_allowed + manual_review_needed = manual_review_needed or policy_review_needed or workflow_review_needed + promotion_eligible = bool( + claim_inventory["promotion_eligible"] + and validation_target + and projection_ready + and workflow_execution_allowed + ) + return { + "manual_review_needed": manual_review_needed, + "policy_review_needed": policy_review_needed, + "projection_ready": projection_ready, + "promotion_eligible": promotion_eligible, + "workflow_execution_allowed": workflow_execution_allowed, + "workflow_review_needed": workflow_review_needed, + } + + +__all__ = [ + "apply_project_render_policy_context", + "decide_project_render_promotion_eligibility", + "resolve_project_render_policy_context", + "resolve_project_render_workflow_intent", +] diff --git a/hub_core/mcp/render_response.py b/hub_core/mcp/render_response.py index bc4cc34..68f3c98 100644 --- a/hub_core/mcp/render_response.py +++ b/hub_core/mcp/render_response.py @@ -44,6 +44,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "status": _text(runtime_availability.get("status")), "reason": _text(runtime_availability.get("reason")), } + _add_project_render_context(response, result) return _bounded(response) evidence = normalize_evidence_envelope(evidence_raw) @@ -64,6 +65,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "failure_stage": None, "resolution_hint": None, } + _add_project_render_context(response, result) return _bounded(response) @@ -161,6 +163,13 @@ def _texts(value: Any) -> list[str]: return [_text(item) for item in value[:MAX_RESPONSE_WARNINGS]] +def _add_project_render_context(response: dict[str, Any], result: Mapping[str, Any]) -> None: + for key in ("policy_context", "workflow_intent"): + value = result.get(key) + if isinstance(value, Mapping): + response[key] = dict(value) + + def _bounded(response: dict[str, Any]) -> dict[str, Any]: encoded = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode("utf-8") if len(encoded) > MAX_RENDER_RESPONSE_BYTES: diff --git a/hub_core/mcp/schemas.py b/hub_core/mcp/schemas.py index 5fd872a..fe89ff3 100644 --- a/hub_core/mcp/schemas.py +++ b/hub_core/mcp/schemas.py @@ -8,6 +8,8 @@ from hub_core.mcp.discovery_schemas import list_prompt_definitions as _list_prompt_definitions from hub_core.mcp.discovery_schemas import list_resource_definitions as _list_resource_definitions from hub_core.mcp.discovery_schemas import list_resource_templates as _list_resource_templates +from hub_core.mcp.phase2_render_schemas import RENDER_POLICY_CONTEXT_SCHEMA as _RENDER_POLICY_CONTEXT_SCHEMA +from hub_core.mcp.phase2_render_schemas import WORKFLOW_INTENT_SCHEMA as _WORKFLOW_INTENT_SCHEMA from hub_core.mcp.render_geometry_schemas import GEOMETRY_DIAGNOSTICS_SCHEMA as _GEOMETRY_DIAGNOSTICS_SCHEMA from hub_core.mcp.render_geometry_schemas import GEOMETRY_METRIC_NAMES as _GEOMETRY_METRIC_NAMES # noqa: F401 from hub_core.mcp.render_geometry_schemas import LAYOUT_REPORT_SCHEMA as _LAYOUT_REPORT_SCHEMA @@ -565,6 +567,8 @@ def list_tool_definitions( "baseline_comparison": {"type": "object"}, "provenance": {"type": "object"}, "evidence": {"type": "object"}, + "policy_context": _RENDER_POLICY_CONTEXT_SCHEMA, + "workflow_intent": _WORKFLOW_INTENT_SCHEMA, } ), ), diff --git a/hub_core/mcp/tools/render_project.py b/hub_core/mcp/tools/render_project.py index 6e7445d..2e6626f 100644 --- a/hub_core/mcp/tools/render_project.py +++ b/hub_core/mcp/tools/render_project.py @@ -5,15 +5,22 @@ from typing import Any from hub_core.adapters import select_adapters -from hub_core.artifact_policy_measurement import resolve_render_validation_policies +from hub_core.artifact_policy_measurement import resolve_render_policy_context from hub_core.attempt_provenance import build_attempt_provenance, update_attempt_provenance -from hub_core.config_parser import master_execution_error, project_role, project_status, validate_config +from hub_core.config_parser import ( + master_execution_error, + project_role, + project_status, + validate_config, + workflow_intent_report, +) from hub_core.data_contract import validate_data_contract, validate_data_contract_preflight from hub_core.external_raw_execution import ( is_external_raw_declaration, materialize_external_raw_inputs, ) from hub_core.mcp import render_orchestration as render_helpers +from hub_core.mcp import render_project_integrity_context as integrity_context from hub_core.mcp.errors import PROJECT_DECLARATION_PATH_INVALID, has_unsafe_declared_path from hub_core.project_paths import ProjectPathError, resolve_project_input, resolve_project_output from hub_core.provenance_inputs import expand_project_input_files, resolved_research_ops_evidence @@ -123,6 +130,10 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ), persist_failure=True, ) + workflow_intent = integrity_context.resolve_project_render_workflow_intent( + config, + workflow_intent_report_fn=workflow_intent_report, + ) research_ops = validate_research_ops_contract(project_path, config) research_ops_policy = resolved_research_ops_evidence(config) if research_ops["errors"]: @@ -274,12 +285,15 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ) safe_output_path = True style_summary = self._selected_figure_style_summary(config, selected, arguments) - validation_target, render_policy = resolve_render_validation_policies( + policy_context = integrity_context.resolve_project_render_policy_context( arguments, target_format=style_summary["target_format"], + resolve_render_policy_context_fn=resolve_render_policy_context, + ) + validation_target, render_policy = integrity_context.apply_project_render_policy_context( + style_summary, + policy_context, ) - style_summary["render_policy"] = render_policy["id"] - style_summary["validation_target"] = validation_target or None style_errors = self._render_style_errors( style_summary["target_format"], style_summary["output_format"], @@ -354,6 +368,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: artifact_status="validated", baseline_comparison=self._baseline_comparison(None, arguments.get("baseline_path")), provenance={"attempt": attempt}, + policy_context=policy_context, + workflow_intent=workflow_intent, failure_stage="", resolution_hint="", ) @@ -575,6 +591,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: research_ops_policy=research_ops_policy, data_contract={"schema_version": "data_contract_summary/1", "passed": True}, preview_artifacts=preview_artifacts, + policy_context=policy_context, + workflow_intent=workflow_intent, ) manifest["claim_inventory"] = claim_inventory manifest["publication_status"] = ( @@ -593,22 +611,21 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: else None ), render_policy=render_policy, + policy_context=policy_context, validation_target=validation_target or None, ) policy_projections = manifest["evidence"]["policy_projections"] - projection_ready = ( - len(policy_projections) == 1 - and policy_projections[0].get("status") == "informational" + promotion_decision = integrity_context.decide_project_render_promotion_eligibility( + claim_inventory=claim_inventory, + policy_projections=policy_projections, + validation_target=validation_target, + workflow_intent=workflow_intent, + manual_review_needed=manual_review_needed, ) - policy_review_needed = bool(validation_target) and not projection_ready - manual_review_needed = manual_review_needed or policy_review_needed + manual_review_needed = bool(promotion_decision["manual_review_needed"]) status = "warning" if manual_review_needed else "ok" manifest["manual_review_needed"] = manual_review_needed - manifest["promotion_eligible"] = bool( - claim_inventory["promotion_eligible"] - and validation_target - and projection_ready - ) + manifest["promotion_eligible"] = bool(promotion_decision["promotion_eligible"]) status_payload = self._render_status_payload( job_id=job_id, status=status, @@ -628,24 +645,28 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: status_payload["claim_inventory"] = claim_inventory status_payload["publication_status"] = manifest["publication_status"] status_payload["promotion_eligible"] = manifest["promotion_eligible"] + status_payload["policy_context"] = policy_context + status_payload["workflow_intent"] = workflow_intent render_helpers._write_manifest_and_status(manifest, manifest_path, status_payload, status_path, latest_dir) - try: - promoted = promote_eligible_project_result( - project_root=project_path, - config=config, - runtime_root=self.runtime_root, - runtime_artifact=output_path, - output_relpath=output_relpath, - manifest=manifest, - manifest_path=manifest_path, - figure_id=str(selected.get("id") or "figure"), - selected_figure=selected, - ) - except Exception as exc: - raise render_helpers.ProjectRenderExportError( - f"Eligible result promotion failed: {exc}", - script_output=self._read_project_script_output(job_root), - ) from exc + promoted = None + if manifest["promotion_eligible"]: + try: + promoted = promote_eligible_project_result( + project_root=project_path, + config=config, + runtime_root=self.runtime_root, + runtime_artifact=output_path, + output_relpath=output_relpath, + manifest=manifest, + manifest_path=manifest_path, + figure_id=str(selected.get("id") or "figure"), + selected_figure=selected, + ) + except Exception as exc: + raise render_helpers.ProjectRenderExportError( + f"Eligible result promotion failed: {exc}", + script_output=self._read_project_script_output(job_root), + ) from exc if promoted is not None: created_paths.extend(str(item.path) for item in promoted) except Exception as exc: @@ -768,6 +789,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: baseline_comparison=baseline_comparison, provenance=provenance, evidence=manifest["evidence"], + policy_context=policy_context, + workflow_intent=workflow_intent, claim_inventory=claim_inventory, publication_status=manifest["publication_status"], promotion_eligible=manifest["promotion_eligible"], diff --git a/hub_core/mcp/v2_tool_schemas.py b/hub_core/mcp/v2_tool_schemas.py index d1646dc..15ec77d 100644 --- a/hub_core/mcp/v2_tool_schemas.py +++ b/hub_core/mcp/v2_tool_schemas.py @@ -7,6 +7,7 @@ from hub_core.artifact_audit import SUPPORTED_POLICY_PACKS from hub_core.config_parser import ALLOWED_OUTPUT_FORMATS, PUBLIC_TARGET_FORMATS from hub_core.journal_specs import list_supported_preflight_targets +from hub_core.mcp.phase2_render_schemas import RENDER_POLICY_CONTEXT_SCHEMA, WORKFLOW_INTENT_SCHEMA from hub_core.mcp.tool_schema_common import ToolDefinition, object_schema @@ -37,6 +38,8 @@ def build_v2_tool_definitions( "properties": { **render_output["properties"], "runtime_availability": {"type": "object"}, + "policy_context": RENDER_POLICY_CONTEXT_SCHEMA, + "workflow_intent": WORKFLOW_INTENT_SCHEMA, }, } return [ diff --git a/hub_core/policy_resolution.py b/hub_core/policy_resolution.py new file mode 100644 index 0000000..1959429 --- /dev/null +++ b/hub_core/policy_resolution.py @@ -0,0 +1,271 @@ +"""Pure canonical policy resolution for figure-integrity policy inputs.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Final + +from .config_style import ALLOWED_TARGET_FORMATS +from .policy_resolution_json import ( + PolicyResolutionError as PolicyResolutionError, +) +from .policy_resolution_json import ( + closed, + fail, + finite_number, + normalize_json_value, + parse_json_array, + set_value, + sha256, + token, +) +from .policy_resolution_types import ( + PolicyConstraint, + PolicyException, + ResolvedPolicySet, + ResolvedPolicyValue, + jsonable, +) + +SCHEMA_VERSION: Final = "figops-resolved-policy-set/1" +POLICY_VERSION: Final = "1" +LAYER_ORDER: Final = ("kernel", "operator", "lab", "project", "render") +PRECEDENCE: Final = {source: index for index, source in enumerate(LAYER_ORDER)} +MERGE_OPERATORS: Final = {"require", "minimum", "maximum", "allowed_set", "exact", "selection"} +JOURNAL_TARGETS: Final = tuple(sorted(ALLOWED_TARGET_FORMATS - {"neutral", "default", "ppt"})) +KERNEL_INVARIANTS: Final = frozenset( + { + "path_containment", + "schema_receipt_integrity", + "runtime_result_disjointness", + "no_replace_promotion", + } +) +_MISSING = object() + + +@dataclass(frozen=True, slots=True) +class _ParamSchema: + operator: str + default: Any = None + allowed: tuple[Any, ...] = () + opt_out_allowed: bool = False + waivable: bool = False + + +PARAMETER_SCHEMAS: Final = { + "path_containment": _ParamSchema("require", True), + "schema_receipt_integrity": _ParamSchema("require", True), + "runtime_result_disjointness": _ParamSchema("require", True), + "no_replace_promotion": _ParamSchema("require", True), + "human_signoff_required": _ParamSchema("require", False), + "render_policy": _ParamSchema("selection", "neutral", tuple(sorted(ALLOWED_TARGET_FORMATS))), + "validation_target": _ParamSchema("selection", None, (None, *JOURNAL_TARGETS)), + "minimum_raster_dpi": _ParamSchema("minimum", None), + "maximum_physical_width_mm": _ParamSchema("maximum", None), + "allowed_artifact_formats": _ParamSchema("allowed_set", None), + "project_role": _ParamSchema("exact", None), + "require_figure_traceability": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "require_canonical_docs": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "forbid_todo_placeholders": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "raw_integrity_mode": _ParamSchema("selection", "off", ("off", "warn", "strict")), +} + + +def resolve_policy_set(layers: Sequence[Mapping[str, Any]] | bytes | bytearray | memoryview) -> ResolvedPolicySet: + constraints = {name: [] for name in PARAMETER_SCHEMAS} + exceptions: dict[str, list[PolicyException]] = {name: [] for name in PARAMETER_SCHEMAS} + opt_outs = dict.fromkeys(PARAMETER_SCHEMAS, False) + seen_sources: set[str] = set() + for layer in _parse_layers(layers): + layer = closed(layer, {"source", "policy_id", "version", "parameters"}, "layer") + source = _source(layer["source"], seen_sources) + precedence = PRECEDENCE[source] + policy_id = token(layer["policy_id"], "policy_id") + version = _version(layer["version"]) + params = closed(layer["parameters"], set(PARAMETER_SCHEMAS), "parameters", subset=True) + for name, raw in params.items(): + schema = PARAMETER_SCHEMAS[name] + values, opt_out, found_exceptions = _parts(name, raw, schema) + if name in KERNEL_INVARIANTS and any(value is False for value in values): + fail(f"{name} immutable kernel invariant cannot be disabled") + if opt_out and not schema.opt_out_allowed: + fail(f"{name} does not allow opt-out") + opt_outs[name] = opt_outs[name] or opt_out + constraint_source = source + if opt_out and not values: + values = [False] + constraint_source = _opt_out_source(source) + constraints[name].extend( + PolicyConstraint(constraint_source, precedence, policy_id, version, v) for v in values + ) + exceptions[name].extend( + PolicyException(source, policy_id, version, e["finding_code"], e.get("subject_digest")) + for e in found_exceptions + ) + resolved = ( + _resolve(name, PARAMETER_SCHEMAS[name], tuple(constraints[name]), opt_outs[name], tuple(exceptions[name])) + for name in PARAMETER_SCHEMAS + ) + return ResolvedPolicySet(SCHEMA_VERSION, tuple(sorted(resolved, key=lambda item: item.parameter))) + + +def compatibility_resolved_policy(policy_set: ResolvedPolicySet) -> dict[str, Any]: + """Project the canonical set into the legacy singular ``resolved_policy`` shape.""" + + render_policy = policy_set.value("render_policy").value + target = policy_set.value("validation_target").value + if target is None: + parameters = {"style_policy": render_policy, "mutates_journal_aesthetics": render_policy != "neutral"} + policy_id = f"render-{render_policy}" + else: + parameters = {"render_policy": f"render-{render_policy}", "validation_target": target} + policy_id = f"journal-{target}" + return { + "id": policy_id, + "version": POLICY_VERSION, + "source": "policy-set-compatibility-projection", + "parameters": parameters, + } + + +def parse_policy_layers_json(data: bytes | bytearray | memoryview) -> tuple[dict[str, Any], ...]: + return parse_json_array(data) + + +def _resolve( + name: str, + schema: _ParamSchema, + constraints: tuple[PolicyConstraint, ...], + opt_out: bool, + exceptions: tuple[PolicyException, ...], +) -> ResolvedPolicyValue: + values = [item.value for item in constraints] + value = schema.default if not values else _merge(name, schema, values) + if schema.operator == "require" and opt_out and not any(item.value is True for item in constraints): + value = False + selected = _selected(constraints, value) + return ResolvedPolicyValue( + name, + jsonable(value), + schema.operator, + selected.source, + selected.precedence, + selected.policy_id, + selected.version, + opt_out, + bool(opt_out and value is False), + constraints, + exceptions, + ) + + +def _merge(name: str, schema: _ParamSchema, values: list[Any]) -> Any: + if schema.operator == "require": + if not all(isinstance(item, bool) for item in values): + fail(f"{name} require values must be boolean") + return any(values) + if schema.operator in {"minimum", "maximum"}: + nums = [finite_number(item, name) for item in values] + return max(nums) if schema.operator == "minimum" else min(nums) + if schema.operator in {"allowed_set", "selection"}: + return _merge_set_or_selection(name, schema, values) + if len({json.dumps(jsonable(item), sort_keys=True) for item in values}) != 1: + fail(f"{name} exact values conflict") + return values[0] + + +def _merge_set_or_selection(name: str, schema: _ParamSchema, values: list[Any]) -> Any: + allowed, selected = set(schema.allowed) if schema.allowed else None, _MISSING + for item in values: + candidate = set(item) if isinstance(item, tuple) else {item} + allowed = candidate if allowed is None else allowed & candidate + if not isinstance(item, tuple): + selected = item + if not allowed: + fail(f"{name} has an empty allowed-set intersection") + if schema.operator == "allowed_set": + return tuple(sorted(allowed, key=lambda item: "" if item is None else str(item))) + selection = schema.default if selected is _MISSING else selected + if selection not in allowed: + fail(f"{name} selection is outside the resolved allowed set") + return selection + + +def _parts(name: str, raw: Any, schema: _ParamSchema) -> tuple[list[Any], bool, list[dict[str, str]]]: + if not isinstance(raw, Mapping): + return [_normalize_value(raw, name, schema)], False, [] + item = closed(raw, {"value", "allowed", "opt_out", "exceptions"}, name, subset=True) + values = [] + if "opt_out" in item and not isinstance(item["opt_out"], bool): + fail(f"{name}.opt_out must be boolean") + if "allowed" in item: + values.append(set_value(item["allowed"], name)) + if "value" in item: + values.append(_normalize_value(item["value"], name, schema)) + exceptions = [_exception(exc, f"{name}.exceptions") for exc in item.get("exceptions", [])] + return values, bool(item.get("opt_out", False)), exceptions + + +def _normalize_value(value: Any, name: str, schema: _ParamSchema) -> Any: + if value is None: + return None + if schema.operator == "require" and not isinstance(value, bool): + fail(f"{name} must be boolean") + if schema.operator in {"minimum", "maximum"}: + return finite_number(value, name) + if schema.operator == "allowed_set": + return set_value(value, name) + value = normalize_json_value(value) if schema.operator in {"allowed_set", "selection", "exact"} else value + if schema.allowed and value not in schema.allowed: + fail(f"{name} has unsupported value {value!r}") + return value + + +def _parse_layers( + layers: Sequence[Mapping[str, Any]] | bytes | bytearray | memoryview, +) -> tuple[Mapping[str, Any], ...]: + if isinstance(layers, (bytes, bytearray, memoryview)): + return parse_json_array(layers) + if not isinstance(layers, Sequence) or isinstance(layers, str): + fail("policy layers must be an array") + return tuple(normalize_json_value(layer) for layer in layers) + + +def _source(value: Any, seen: set[str]) -> str: + source = token(value, "source") + if source not in PRECEDENCE: + fail(f"unknown policy source {source!r}") + if source in seen: + fail(f"duplicate policy source {source!r}") + seen.add(source) + return source + + +def _opt_out_source(source: str) -> str: + return "explicit_project_opt_out" if source == "project" else f"explicit_{source}_opt_out" + + +def _exception(value: Any, field: str) -> dict[str, str]: + item = closed(value, {"finding_code", "subject_digest"}, field, subset=True) + result = {"finding_code": token(item.get("finding_code"), f"{field}.finding_code")} + if "subject_digest" in item: + result["subject_digest"] = sha256(item["subject_digest"], f"{field}.subject_digest") + return result + + +def _selected(constraints: tuple[PolicyConstraint, ...], value: Any) -> PolicyConstraint: + default = PolicyConstraint("kernel", PRECEDENCE["kernel"], "figops-kernel-defaults", POLICY_VERSION, value) + if not constraints: + return default + matches = [item for item in constraints if item.value == value] + return sorted(matches or constraints, key=lambda item: item.precedence)[0] + + +def _version(value: Any) -> str: + value = token(value, "version") + if value != POLICY_VERSION: + fail("version has unsupported policy version") + return value diff --git a/hub_core/policy_resolution_json.py b/hub_core/policy_resolution_json.py new file mode 100644 index 0000000..c768992 --- /dev/null +++ b/hub_core/policy_resolution_json.py @@ -0,0 +1,115 @@ +"""Strict JSON and scalar helpers for policy resolution.""" + +from __future__ import annotations + +import json +import math +import re +import unicodedata +from collections.abc import Mapping +from typing import Any, NoReturn + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_PATH_RE = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~[\\/]|(?:file|runtime|raw):)", re.I) + + +class PolicyResolutionError(ValueError): + """Raised when policy inputs cannot resolve to one deterministic value.""" + + +def parse_json_array(data: bytes | bytearray | memoryview) -> tuple[dict[str, Any], ...]: + raw = bytes(data) + if raw.startswith(b"\xef\xbb\xbf"): + fail("JSON bytes must be UTF-8 without a BOM") + try: + parsed = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_duplicates, parse_constant=_constant) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PolicyResolutionError(f"policy JSON is invalid: {exc}") from exc + parsed = normalize_json_value(parsed) + if not isinstance(parsed, list): + fail("top-level policy JSON must be an array") + return tuple(parsed) + + +def closed(value: Any, allowed: set[str], field: str, *, subset: bool = False) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + fail(f"{field} must be a mapping") + keys = set(value) + if any(not isinstance(key, str) for key in keys) or keys - allowed or (allowed - keys and not subset): + fail(f"{field} contains unsupported or missing keys") + return value + + +def set_value(value: Any, field: str) -> tuple[Any, ...]: + if not isinstance(value, (list, tuple)) or not value: + fail(f"{field}.allowed must be a non-empty array") + normalized = tuple(normalize_json_value(item) for item in value) + if len(set(normalized)) != len(normalized): + fail(f"{field}.allowed contains duplicate values") + return normalized + + +def normalize_json_value(value: Any) -> Any: + if isinstance(value, str): + value = unicodedata.normalize("NFC", value) + if _PATH_RE.search(value) or "\\" in value or value.startswith("../"): + fail("policy input contains a path-like string") + return value + if isinstance(value, bool) or value is None or isinstance(value, int): + return value + if isinstance(value, float): + return value if math.isfinite(value) else fail("policy input contains non-finite number") + if isinstance(value, list): + return [normalize_json_value(item) for item in value] + if isinstance(value, tuple): + return tuple(normalize_json_value(item) for item in value) + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + normalized = normalize_json_value(key) + if not isinstance(normalized, str) or normalized in result: + fail("policy object keys must be unique strings") + result[normalized] = normalize_json_value(child) + return result + fail("policy input contains unsupported JSON value") + + +def token(value: Any, field: str) -> str: + value = normalize_json_value(value) + if not isinstance(value, str) or not value.strip() or value != value.strip(): + fail(f"{field} must be a canonical non-empty string") + return value + + +def finite_number(value: Any, field: str) -> int | float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + fail(f"{field} must be a finite number") + return value + + +def sha256(value: Any, field: str) -> str: + value = token(value, field) + if _SHA256_RE.fullmatch(value) is None: + fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def canonical_json_bytes(value: Mapping[str, Any]) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def fail(message: str) -> NoReturn: + raise PolicyResolutionError(f"policy resolution {message}") + + +def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise PolicyResolutionError(f"policy JSON contains duplicate key {key!r}") + result[key] = value + return result + + +def _constant(value: str) -> NoReturn: + raise PolicyResolutionError(f"policy JSON contains non-finite value {value}") diff --git a/hub_core/policy_resolution_types.py b/hub_core/policy_resolution_types.py new file mode 100644 index 0000000..e2b051c --- /dev/null +++ b/hub_core/policy_resolution_types.py @@ -0,0 +1,92 @@ +"""Immutable DTOs for canonical policy resolution.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + +from .policy_resolution_json import canonical_json_bytes + + +@dataclass(frozen=True, slots=True) +class PolicyConstraint: + source: str + precedence: int + policy_id: str + version: str + value: Any + + +@dataclass(frozen=True, slots=True) +class PolicyException: + source: str + policy_id: str + version: str + finding_code: str + subject_digest: str | None = None + + +@dataclass(frozen=True, slots=True) +class ResolvedPolicyValue: + parameter: str + value: Any + merge_operator: str + source: str + precedence: int + policy_id: str + version: str + opt_out_requested: bool + opt_out_accepted: bool + constraints: tuple[PolicyConstraint, ...] + exceptions: tuple[PolicyException, ...] = () + + def to_json(self) -> dict[str, Any]: + return { + "value": jsonable(self.value), + "merge_operator": self.merge_operator, + "source": self.source, + "precedence": self.precedence, + "policy_id": self.policy_id, + "version": self.version, + "opt_out_requested": self.opt_out_requested, + "opt_out_accepted": self.opt_out_accepted, + "constraints": [_dc_json(item) for item in self.constraints], + "exceptions": [_dc_json(item) for item in self.exceptions], + } + + +@dataclass(frozen=True, slots=True) +class ResolvedPolicySet: + schema_version: str + parameters: tuple[ResolvedPolicyValue, ...] + + def value(self, parameter: str) -> ResolvedPolicyValue: + for item in self.parameters: + if item.parameter == parameter: + return item + raise KeyError(parameter) + + def to_json(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "parameters": {value.parameter: value.to_json() for value in self.parameters}, + } + + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.to_json()) + + def canonical_sha256(self) -> str: + return hashlib.sha256(self.canonical_bytes()).hexdigest() + + +def jsonable(value: Any) -> Any: + return [jsonable(item) for item in value] if isinstance(value, tuple) else value + + +def _dc_json(value: PolicyConstraint | PolicyException) -> dict[str, Any]: + return { + key: jsonable(getattr(value, key)) + for key in value.__dataclass_fields__ + if getattr(value, key) is not None + } diff --git a/hub_core/render_evidence.py b/hub_core/render_evidence.py index 42111ab..36ca6ba 100644 --- a/hub_core/render_evidence.py +++ b/hub_core/render_evidence.py @@ -28,6 +28,7 @@ _MAX_ARTIFACTS: Final = 256 _MAX_ARTIFACT_BYTES: Final = 256 * 1024 * 1024 _MAX_RASTER_PIXELS: Final = 100_000_000 +_RENDER_POLICY_CONTEXT_SCHEMA: Final = "figops-render-policy-context/1" class RenderEvidenceError(ValueError): @@ -42,6 +43,7 @@ def build_render_evidence( producer_version: str, resolved_policy: Mapping[str, Any] | None = None, render_policy: Mapping[str, Any] | None = None, + policy_context: Mapping[str, Any] | None = None, validation_target: str | None = None, baseline_reference_sha256: str | None = None, ) -> dict[str, Any]: @@ -74,8 +76,19 @@ def build_render_evidence( "visual_comparison": None, } policy = _resolved_policy(resolved_policy) + context = _policy_context(policy_context) selected_render_policy = _resolved_policy(render_policy) target = str(validation_target or "").strip().lower() + if context is not None: + evidence["policy_context"] = context + context_render_policy = _resolved_policy(context["render_policy"]) + if selected_render_policy is not None and selected_render_policy != context_render_policy: + raise RenderEvidenceError("render_policy conflicts with policy_context") + selected_render_policy = selected_render_policy or context_render_policy + context_target = str(context.get("validation_target") or "").strip().lower() + if target and context_target and target != context_target: + raise RenderEvidenceError("validation_target conflicts with policy_context") + target = target or context_target if target: if policy is not None: raise RenderEvidenceError( @@ -322,6 +335,32 @@ def _resolved_policy(raw: Mapping[str, Any] | None) -> dict[str, Any] | None: return policy +def _policy_context(raw: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(raw, Mapping): + return None + context = { + "schema_version": raw.get("schema_version"), + "source": raw.get("source"), + "policy_set_sha256": raw.get("policy_set_sha256"), + "render_policy": raw.get("render_policy"), + "validation_target": raw.get("validation_target"), + } + if context["schema_version"] != _RENDER_POLICY_CONTEXT_SCHEMA: + raise RenderEvidenceError("policy_context schema is malformed") + if not isinstance(context["source"], str) or not context["source"].strip(): + raise RenderEvidenceError("policy_context source is malformed") + if not isinstance(context["policy_set_sha256"], str) or _SHA256.fullmatch(context["policy_set_sha256"]) is None: + raise RenderEvidenceError("policy_context digest is malformed") + if not isinstance(context["render_policy"], Mapping): + raise RenderEvidenceError("policy_context render_policy is malformed") + if "validation_target" not in raw: + raise RenderEvidenceError("policy_context validation_target is malformed") + target = context["validation_target"] + if target is not None and (not isinstance(target, str) or not target.strip()): + raise RenderEvidenceError("policy_context validation_target is malformed") + return context + + def _detected_media_type(head: bytes) -> str: if head.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" diff --git a/hub_core/templates/project_config_template.yaml b/hub_core/templates/project_config_template.yaml index 870b4b3..dc2b620 100644 --- a/hub_core/templates/project_config_template.yaml +++ b/hub_core/templates/project_config_template.yaml @@ -36,6 +36,10 @@ project: version: "1.0.0" target_journal: "Nature Communications" +# 1.1 Workflow intent +workflow: + intent: "execution" # exploration | execution | review | promotion + # 2. Visual style settings visual_style: render_policy: "neutral" # preserves authored styling; independent of validation diff --git a/hub_core/workflow_intent.py b/hub_core/workflow_intent.py new file mode 100644 index 0000000..d516e4a --- /dev/null +++ b/hub_core/workflow_intent.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Mapping + +INTENT_EXPLORATION = "exploration" +INTENT_EXECUTION = "execution" +INTENT_REVIEW = "review" +INTENT_PROMOTION = "promotion" +WORKFLOW_INTENTS = (INTENT_EXPLORATION, INTENT_EXECUTION, INTENT_REVIEW, INTENT_PROMOTION) + +SOURCE_EXPLICIT = "explicit" +SOURCE_ORCHESTRATOR = "orchestrator" +SOURCE_MCP = "mcp" +SOURCE_DIRECT_CSV = "direct_csv" +SOURCE_READ_ONLY = "read_only" +SOURCE_READINESS = "readiness" +SOURCE_LEGACY = "legacy" +WORKFLOW_SOURCES = ( + SOURCE_EXPLICIT, SOURCE_ORCHESTRATOR, SOURCE_MCP, SOURCE_DIRECT_CSV, + SOURCE_READ_ONLY, SOURCE_READINESS, SOURCE_LEGACY, +) + +ORCHESTRATOR_EXECUTION_STEPS = frozenset({"all", "analysis", "plot"}) +MCP_EXECUTION_TOOLS = frozenset({"figops.render_project_script", "figops.render_project_figure"}) +DIRECT_CSV_TOOLS = frozenset({"figops.render_basic_csv", "figops.render_csv_graph", "figops.render_csv_multipanel"}) +READINESS_TOOLS = frozenset({"figops.evaluate_publication_readiness"}) +READ_ONLY_TOOLS = frozenset( + { + "figops.health", "figops.describe", "figops.list_styles", "figops.list_projects", + "figops.inspect_project", "figops.validate_project", "figops.collect_artifacts", + "figops.inspect_data", "figops.audit_artifact", + } +) +EXECUTION_INTENTS = frozenset({INTENT_EXECUTION}) +EXPLORATION_REVIEW_INTENTS = frozenset({INTENT_EXPLORATION, INTENT_REVIEW}) +REVIEW_INTENTS = frozenset({INTENT_REVIEW}) +ORCHESTRATOR_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_ORCHESTRATOR}) +MCP_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_MCP}) +DIRECT_CSV_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_DIRECT_CSV}) +READ_ONLY_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_READINESS, SOURCE_READ_ONLY}) +SURFACE_RULES = MappingProxyType( + { + SOURCE_ORCHESTRATOR: (INTENT_EXECUTION, EXECUTION_INTENTS, ORCHESTRATOR_SOURCES), + SOURCE_MCP: (INTENT_EXECUTION, EXECUTION_INTENTS, MCP_SOURCES), + SOURCE_DIRECT_CSV: (INTENT_EXPLORATION, EXPLORATION_REVIEW_INTENTS, DIRECT_CSV_SOURCES), + SOURCE_READINESS: (INTENT_REVIEW, REVIEW_INTENTS, READ_ONLY_SOURCES), + SOURCE_READ_ONLY: (INTENT_REVIEW, REVIEW_INTENTS, READ_ONLY_SOURCES), + } +) + + +class WorkflowIntentError(ValueError): + """A workflow intent or provenance source is outside the closed vocabulary.""" + + +def _normalize_token(value: object, *, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise WorkflowIntentError(f"{field_name} must be a non-empty string") + return value.strip().lower().replace("-", "_").replace(" ", "_") + + +def parse_workflow_intent(value: object) -> str: + normalized = _normalize_token(value, field_name="workflow intent") + if normalized not in WORKFLOW_INTENTS: + raise WorkflowIntentError(f"unknown workflow intent: {value!r}") + return normalized + + +def parse_workflow_source(value: object) -> str: + normalized = _normalize_token(value, field_name="workflow source") + if normalized not in WORKFLOW_SOURCES: + raise WorkflowIntentError(f"unknown workflow source: {value!r}") + return normalized + + +def _tool_name(value: object) -> str: + return str(value or "").strip() + + +def _legacy_surface(tool_name: str, project_status: object, source: str | None) -> bool: + status = str(project_status or "").strip().lower() + return status == "legacy" or source == SOURCE_LEGACY or tool_name.startswith("graphhub.") + + +@dataclass(frozen=True, slots=True) +class WorkflowIntent: + intent: str | None + source: str | None + provenance: Mapping[str, Any] = field(default_factory=dict) + fail_closed: bool = False + legacy: bool = False + issues: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.intent is not None and self.intent not in WORKFLOW_INTENTS: + raise WorkflowIntentError(f"unknown workflow intent: {self.intent!r}") + if self.source is not None and self.source not in WORKFLOW_SOURCES: + raise WorkflowIntentError(f"unknown workflow source: {self.source!r}") + object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance))) + object.__setattr__(self, "issues", tuple(self.issues)) + + @property + def execution_allowed(self) -> bool: + return self.intent == INTENT_EXECUTION and not self.fail_closed and not self.legacy + + @property + def promotion_allowed(self) -> bool: + return self.intent == INTENT_PROMOTION and not self.fail_closed and not self.legacy + + @property + def read_only(self) -> bool: + return self.intent == INTENT_REVIEW or self.fail_closed or self.legacy + + @property + def promotable(self) -> bool: + return self.promotion_allowed + + @property + def digest(self) -> str: + return workflow_intent_digest(self) + + def to_dict(self) -> dict[str, Any]: + payload = { + "schema_version": "figops-workflow-intent/1", + "intent": self.intent, + "source": self.source, + "provenance": dict(self.provenance), + } + payload.update( + fail_closed=self.fail_closed, legacy=self.legacy, execution_allowed=self.execution_allowed, + promotion_allowed=self.promotion_allowed, read_only=self.read_only, promotable=self.promotable, + issues=list(self.issues), + ) + return payload + + +def workflow_intent_digest(intent: WorkflowIntent) -> str: + payload = json.dumps(intent.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _fail_closed( + *, + intent: str | None, + source: str | None, + provenance: Mapping[str, Any], + issues: tuple[str, ...], +) -> WorkflowIntent: + return WorkflowIntent(intent=intent, source=source, provenance=provenance, fail_closed=True, issues=issues) + + +def _surface_intent( + surface_source: str, + provenance: Mapping[str, Any], + requested_intent: str | None, + requested_source: str | None, + default_intent: str | None = None, + compatible_sources: frozenset[str] | None = None, +) -> WorkflowIntent: + rule_default, compatible_intents, rule_sources = SURFACE_RULES[surface_source] + default_intent = default_intent or rule_default + compatible_sources = compatible_sources or rule_sources + if requested_source is not None and requested_source not in compatible_sources: + safe_intent = INTENT_REVIEW if requested_source in {SOURCE_READ_ONLY, SOURCE_READINESS} else default_intent + return _fail_closed( + intent=safe_intent, + source=surface_source, + provenance=provenance, + issues=(f"requested source {requested_source!r} conflicts with {surface_source!r} surface",), + ) + if requested_intent is not None and requested_intent not in compatible_intents: + safe_intent = default_intent if requested_intent == INTENT_EXECUTION else requested_intent + return _fail_closed( + intent=safe_intent, + source=surface_source, + provenance=provenance, + issues=( + f"requested intent {requested_intent!r} conflicts with " + f"{surface_source!r} surface default {default_intent!r}", + ), + ) + return WorkflowIntent(intent=default_intent, source=surface_source, provenance=provenance) + + +def infer_workflow_intent( + *, + active: bool = False, + step: object = None, + tool_name: object = None, + source: object = None, + requested_intent: object = None, + project_status: object = None, +) -> WorkflowIntent: + provenance: dict[str, Any] = { + "active": bool(active), + "step": None if step is None else str(step), + "tool_name": None if tool_name is None else _tool_name(tool_name), + "requested_intent": None if requested_intent is None else str(requested_intent), + "requested_source": None if source is None else str(source), + "project_status": None if project_status is None else str(project_status), + } + issues: list[str] = [] + parsed_intent: str | None = None + parsed_source: str | None = None + if requested_intent is not None: + try: + parsed_intent = parse_workflow_intent(requested_intent) + except WorkflowIntentError as exc: + issues.append(str(exc)) + if source is not None: + try: + parsed_source = parse_workflow_source(source) + except WorkflowIntentError as exc: + issues.append(str(exc)) + + tool = provenance["tool_name"] or "" + if _legacy_surface(tool, project_status, parsed_source): + return WorkflowIntent( + intent=INTENT_REVIEW, + source=SOURCE_LEGACY, + provenance=provenance, + legacy=True, + issues=("legacy workflows are read-only, non-promotable, and never infer execution",), + ) + if issues: + return _fail_closed(intent=parsed_intent, source=parsed_source, provenance=provenance, issues=tuple(issues)) + + if step is not None: + try: + normalized_step = _normalize_token(step, field_name="orchestrator step") + except WorkflowIntentError as exc: + return _fail_closed( + intent=INTENT_REVIEW, + source=SOURCE_ORCHESTRATOR, + provenance=provenance, + issues=(str(exc),), + ) + if active and normalized_step in ORCHESTRATOR_EXECUTION_STEPS: + return _surface_intent(SOURCE_ORCHESTRATOR, provenance, parsed_intent, parsed_source) + issue = f"unknown or inactive orchestrator execution step: {step!r}" + return _fail_closed(intent=INTENT_REVIEW, source=SOURCE_ORCHESTRATOR, provenance=provenance, issues=(issue,)) + if tool: + if active and tool in MCP_EXECUTION_TOOLS: + return _surface_intent(SOURCE_MCP, provenance, parsed_intent, parsed_source) + if active and tool in DIRECT_CSV_TOOLS: + return _surface_intent(SOURCE_DIRECT_CSV, provenance, parsed_intent, parsed_source) + if tool in READINESS_TOOLS: + return _surface_intent(SOURCE_READINESS, provenance, parsed_intent, parsed_source) + if tool in READ_ONLY_TOOLS or not active: + return _surface_intent(SOURCE_READ_ONLY, provenance, parsed_intent, parsed_source) + return _fail_closed( + intent=INTENT_REVIEW, + source=parsed_source, + provenance=provenance, + issues=(f"unknown active workflow tool: {tool!r}",), + ) + if parsed_source in {SOURCE_READ_ONLY, SOURCE_READINESS}: + return _surface_intent( + parsed_source, + provenance, + parsed_intent, + parsed_source, + compatible_sources=frozenset({parsed_source}), + ) + if parsed_source == SOURCE_DIRECT_CSV: + return _surface_intent( + parsed_source, + provenance, + parsed_intent, + parsed_source, + default_intent=INTENT_EXPLORATION if active else INTENT_REVIEW, + compatible_sources=frozenset({parsed_source}), + ) + if parsed_intent is not None and parsed_source in {None, SOURCE_EXPLICIT}: + return WorkflowIntent(intent=parsed_intent, source=parsed_source or SOURCE_EXPLICIT, provenance=provenance) + return WorkflowIntent(intent=INTENT_REVIEW, source=parsed_source or SOURCE_READ_ONLY, provenance=provenance) diff --git a/project_config_template.yaml b/project_config_template.yaml index 5355a10..f45c711 100644 --- a/project_config_template.yaml +++ b/project_config_template.yaml @@ -37,6 +37,10 @@ project: version: "1.0.0" target_journal: "Nature Communications" +# 1.1 Workflow intent +workflow: + intent: "execution" # exploration | execution | review | promotion + # 2. 통합 시각화 스타일 설정 (Theme Vending Machine) visual_style: render_policy: "neutral" # preserves authored styling; independent of validation diff --git a/tests/fixtures/ai_native_agent_eval/final-v2.json b/tests/fixtures/ai_native_agent_eval/final-v2.json index 33a7bfe..55cf049 100644 --- a/tests/fixtures/ai_native_agent_eval/final-v2.json +++ b/tests/fixtures/ai_native_agent_eval/final-v2.json @@ -19,8 +19,8 @@ "figops.audit_artifact" ], "emitted_tool_definition_count": 7, - "tools_list_response_bytes": 11973, - "tools_array_bytes": 11929, + "tools_list_response_bytes": 14477, + "tools_array_bytes": 14433, "maximum_input_schema_bytes": 1102, "maximum_input_schema_tool": "figops.render_basic_csv", "render_basic_csv_top_level_properties": 12 @@ -45,18 +45,18 @@ "generated_references": { "default_full": { "path": "docs/tools.md", - "bytes": 157174, - "lines": 7707 + "bytes": 167680, + "lines": 8161 }, "v2": { "path": "docs/tools-v2.md", - "bytes": 18389, - "lines": 1071 + "bytes": 23642, + "lines": 1298 }, "compatibility": { "path": "docs/tools-compatibility.md", - "bytes": 131686, - "lines": 5992 + "bytes": 136939, + "lines": 6219 } }, "guided_call_contract": { diff --git a/tests/test_config_workflow_intent.py b/tests/test_config_workflow_intent.py new file mode 100644 index 0000000..fd6868d --- /dev/null +++ b/tests/test_config_workflow_intent.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import copy +from pathlib import Path + +import yaml + +from hub_core.config_parser import ( + normalize_workflow_defaults, + validate_config, + workflow_intent, + workflow_intent_report, +) +from hub_core.project_layout import build_scaffold_config_text +from hub_core.workflow_intent import INTENT_EXECUTION, INTENT_EXPLORATION, INTENT_REVIEW, infer_workflow_intent + + +def _minimal_config() -> dict: + return { + "project": {"name": "Workflow Intent Demo"}, + "visual_style": {"target_format": "neutral"}, + } + + +def test_active_project_without_declared_workflow_normalizes_to_execution() -> None: + config = normalize_workflow_defaults(copy.deepcopy(_minimal_config())) + + assert validate_config(config) == [] + assert config["workflow"]["intent"] == INTENT_EXECUTION + assert workflow_intent(config) == INTENT_EXECUTION + report = workflow_intent_report(config) + assert report["execution_allowed"] is True + assert report["provenance"]["config_source"] == "declared" + + +def test_unknown_workflow_intent_fails_closed_but_remains_inspectable() -> None: + config = _minimal_config() + config["workflow"] = {"intent": "run"} + + errors = validate_config(config) + report = workflow_intent_report(config) + + assert any("Invalid workflow.intent" in error for error in errors) + assert report["intent"] is None + assert report["execution_allowed"] is False + assert report["fail_closed"] is True + assert report["provenance"]["config_source"] == "declared-invalid" + assert report["issues"] == ["unknown workflow intent: 'run'"] + + +def test_legacy_projects_normalize_to_read_only_non_promotable_intent() -> None: + config = _minimal_config() + config["project"]["status"] = "legacy" + normalized = normalize_workflow_defaults(copy.deepcopy(config)) + explicit_execution = copy.deepcopy(config) + explicit_execution["workflow"] = {"intent": INTENT_EXECUTION} + + assert normalized["workflow"]["intent"] == INTENT_REVIEW + report = workflow_intent_report(normalized) + assert report["intent"] == INTENT_REVIEW + assert report["execution_allowed"] is False + assert report["promotable"] is False + assert report["read_only"] is True + assert any("legacy" in error for error in validate_config(explicit_execution)) + + +def test_explicit_project_draft_intent_conflicts_with_execution_surface_without_mutating_config() -> None: + config = normalize_workflow_defaults(copy.deepcopy(_minimal_config())) + config["workflow"]["intent"] = INTENT_EXPLORATION + + report = workflow_intent_report(config, active=True, step="plot") + + assert config["workflow"]["intent"] == INTENT_EXPLORATION + assert validate_config(config) == [] + assert report["intent"] == INTENT_EXPLORATION + assert report["source"] == "orchestrator" + assert report["execution_allowed"] is False + assert report["fail_closed"] is True + assert report["provenance"]["config_source"] == "declared" + assert "conflicts with 'orchestrator' surface" in report["issues"][0] + + +def test_direct_csv_and_read_only_compatibility_intents_never_enable_execution() -> None: + csv_intent = infer_workflow_intent(active=True, tool_name="figops.render_csv_graph") + read_only_intent = infer_workflow_intent(active=True, tool_name="figops.inspect_project") + + assert csv_intent.intent == INTENT_EXPLORATION + assert csv_intent.execution_allowed is False + assert csv_intent.promotable is False + assert read_only_intent.intent == INTENT_REVIEW + assert read_only_intent.execution_allowed is False + assert read_only_intent.read_only is True + + +def test_project_templates_and_scaffolded_config_declare_execution_intent() -> None: + hub_path = Path(__file__).resolve().parents[1] + template_paths = [ + hub_path / "project_config_template.yaml", + hub_path / "hub_core" / "templates" / "project_config_template.yaml", + ] + + for template_path in template_paths: + config = yaml.safe_load(template_path.read_text(encoding="utf-8")) + assert config["workflow"]["intent"] == INTENT_EXECUTION + assert validate_config(config) == [] + + scaffolded = yaml.safe_load( + build_scaffold_config_text(hub_path, "Scaffold Workflow Demo", "neutral", font_scale=1.0) + ) + assert scaffolded["workflow"]["intent"] == INTENT_EXECUTION + assert validate_config(scaffolded) == [] diff --git a/tests/test_evidence_contract.py b/tests/test_evidence_contract.py index 9093db5..71cba28 100644 --- a/tests/test_evidence_contract.py +++ b/tests/test_evidence_contract.py @@ -1388,6 +1388,84 @@ def test_resolved_policy_with_identity_version_and_source_is_valid() -> None: validate_evidence_envelope(envelope) +def test_policy_context_with_digest_and_render_policy_is_valid() -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {"style_policy": "neutral"}, + }, + "validation_target": None, + } + + normalized = normalize_evidence_envelope(envelope) + + assert normalized["policy_context"]["policy_set_sha256"] == "b" * 64 + + +@pytest.mark.parametrize( + ("field", "value", "code"), + [ + ("policy_set_sha256", None, "SHA256_INVALID"), + ("policy_set_sha256", "not-a-digest", "SHA256_INVALID"), + ("schema_version", "figops-render-policy-context/0", "POLICY_CONTEXT_SCHEMA_INVALID"), + ("source", "", "NONEMPTY_STRING"), + ("render_policy", None, "TYPE_MAPPING"), + ("validation_target", "", "NONEMPTY_STRING"), + ], +) +def test_policy_context_rejects_malformed_fields(field: str, value: object, code: str) -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {}, + }, + "validation_target": None, + } + if value is None: + envelope["policy_context"].pop(field) + else: + envelope["policy_context"][field] = value + + with pytest.raises(EvidenceContractError) as raised: + validate_evidence_envelope(envelope) + + assert raised.value.code == code + + +def test_policy_context_rejects_unknown_fields() -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {}, + }, + "validation_target": None, + "policy_set": {}, + } + + with pytest.raises(EvidenceContractError) as raised: + validate_evidence_envelope(envelope) + + assert raised.value.code == "UNKNOWN_FIELD" + + @pytest.mark.parametrize("field", ["id", "version", "source"]) def test_resolved_policy_requires_identity_version_and_source(field: str) -> None: envelope = _minimal_envelope() diff --git a/tests/test_mcp_rendering.py b/tests/test_mcp_rendering.py index 58b7733..efbaff3 100644 --- a/tests/test_mcp_rendering.py +++ b/tests/test_mcp_rendering.py @@ -365,6 +365,12 @@ def test_tool_definitions_include_controlled_rendering_tools(self): self.assertIn("selected_figure", project_output) self.assertIn("snapshot_project_path", project_output) self.assertIn("provenance", project_output) + self.assertIn("policy_context", project_output) + self.assertIn("workflow_intent", project_output) + for csv_tool in ("figops.render_csv_graph", "figops.render_csv_multipanel"): + csv_output = definitions[csv_tool]["outputSchema"]["properties"] + self.assertNotIn("policy_context", csv_output) + self.assertNotIn("workflow_intent", csv_output) def test_render_csv_graph_schema_exposes_legend_axis_polish_controls(self): definitions = {tool["name"]: tool for tool in list_tool_definitions()} @@ -4642,10 +4648,24 @@ def test_schema_validity_per_tool_scoped(self): {"type": "string", "enum": ["verified", "unverified"]}, ) self.assertEqual(project_properties["promotion_eligible"]["type"], "boolean") + self.assertFalse(project_properties["policy_context"].get("additionalProperties", True)) + self.assertFalse( + project_properties["policy_context"]["properties"]["policy_set"].get("additionalProperties", True) + ) + self.assertFalse(project_properties["workflow_intent"].get("additionalProperties", True)) + self.assertFalse( + project_properties["workflow_intent"]["properties"]["provenance"].get("additionalProperties", True) + ) + for csv_tool in ("figops.render_csv_graph", "figops.render_csv_multipanel"): + csv_properties = definitions[csv_tool]["outputSchema"]["properties"] + self.assertNotIn("policy_context", csv_properties) + self.assertNotIn("workflow_intent", csv_properties) for optional_success_field in ( "claim_inventory", "publication_status", "promotion_eligible", + "policy_context", + "workflow_intent", ): self.assertNotIn(optional_success_field, project_schema.get("required", [])) with tempfile.TemporaryDirectory(prefix="graph_hub_mcp_geom_") as tmpdir: @@ -4695,6 +4715,8 @@ def test_schema_validity_per_tool_scoped(self): self._assert_validates(project_success, project_schema) self.assertIn("geometry_diagnostics", project_success) self.assertIn("layout_report", project_success) + self.assertIn("policy_context", project_success) + self.assertIn("workflow_intent", project_success) with tempfile.TemporaryDirectory(prefix="graph_hub_mcp_geom_") as tmpdir: root = Path(tmpdir) / "ResearchOS" diff --git a/tests/test_mcp_v2_tools.py b/tests/test_mcp_v2_tools.py index 90910b8..02c2c30 100644 --- a/tests/test_mcp_v2_tools.py +++ b/tests/test_mcp_v2_tools.py @@ -66,6 +66,8 @@ def test_basic_render_is_one_call_with_validated_evidence_and_lazy_uris(tmp_path validate_evidence_envelope(result["evidence"]) assert result["evidence"]["provenance"]["output_sha256"] == result["artifact"]["sha256"] assert not ({"created_paths", "job_root", "output_path", "config_path"} & set(result)) + assert "policy_context" not in result + assert "workflow_intent" not in result assert "blob" not in json.dumps(result).lower() manifest = json.loads( (tmp_path / "runtime" / "mcp_jobs" / "v2-basic" / "manifest.json").read_text(encoding="utf-8") @@ -89,6 +91,10 @@ def test_project_script_render_executes_only_declared_python_and_returns_same_co assert result["preview_uri"] == "figops://jobs/v2-project/previews/primary/0" validate_evidence_envelope(result["evidence"]) assert result["evidence"]["producer"]["kind"] == "mcp-project-script-render" + assert result["policy_context"]["schema_version"] == "figops-render-policy-context/1" + assert result["policy_context"]["render_policy"]["id"] == "render-neutral" + assert result["workflow_intent"]["schema_version"] == "figops-workflow-intent/1" + assert result["workflow_intent"]["intent"] == "execution" def test_project_rscript_missing_is_typed_and_creates_no_job( @@ -369,12 +375,25 @@ def test_all_tool_annotations_are_truthful_and_v2_schemas_fit_budgets() -> None: size = len(json.dumps(definitions[name]["inputSchema"], separators=(",", ":")).encode()) assert size <= 6 * 1024 assert len(definitions["figops.render_basic_csv"]["inputSchema"]["properties"]) <= 14 + project_properties = definitions["figops.render_project_script"]["outputSchema"]["properties"] + assert not project_properties["policy_context"].get("additionalProperties", True) + assert not project_properties["policy_context"]["properties"]["policy_set"].get( + "additionalProperties", True + ) + assert not project_properties["workflow_intent"].get("additionalProperties", True) + assert not project_properties["workflow_intent"]["properties"]["provenance"].get( + "additionalProperties", True + ) + basic_properties = definitions["figops.render_basic_csv"]["outputSchema"]["properties"] + assert "policy_context" not in basic_properties + assert "workflow_intent" not in basic_properties def test_wp5_touched_modules_stay_below_modularity_gate() -> None: root = Path(__file__).parents[1] for relative in ( "hub_core/mcp/schemas.py", + "hub_core/mcp/phase2_render_schemas.py", "hub_core/mcp/tool_schema_common.py", "hub_core/mcp/v2_tool_schemas.py", "hub_core/mcp/render_orchestration.py", diff --git a/tests/test_policy_resolution.py b/tests/test_policy_resolution.py new file mode 100644 index 0000000..b0b4d31 --- /dev/null +++ b/tests/test_policy_resolution.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import hashlib +from dataclasses import FrozenInstanceError + +import pytest + +from hub_core.policy_resolution import ( + PolicyResolutionError, + compatibility_resolved_policy, + parse_policy_layers_json, + resolve_policy_set, +) + + +def _layer(source: str, parameters: dict, policy_id: str | None = None) -> dict: + return { + "source": source, + "policy_id": policy_id or f"{source}-policy", + "version": "1", + "parameters": parameters, + } + + +KERNEL_INVARIANTS = ( + "path_containment", + "schema_receipt_integrity", + "runtime_result_disjointness", + "no_replace_promotion", +) + + +def test_defaults_are_canonical_digestible_and_immutable() -> None: + policy = resolve_policy_set([]) + render = policy.value("render_policy") + + assert render.value == "neutral" + assert policy.value("path_containment").value is True + assert policy.value("validation_target").value is None + assert policy.canonical_sha256() == hashlib.sha256(policy.canonical_bytes()).hexdigest() + with pytest.raises(FrozenInstanceError): + render.value = "nature" # type: ignore[misc] + + +def test_render_validation_axes_project_to_legacy_resolved_policy() -> None: + policy = resolve_policy_set( + [_layer("render", {"render_policy": "neutral", "validation_target": "nature"})] + ) + + assert compatibility_resolved_policy(policy) == { + "id": "journal-nature", + "version": "1", + "source": "policy-set-compatibility-projection", + "parameters": {"render_policy": "render-neutral", "validation_target": "nature"}, + } + + +def test_research_ops_false_opt_out_preserves_current_project_fact() -> None: + policy = resolve_policy_set( + [_layer("project", {"require_figure_traceability": {"value": False, "opt_out": True}})] + ) + traceability = policy.value("require_figure_traceability") + + assert traceability.value is False + assert traceability.source == "project" + assert traceability.opt_out_requested is True + assert traceability.opt_out_accepted is True + assert policy.value("require_canonical_docs").value is True + + +def test_research_ops_opt_out_only_records_explicit_source_and_digest_fact() -> None: + default = resolve_policy_set([]) + policy = resolve_policy_set( + [_layer("project", {"require_canonical_docs": {"opt_out": True}}, "project-config-policy")] + ) + canonical_docs = policy.value("require_canonical_docs") + + assert canonical_docs.value is False + assert canonical_docs.source == "explicit_project_opt_out" + assert canonical_docs.policy_id == "project-config-policy" + assert canonical_docs.opt_out_requested is True + assert canonical_docs.opt_out_accepted is True + assert canonical_docs.constraints[0].source == "explicit_project_opt_out" + assert canonical_docs.constraints[0].value is False + assert b"explicit_project_opt_out" in policy.canonical_bytes() + assert policy.canonical_sha256() != default.canonical_sha256() + + +def test_higher_require_blocks_lower_opt_out_without_silent_override() -> None: + policy = resolve_policy_set( + [ + _layer("operator", {"require_figure_traceability": True}), + _layer("project", {"require_figure_traceability": {"value": False, "opt_out": True}}), + ] + ) + traceability = policy.value("require_figure_traceability") + + assert traceability.value is True + assert traceability.source == "operator" + assert traceability.opt_out_requested is True + assert traceability.opt_out_accepted is False + + +def test_kernel_invariants_are_not_opt_out_capable() -> None: + with pytest.raises(PolicyResolutionError, match="path_containment immutable kernel invariant"): + resolve_policy_set([_layer("project", {"path_containment": {"value": False, "opt_out": True}})]) + + +@pytest.mark.parametrize("invariant", KERNEL_INVARIANTS) +@pytest.mark.parametrize( + "disable_form", + [ + False, + {"value": False}, + {"value": False, "opt_out": True}, + ], +) +def test_kernel_invariants_reject_all_false_disable_forms(invariant: str, disable_form: object) -> None: + with pytest.raises(PolicyResolutionError, match="immutable kernel invariant cannot be disabled"): + resolve_policy_set([_layer("project", {invariant: disable_form})]) + + +@pytest.mark.parametrize("invariant", KERNEL_INVARIANTS) +def test_kernel_invariants_reject_opt_out_only_disable_form(invariant: str) -> None: + with pytest.raises(PolicyResolutionError, match="does not allow opt-out"): + resolve_policy_set([_layer("project", {invariant: {"opt_out": True}})]) + + +def test_selection_allowed_sets_only_narrow_and_conflict_fail_closed() -> None: + with pytest.raises(PolicyResolutionError, match="empty allowed-set intersection"): + resolve_policy_set( + [ + _layer("operator", {"render_policy": {"allowed": ["nature"]}}), + _layer("project", {"render_policy": {"allowed": ["nature", "science"]}}), + _layer("render", {"render_policy": "science"}), + ] + ) + + +def test_exact_and_equal_source_conflicts_fail_closed() -> None: + with pytest.raises(PolicyResolutionError, match="exact values conflict"): + resolve_policy_set( + [ + _layer("lab", {"project_role": "module"}), + _layer("project", {"project_role": "legacy"}), + ] + ) + with pytest.raises(PolicyResolutionError, match="duplicate policy source"): + resolve_policy_set([_layer("project", {}), _layer("project", {}, "project-policy-2")]) + + +def test_all_merge_operators_are_deterministic() -> None: + policy = resolve_policy_set( + [ + _layer( + "operator", + { + "minimum_raster_dpi": 300, + "maximum_physical_width_mm": 180, + "allowed_artifact_formats": {"allowed": ["png", "pdf"]}, + }, + ), + _layer( + "project", + { + "minimum_raster_dpi": 600, + "maximum_physical_width_mm": 120, + "allowed_artifact_formats": {"allowed": ["png"]}, + }, + ), + ] + ) + + assert policy.value("minimum_raster_dpi").value == 600 + assert policy.value("maximum_physical_width_mm").value == 120 + assert policy.value("allowed_artifact_formats").value == ["png"] + + +def test_unknown_duplicate_nonfinite_and_path_like_inputs_are_rejected() -> None: + with pytest.raises(PolicyResolutionError, match="duplicate key"): + parse_policy_layers_json( + b'[{"source":"project","source":"project","policy_id":"p","version":"1","parameters":{}}]' + ) + with pytest.raises(PolicyResolutionError, match="non-finite"): + resolve_policy_set( + b'[{"source":"project","policy_id":"p","version":"1","parameters":{"minimum_raster_dpi":NaN}}]' + ) + with pytest.raises(PolicyResolutionError, match="unsupported or missing"): + resolve_policy_set([_layer("project", {"unknown_axis": True})]) + with pytest.raises(PolicyResolutionError, match="path-like"): + resolve_policy_set([_layer("project", {"project_role": "C:/research/project"})]) + + +def test_exceptions_are_recorded_but_do_not_change_resolved_values() -> None: + policy = resolve_policy_set( + [ + _layer( + "project", + { + "require_canonical_docs": { + "value": True, + "exceptions": [ + {"finding_code": "CANONICAL_DOC_EVIDENCE_INVALID", "subject_digest": "a" * 64} + ], + } + }, + ) + ] + ) + canonical_docs = policy.value("require_canonical_docs") + + assert canonical_docs.value is True + assert canonical_docs.exceptions[0].finding_code == "CANONICAL_DOC_EVIDENCE_INVALID" + assert canonical_docs.exceptions[0].subject_digest == "a" * 64 + + +def test_canonical_digest_is_stable_for_equivalent_mapping_order() -> None: + left = resolve_policy_set([_layer("render", {"render_policy": "neutral", "validation_target": "nature"})]) + right = resolve_policy_set([_layer("render", {"validation_target": "nature", "render_policy": "neutral"})]) + + assert left.canonical_bytes() == right.canonical_bytes() + assert left.canonical_sha256() == right.canonical_sha256() diff --git a/tests/test_release_discipline.py b/tests/test_release_discipline.py index 40d12f1..a26e327 100644 --- a/tests/test_release_discipline.py +++ b/tests/test_release_discipline.py @@ -2,11 +2,27 @@ import tomllib from pathlib import Path -from scripts.release_discipline import stale_post_tag_release_blocker +from scripts.release_discipline import post_tag_release_blocker, stale_post_tag_release_blocker HUB_ROOT = Path(__file__).resolve().parent.parent SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") CHANGELOG_VERSION_RE = re.compile(r"^## \[(?P\d+\.\d+\.\d+)] - \d{4}-\d{2}-\d{2}$", re.MULTILINE) +POST_TAG_METADATA_ROW = "| post_tag_metadata | 1 | requires_release_decision | yes |" + + +def _assert_release_status_matches_post_tag_state(status: str, post_tag_blocker: str | None) -> None: + if post_tag_blocker is None: + assert "- Technical release gate: ok" in status + assert "- Repository technically eligible for public release: yes" in status + assert "- Repository release allowed: yes" in status + assert "post_tag_metadata" not in status + return + + assert "Release metadata is stale" in post_tag_blocker + assert "- Technical release gate: blocked" in status + assert "- Repository technically eligible for public release: no" in status + assert "- Repository release allowed: no" in status + assert POST_TAG_METADATA_ROW in status def test_package_version_is_semver_and_matches_latest_changelog_entry(): @@ -43,6 +59,27 @@ def test_stale_post_tag_release_blocker_allows_bumped_package_and_changelog_vers assert blocker is None +def test_release_status_assertion_preserves_clean_gate_expectations(): + status = """ +- Repository technically eligible for public release: yes +- Repository release allowed: yes +- Technical release gate: ok +""" + + _assert_release_status_matches_post_tag_state(status, None) + + +def test_release_status_assertion_fails_closed_for_stale_post_tag_metadata(): + status = f""" +- Repository technically eligible for public release: no +- Repository release allowed: no +- Technical release gate: blocked +{POST_TAG_METADATA_ROW} +""" + + _assert_release_status_matches_post_tag_state(status, "Release metadata is stale: 3 commits after v0.20.0") + + def test_v020_release_requires_structure_p1_gate(): status = (HUB_ROOT / "docs" / "packaging" / "public-release-status.md").read_text( encoding="utf-8" @@ -58,11 +95,11 @@ def test_v020_release_requires_structure_p1_gate(): ).read_text(encoding="utf-8") # A green technical inventory remains necessary; owner-recorded evidence - # supplies release authority for this scoped version. - assert "- Technical release gate: ok" in status - assert "- Repository technically eligible for public release: yes" in status + # supplies release authority for this scoped version. Post-tag metadata + # drift must fail closed until the next release version is chosen. + _assert_release_status_matches_post_tag_state(status, post_tag_release_blocker(HUB_ROOT)) + assert "- Repository publication authorized: yes" in status - assert "- Repository release allowed: yes" in status assert "- Authorization evidence references: 1" in status assert "technical gate remains independent evidence" in status assert "Decision record:" in status diff --git a/tests/test_render_evidence.py b/tests/test_render_evidence.py index 176fc12..6e43e0d 100644 --- a/tests/test_render_evidence.py +++ b/tests/test_render_evidence.py @@ -2,6 +2,7 @@ import copy import hashlib +import json from pathlib import Path import matplotlib.pyplot as plt @@ -15,7 +16,9 @@ RULE_VERSION, ArtifactPolicyMeasurementError, measure_artifact_policy, + resolve_render_policy_context, resolve_render_policy_selection, + resolve_render_validation_policies, verify_artifact_policy_projection, ) from hub_core.evidence_contract import EvidenceContractError, validate_evidence_envelope @@ -188,6 +191,80 @@ def test_primary_output_hash_mismatch_still_fails_closed_for_vector_render(tmp_p assert raised.value.code == "PRIMARY_OUTPUT_HASH_CONFLICT" +def test_policy_context_preserves_neutral_v2_and_nature_compatibility_split() -> None: + v2 = resolve_render_policy_context({}, compatibility=False) + compatibility = resolve_render_policy_context({}, compatibility=True) + + assert v2["render_policy"]["id"] == "render-neutral" + assert v2["render_policy"]["source"] == "v2-default" + assert v2["validation_target"] is None + assert compatibility["render_policy"]["id"] == "render-nature" + assert compatibility["render_policy"]["source"] == "compatibility-default" + assert compatibility["validation_target"] is None + + inferred_target, inferred_policy = resolve_render_validation_policies({}, target_format="nature") + v2_target, v2_policy = resolve_render_validation_policies( + {"v2_policy_contract": True}, + target_format="nature", + ) + assert (inferred_target, inferred_policy["id"]) == ("nature", "render-nature") + assert (v2_target, v2_policy["id"]) == ("", "render-nature") + + +def test_explicit_render_policy_wins_over_compatibility_target_projection() -> None: + explicit = { + "id": "render-science", + "version": "1", + "source": "explicit-render-policy", + "parameters": {"style_policy": "science", "mutates_journal_aesthetics": True}, + } + context = resolve_render_policy_context( + {"resolved_render_policy": explicit}, + target_format="nature", + compatibility=True, + ) + + assert context["render_policy"] == explicit + assert context["validation_target"] == "nature" + assert context["policy_set"]["parameters"]["render_policy"]["value"] == "science" + assert context["policy_set"]["parameters"]["validation_target"]["value"] == "nature" + + +def test_policy_context_records_source_opt_out_provenance_and_canonical_digest() -> None: + layer = { + "source": "project", + "policy_id": "project-research-ops", + "version": "1", + "parameters": {"require_figure_traceability": {"opt_out": True}}, + } + + left = resolve_render_policy_context({}, compatibility=False, policy_layers=[layer]) + reordered_layer = { + "parameters": layer["parameters"], + "version": layer["version"], + "policy_id": layer["policy_id"], + "source": layer["source"], + } + right = resolve_render_policy_context({}, compatibility=False, policy_layers=[reordered_layer]) + traceability = left["policy_set"]["parameters"]["require_figure_traceability"] + canonical_digest = hashlib.sha256( + json.dumps( + left["policy_set"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + assert left["source"] == "v2-default" + assert traceability["source"] == "explicit_project_opt_out" + assert traceability["opt_out_requested"] is True + assert traceability["opt_out_accepted"] is True + assert left["policy_set_sha256"] == canonical_digest + assert left["policy_set_sha256"] == right["policy_set_sha256"] + + def test_validator_measures_artifact_without_render_mutation(tmp_path: Path) -> None: job_root = tmp_path / "job" primary = job_root / "results" / "figure.png" @@ -217,6 +294,121 @@ def test_validator_measures_artifact_without_render_mutation(tmp_path: Path) -> assert evidence["policy_projections"][0]["status"] == "blocked" +def test_build_render_evidence_consumes_policy_context_with_old_policy_fields(tmp_path: Path) -> None: + job_root = tmp_path / "job" + primary = job_root / "results" / "figure.png" + primary.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "navy").save(primary, format="PNG", dpi=(300, 300)) + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("context-neutral-nature-audit", previews, _sha256(primary)) + context = resolve_render_policy_context({"validation_target": "nature"}, compatibility=False) + + evidence = build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-neutral-render", + producer_version="1", + policy_context=context, + ) + + assert evidence["resolved_policy"]["id"] == "journal-nature" + assert evidence["resolved_policy"]["parameters"]["render_policy"] == "render-neutral" + assert evidence["resolved_policy"]["parameters"]["validation_target"] == "nature" + assert evidence["policy_projections"][0]["id"] == "journal-nature" + assert evidence["policy_context"] == { + "schema_version": context["schema_version"], + "source": context["source"], + "policy_set_sha256": context["policy_set_sha256"], + "render_policy": context["render_policy"], + "validation_target": context["validation_target"], + } + assert evidence["policy_context"]["policy_set_sha256"] == context["policy_set_sha256"] + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("policy_set_sha256", None, "policy_context digest is malformed"), + ("policy_set_sha256", "forged", "policy_context digest is malformed"), + ("schema_version", "figops-render-policy-context/0", "policy_context schema is malformed"), + ("render_policy", None, "policy_context render_policy is malformed"), + ], +) +def test_build_render_evidence_rejects_malformed_policy_context( + tmp_path: Path, + field: str, + value: object, + message: str, +) -> None: + job_root = tmp_path / "job" + primary = job_root / "results" / "figure.png" + primary.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "navy").save(primary, format="PNG", dpi=(300, 300)) + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("malformed-policy-context", previews, _sha256(primary)) + context = resolve_render_policy_context({"validation_target": "nature"}, compatibility=False) + if value is None: + context.pop(field) + else: + context[field] = value + + with pytest.raises(ValueError, match=message): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-neutral-render", + producer_version="1", + policy_context=context, + ) + + +def test_legacy_render_policy_argument_conflicts_keep_public_errors(tmp_path: Path) -> None: + job_root = tmp_path / "job" + primary = _pdf(job_root / "results" / "figure.pdf") + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("legacy-conflicts", previews, _sha256(primary)) + legacy = { + "id": "legacy-policy", + "version": "1", + "source": "legacy-test", + "parameters": {}, + } + + with pytest.raises( + ValueError, + match="validation_target cannot be combined with the legacy resolved_policy argument", + ): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-render", + producer_version="1", + resolved_policy=legacy, + validation_target="nature", + ) + with pytest.raises(ValueError, match="render_policy conflicts with the legacy resolved_policy argument"): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-render", + producer_version="1", + resolved_policy=legacy, + render_policy=resolve_render_policy_selection("neutral"), + ) + + def test_policy_projection_binds_artifact_rule_and_measurement_versions(tmp_path: Path) -> None: job_root = tmp_path / "job" primary = job_root / "results" / "figure.png" diff --git a/tests/test_render_project_policy_integration.py b/tests/test_render_project_policy_integration.py index b1a2f0f..7a05c95 100644 --- a/tests/test_render_project_policy_integration.py +++ b/tests/test_render_project_policy_integration.py @@ -16,6 +16,7 @@ def _write_project( geometry_mode: str = "direct", validation_target: str | None = None, declare_claim: bool = True, + workflow_intent: str | None = None, ) -> Path: project = root / "project" (project / "hub_scripts").mkdir(parents=True) @@ -64,9 +65,11 @@ def _write_project( inventory_line = " claim_inventory: results/evidence/Fig1.claims.json\n" if claim_inventory else "" claim_line = " claim: Fixture render completes.\n" if declare_claim else "" validation_line = f" validation_target: {validation_target}\n" if validation_target else "" + workflow_block = f"workflow:\n intent: {workflow_intent}\n" if workflow_intent else "" (project / "project_config.yaml").write_text( "project:\n" " name: Project policy integration\n" + f"{workflow_block}" "visual_style:\n" " target_format: nature\n" " profile: baseline\n" @@ -117,6 +120,7 @@ def _render( dpi: int = 300, geometry_mode: str = "direct", declare_claim: bool = True, + workflow_intent: str | None = None, ): research_root = tmp_path / "research" project = _write_project( @@ -126,6 +130,7 @@ def _render( geometry_mode=geometry_mode, validation_target=validation_target, declare_claim=declare_claim, + workflow_intent=workflow_intent, ) runtime_root = tmp_path / "runtime" server = FigOpsMCPServer( @@ -190,6 +195,66 @@ def test_publication_projection_is_persisted_and_unverified_claims_block_promoti assert manifest["manual_review_needed"] is True +def test_project_render_manifest_binds_canonical_policy_context_digest(tmp_path: Path) -> None: + response, manifest = _render( + tmp_path, + job_id="project-policy-context", + claim_inventory=False, + validation_target="nature", + ) + + assert response["status"] == "warning", (response, manifest) + assert len(manifest["policy_context"]["policy_set_sha256"]) == 64 + assert manifest["policy_context"]["render_policy"]["id"] == "render-neutral" + assert manifest["policy_context"]["validation_target"] == "nature" + assert manifest["evidence"]["resolved_policy"]["parameters"]["validation_target"] == "nature" + + +def test_policy_context_contradiction_fails_closed_without_promotion( + tmp_path: Path, + monkeypatch: Any, +) -> None: + research_root = tmp_path / "research" + project = _write_project(research_root, claim_inventory=False, validation_target="nature") + runtime_root = tmp_path / "runtime" + server = FigOpsMCPServer( + research_root=research_root, + runtime_root=runtime_root, + write_tools_enabled=True, + ) + real_build_render_evidence = build_render_evidence + + def contradict_policy_context(*args: Any, **kwargs: Any) -> dict[str, Any]: + kwargs["validation_target"] = "acs" + return real_build_render_evidence(*args, **kwargs) + + def fail_promotion(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("contradicted policy context must not reach durable promotion") + + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.build_render_evidence", + contradict_policy_context, + ) + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.promote_eligible_project_result", + fail_promotion, + ) + + response = server.call_tool( + "figops.render_project_script", + { + "project_path": str(project), + "figure_id": "Fig1", + "job_id": "project-policy-context-contradiction", + "validation_target": "nature", + }, + )["structuredContent"] + + assert response["status"] == "error" + assert response["failure_stage"] == "PLOT" + assert any("validation_target conflicts with policy_context" in line for line in response["errors"]) + + def test_publication_missing_projection_blocks_promotion_with_verified_claims( tmp_path: Path, monkeypatch: Any, @@ -208,6 +273,7 @@ def test_publication_missing_projection_blocks_promotion_with_verified_claims( write_tools_enabled=True, ) def without_projection(*args: Any, **kwargs: Any) -> dict[str, Any]: + kwargs["policy_context"] = None kwargs["validation_target"] = None return build_render_evidence(*args, **kwargs) @@ -261,6 +327,39 @@ def test_passing_measured_journal_minima_make_verified_claims_promotion_eligible assert len(list((project / "results" / "evidence").glob("*.receipt.json"))) == 1 +def test_exploration_workflow_blocks_promotion_and_does_not_invoke_durable_promotion( + tmp_path: Path, + monkeypatch: Any, +) -> None: + def fail_promotion(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("exploration workflow must not invoke durable promotion") + + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.promote_eligible_project_result", + fail_promotion, + ) + response, manifest = _render( + tmp_path, + job_id="project-exploration-non-promotable", + claim_inventory=True, + validation_target="nature", + dpi=600, + geometry_mode="compliant", + declare_claim=False, + workflow_intent="exploration", + ) + + assert response["status"] == "warning", (response, manifest) + assert manifest["workflow_intent"]["intent"] == "exploration" + assert manifest["workflow_intent"]["execution_allowed"] is False + assert manifest["workflow_intent"]["fail_closed"] is True + assert manifest["claim_inventory"]["promotion_eligible"] is True + assert manifest["promotion_eligible"] is False + project = tmp_path / "research" / "project" + assert (project / "results" / "figures" / "Fig1.png").exists() is False + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + def test_direct_tiny_font_and_line_without_geometry_sidecar_blocks_promotion( tmp_path: Path, ) -> None: diff --git a/tests/test_workflow_intent.py b/tests/test_workflow_intent.py new file mode 100644 index 0000000..7f69d86 --- /dev/null +++ b/tests/test_workflow_intent.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import pytest + +from hub_core.workflow_intent import ( + DIRECT_CSV_TOOLS, + INTENT_EXECUTION, + INTENT_EXPLORATION, + INTENT_PROMOTION, + INTENT_REVIEW, + MCP_EXECUTION_TOOLS, + ORCHESTRATOR_EXECUTION_STEPS, + READ_ONLY_TOOLS, + READINESS_TOOLS, + SOURCE_DIRECT_CSV, + SOURCE_LEGACY, + SOURCE_MCP, + SOURCE_ORCHESTRATOR, + SOURCE_READ_ONLY, + SOURCE_READINESS, + WorkflowIntent, + WorkflowIntentError, + infer_workflow_intent, + parse_workflow_intent, + parse_workflow_source, +) + + +def test_strict_parser_normalizes_known_intent_and_source_only() -> None: + assert parse_workflow_intent(" Execution ") == INTENT_EXECUTION + assert parse_workflow_intent("review") == INTENT_REVIEW + assert parse_workflow_source("read-only") == SOURCE_READ_ONLY + + with pytest.raises(WorkflowIntentError, match="unknown workflow intent"): + parse_workflow_intent("run") + with pytest.raises(WorkflowIntentError, match="unknown workflow source"): + parse_workflow_source("notebook") + + +@pytest.mark.parametrize("step", sorted(ORCHESTRATOR_EXECUTION_STEPS)) +def test_orchestrator_active_execution_steps_infer_execution(step: str) -> None: + intent = infer_workflow_intent(active=True, step=step) + + assert intent.intent == INTENT_EXECUTION + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is True + assert intent.read_only is False + + +def test_inactive_or_unknown_orchestrator_step_fails_closed() -> None: + inactive = infer_workflow_intent(active=False, step="plot") + unknown = infer_workflow_intent(active=True, step="publish") + + for intent in (inactive, unknown): + assert intent.intent == INTENT_REVIEW + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is False + assert intent.fail_closed is True + assert intent.issues + + +@pytest.mark.parametrize("tool_name", sorted(MCP_EXECUTION_TOOLS)) +def test_mcp_project_render_surfaces_infer_execution(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_EXECUTION + assert intent.source == SOURCE_MCP + assert intent.execution_allowed is True + assert intent.promotable is False + + +@pytest.mark.parametrize("tool_name", sorted(DIRECT_CSV_TOOLS)) +def test_direct_csv_surfaces_infer_exploration(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_EXPLORATION + assert intent.source == SOURCE_DIRECT_CSV + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.read_only is False + + +@pytest.mark.parametrize("tool_name", sorted(READ_ONLY_TOOLS | READINESS_TOOLS)) +def test_read_only_and_readiness_surfaces_have_no_execution_intent(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_REVIEW + assert intent.source in {SOURCE_READ_ONLY, SOURCE_READINESS} + assert intent.execution_allowed is False + assert intent.read_only is True + + +def test_explicit_intent_does_not_override_known_surface_defaults() -> None: + csv_intent = infer_workflow_intent( + active=True, + tool_name="figops.render_csv_graph", + requested_intent=INTENT_EXECUTION, + ) + orchestrator_intent = infer_workflow_intent( + active=True, + step="all", + requested_intent=INTENT_EXPLORATION, + ) + readiness_intent = infer_workflow_intent( + active=True, + source="readiness", + requested_intent=INTENT_EXECUTION, + ) + + assert csv_intent.intent == INTENT_EXPLORATION + assert csv_intent.source == SOURCE_DIRECT_CSV + assert csv_intent.fail_closed is True + assert orchestrator_intent.intent == INTENT_EXPLORATION + assert orchestrator_intent.source == SOURCE_ORCHESTRATOR + assert orchestrator_intent.fail_closed is True + assert readiness_intent.intent == INTENT_REVIEW + assert readiness_intent.source == SOURCE_READINESS + assert readiness_intent.fail_closed is True + + +@pytest.mark.parametrize("requested_intent", (INTENT_REVIEW, INTENT_PROMOTION)) +@pytest.mark.parametrize("tool_name", sorted(MCP_EXECUTION_TOOLS)) +def test_active_project_render_non_execution_intent_conflicts_fail_closed( + tool_name: str, + requested_intent: str, +) -> None: + intent = infer_workflow_intent( + active=True, + tool_name=tool_name, + requested_intent=requested_intent, + ) + + assert intent.intent == requested_intent + assert intent.source == SOURCE_MCP + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +@pytest.mark.parametrize("requested_intent", (INTENT_REVIEW, INTENT_PROMOTION)) +def test_active_orchestrator_render_non_execution_intent_conflicts_fail_closed(requested_intent: str) -> None: + intent = infer_workflow_intent(active=True, step="plot", requested_intent=requested_intent) + + assert intent.intent == requested_intent + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +@pytest.mark.parametrize( + ("tool_name", "source", "requested_intent", "expected_intent", "expected_source"), + ( + ("figops.render_project_figure", SOURCE_READ_ONLY, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_MCP), + ("figops.render_project_figure", SOURCE_READINESS, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_MCP), + ("figops.render_csv_graph", None, INTENT_PROMOTION, INTENT_PROMOTION, SOURCE_DIRECT_CSV), + ("figops.evaluate_publication_readiness", None, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_READINESS), + ), +) +def test_source_surface_and_intent_conflict_matrix_is_deterministic( + tool_name: str, + source: str | None, + requested_intent: str, + expected_intent: str, + expected_source: str, +) -> None: + intent = infer_workflow_intent( + active=True, + tool_name=tool_name, + source=source, + requested_intent=requested_intent, + ) + + assert intent.intent == expected_intent + assert intent.source == expected_source + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +def test_legacy_status_overrides_active_execution_surfaces() -> None: + for kwargs in ( + {"active": True, "step": "all"}, + {"active": True, "tool_name": "figops.render_project_figure"}, + {"active": True, "tool_name": "graphhub.render_project_figure"}, + ): + intent = infer_workflow_intent(**kwargs, project_status="legacy") + + assert intent.intent == INTENT_REVIEW + assert intent.source == SOURCE_LEGACY + assert intent.legacy is True + assert intent.execution_allowed is False + assert intent.promotable is False + assert intent.read_only is True + + +def test_unknown_intent_or_source_is_inspectable_and_fails_closed_for_execution() -> None: + intent = infer_workflow_intent(active=True, requested_intent="execution", source="notebook") + unknown_intent = infer_workflow_intent(active=True, requested_intent="run", source="mcp") + + assert intent.intent == INTENT_EXECUTION + assert intent.source is None + assert intent.execution_allowed is False + assert intent.fail_closed is True + assert intent.provenance["requested_source"] == "notebook" + assert intent.issues == ("unknown workflow source: 'notebook'",) + + assert unknown_intent.intent is None + assert unknown_intent.source == SOURCE_MCP + assert unknown_intent.execution_allowed is False + assert unknown_intent.fail_closed is True + assert unknown_intent.provenance["requested_intent"] == "run" + + +def test_explicit_promotion_is_closed_and_digest_is_deterministic() -> None: + left = infer_workflow_intent(requested_intent=INTENT_PROMOTION) + right = WorkflowIntent( + intent=INTENT_PROMOTION, + source="explicit", + provenance={"requested_intent": INTENT_PROMOTION}, + ) + + assert left.intent == INTENT_PROMOTION + assert left.promotion_allowed is True + assert left.digest == left.digest + assert len(left.digest) == 64 + assert right.digest == right.digest From 297e69378cbfb3cf11f99ac6500dd44c4a24301c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 20:25:57 +0900 Subject: [PATCH 03/21] Make release gate assertions checkout-safe --- tests/test_release_discipline.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_release_discipline.py b/tests/test_release_discipline.py index a26e327..1bb2c46 100644 --- a/tests/test_release_discipline.py +++ b/tests/test_release_discipline.py @@ -11,18 +11,26 @@ def _assert_release_status_matches_post_tag_state(status: str, post_tag_blocker: str | None) -> None: - if post_tag_blocker is None: + status_is_blocked = "- Technical release gate: blocked" in status + status_is_green = "- Technical release gate: ok" in status + + assert status_is_blocked != status_is_green + if status_is_green: + assert post_tag_blocker is None assert "- Technical release gate: ok" in status assert "- Repository technically eligible for public release: yes" in status assert "- Repository release allowed: yes" in status assert "post_tag_metadata" not in status return - assert "Release metadata is stale" in post_tag_blocker assert "- Technical release gate: blocked" in status assert "- Repository technically eligible for public release: no" in status assert "- Repository release allowed: no" in status + assert "- Technical blockers:" in status + assert "- Technical blockers: 0" not in status assert POST_TAG_METADATA_ROW in status + if post_tag_blocker is not None: + assert "Release metadata is stale" in post_tag_blocker def test_package_version_is_semver_and_matches_latest_changelog_entry(): @@ -74,6 +82,7 @@ def test_release_status_assertion_fails_closed_for_stale_post_tag_metadata(): - Repository technically eligible for public release: no - Repository release allowed: no - Technical release gate: blocked +- Technical blockers: 1 {POST_TAG_METADATA_ROW} """ From eb3194a62808094862a11f7833236faf53db400a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 20:27:28 +0900 Subject: [PATCH 04/21] Fetch release tags in macOS CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c5a592..787b9fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 - name: Install locked uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: From f93904adc8c08adf8f285c74583e5ce90ae8c9b3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 00:29:06 +0900 Subject: [PATCH 05/21] Add read-only all-project structure audit --- AGENTS.md | 3 + docs/QA.md | 31 +- docs/ROADMAP.md | 15 +- docs/architecture.md | 40 +++ hub_core/structure_audit_report.py | 335 +++++++++++++++++++++ hub_core/structure_inventory.py | 39 ++- orchestrator.py | 82 +++++ tests/test_orchestrator_structure_audit.py | 126 ++++++++ tests/test_structure_audit_report.py | 112 +++++++ tests/test_structure_inventory.py | 79 +++++ 10 files changed, 858 insertions(+), 4 deletions(-) create mode 100644 hub_core/structure_audit_report.py create mode 100644 tests/test_orchestrator_structure_audit.py create mode 100644 tests/test_structure_audit_report.py diff --git a/AGENTS.md b/AGENTS.md index a546f65..d9d83f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,9 @@ python orchestrator.py --init --project "새_프로젝트_폴더" # List configured projects python orchestrator.py --list-projects +# Read-only all-project structure audit +python orchestrator.py --audit-structure + # Smoke tests python -m unittest tests.test_smoke diff --git a/docs/QA.md b/docs/QA.md index 817eab7..5aecb77 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -64,6 +64,31 @@ python orchestrator.py --docker --docker-build --project "12. ionoelastomer" --s - Docker 경로에서도 lock gate, provenance, plot 출력이 동일하게 통과해야 함. - uv/R 런타임 상태와 자격증명은 repo 안이 아니라 외부 runtime/cache 경로에 있어야 함. +### 1.1 전체 프로젝트 구조 진단 (Read-only) + +프로젝트 파일을 변경하거나 파이프라인을 실행하지 않고, 연구 루트에서 +발견되는 프로젝트의 선언 구조를 진단한다. + +```bash +python orchestrator.py --audit-structure +python orchestrator.py --audit-structure --audit-structure-format json --scan-depth 2 +``` + +- 기본 출력은 Markdown이며, `--audit-structure-format json`은 JSON을 + stdout으로 출력한다. 시도 provenance는 stderr로 남는다. +- `--scan-depth`는 discovery 깊이를 제한한다. +- 이 모드는 독립 모드다. `--project`, `--check-all`, `--list-projects`와 + 파이프라인/변경 옵션을 함께 주면 fail-fast 오류가 난다. +- 합격 조건: exit code = 0, 진단 보고서가 stdout에 존재하고, analysis/ + plot/diagram 실행이나 project 파일 변경이 없다. `--audit-structure-format` + 단독 사용은 오류여야 한다. + +이 출력은 구조 검토용 진단 보고서이며 runtime manifest, durable result, +또는 evidence receipt가 아니다. 따라서 `results/` 아래에 연구 산출물로 +복사하거나 promotion 대상으로 취급하지 않는다. 실행 로그·cache·snapshot· +상세 manifest는 외부 runtime root에, 의미 있는 결과와 receipt는 선언된 +project role root에 각각 보관한다. + --- ## 2) Regression & Integrity 기준안 @@ -292,5 +317,9 @@ explicit path, with `if-no-files-found: error`, so missing evidence fails closed - **허브 모듈 수정 시**: `hub_core/` 내부 로직 변경 시 반드시 2개 이상의 서로 다른 프로젝트(`ionoelastomer`, `Sulfur_polymer`)에 대해 테스트를 수행. - **Runtime 상태 분리**: 데이터 결과값, 회귀 baseline, 실행 로그, 자격증명은 repo 밖 runtime/cache 경로에 둔다. DVC/data registry는 현재 운영 표면에서 retired 상태다. +- **구조 진단 출력 분리**: `--audit-structure`의 stdout 보고서는 일회성 + diagnostic surface다. runtime 산출물이나 durable 연구 결과로 승격하지 + 않으며, 보존이 필요하면 별도 운영 기록으로 보관하고 프로젝트 `results/` + 역할 트리와 혼합하지 않는다. -**Last Update**: 2026-07-04 (journal visual evidence gate guidance) +**Last Update**: 2026-07-23 (all-project structure audit CLI and journal visual evidence guidance) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f7c408b..e14f83e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -118,6 +118,16 @@ Current release-candidate checkpoint: reference: [PR #224 owner authorization](https://github.com/Moonweave-Research/figops/pull/224#issuecomment-5016360221). Execute merge, tag, package publication, GitHub Release, and release promotion only after rechecking technical gates for the exact release commit. +- The CLI now has an independent all-project structure diagnostic: + `python orchestrator.py --audit-structure` (or + `python orchestrator.py --audit-structure --audit-structure-format json`). + It consumes the read-only structure + inventory/audit path for projects discovered under the research root, honors + `--scan-depth`, and emits the rendered report on stdout without running a + pipeline or changing project files. The diagnostic report is review output + only; it retains invalid/boundary-blocked project rows for review, keeps + `proposed_changes` empty, and is not a runtime manifest, durable result, or + evidence receipt. --- @@ -154,8 +164,9 @@ hub_core/ project_structure_contract.py # v1.1 role/DAG/alias resolution legacy_structure_resolver.py # legacy 1.0 in-memory compatibility view project_layout.py # shared scaffold/normalization inventory - structure_inventory.py / structure_audit.py / structure_plan.py - # read-only semantic discovery and reviewed plan + structure_inventory.py / structure_audit.py / structure_audit_report.py + # read-only semantic discovery and all-project report + structure_plan.py # deterministic reviewed copy plan structure_role_binding.py # approved destinations bound to declared roots structure_apply.py # write-gated copy-only apply transaction runtime_boundary.py # project/result/runtime disjointness diff --git a/docs/architecture.md b/docs/architecture.md index 86146e0..db20223 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,6 +11,7 @@ Dependencies point **downward only**. A layer may import from layers below it, never above. ``` +orchestrator.py # CLI pipeline coordinator and read-only audit mode figops_mcp_server.py # entrypoint (stdio); --smoke; thin | v @@ -40,6 +41,7 @@ hub_core/legacy_structure_resolver.py # schema-less 1.0 -> in-memory 1.1 view hub_core/project_layout.py # one scaffold/normalization layout inventory hub_core/structure_inventory.py # read-only semantic inventory hub_core/structure_audit.py # findings, graph, unresolved classification +hub_core/structure_audit_report.py # all-project diagnostic report assembly/rendering hub_core/structure_plan.py # deterministic reviewed copy plan hub_core/structure_role_binding.py # destination -> declared role-root binding hub_core/structure_stage_cleanup.py # ownership-safe private-stage/lease cleanup @@ -314,6 +316,44 @@ should use `FigOpsMCPServer(surface_profile="v2" | "compatibility")` or the `GRAPH_HUB_MCP_SURFACE_PROFILE` launcher environment setting. Profile-aware references can be rendered from the live registry without duplicating alias schemas. +## All-project structure audit (CLI) + +The CLI exposes an independent, read-only structure diagnostic for the whole +discovery root: + +```bash +python orchestrator.py --audit-structure +python orchestrator.py --audit-structure --audit-structure-format json --scan-depth 2 +``` + +`orchestrator.py` resolves the research root, records the attempt as +`selector_kind: audit_structure`, and delegates to +`hub_core.structure_audit_report.build_structure_audit_report(root_dir, +max_depth=...)`. It selects the module's deterministic Markdown or JSON +renderer for the requested format and writes the result to stdout (Markdown by +default). Attempt provenance remains on stderr. +`--audit-structure-format` is valid only with `--audit-structure`. + +This mode is deliberately independent from project selection and execution: +pipeline selectors and mutating/execution options (including `--project`, +`--check-all`, and `--list-projects`) are rejected rather than silently +combined. The audit walks the discovered projects up to `--scan-depth` and +uses the read-only inventory/audit modules; it does not run analysis, plotting, +diagram, or promotion steps and does not modify project files. + +The aggregate retains invalid-configuration and execution-boundary-blocked +projects as diagnostic rows instead of silently dropping them. Its report +schema is `figops.project-structure-audit-report.v1`; aggregate and per-project +`proposed_changes` are always empty on this surface. + +The emitted structure report is diagnostic output, not a runtime manifest, +durable result, or evidence receipt. It describes current structure findings +for review; it must not be treated as a promoted artifact or copied into a +project's `results/` tree as if it were research output. Runtime job state, +logs, caches, snapshots, and detailed manifests remain under the external +runtime root, while durable results and receipts remain under their declared +project role roots. + ## Why this shape - **Maintainability**: the MCP monolith has been replaced by smaller modules diff --git a/hub_core/structure_audit_report.py b/hub_core/structure_audit_report.py new file mode 100644 index 0000000..52f897b --- /dev/null +++ b/hub_core/structure_audit_report.py @@ -0,0 +1,335 @@ +"""Deterministic, read-only structure audits across discovered projects. + +This module is intentionally a reporting layer: it never creates, edits, or +deletes project files. Discovery metadata is retained even when a project is +invalid or cannot pass the execution-path boundary, so an all-project audit +does not hide the very entries that need attention. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Any, Mapping + +from .config_parser import load_config +from .execution_project_boundary import ExecutionProjectPathError, resolve_execution_project_path +from .project_discovery import discover_projects_with_status +from .structure_audit import audit_project_structure + +REPORT_SCHEMA_VERSION = "figops.project-structure-audit-report.v1" + + +def _error_text(exc: BaseException) -> str: + text = str(exc).strip() + return text or exc.__class__.__name__ + + +def _project_entry(project: Mapping[str, Any]) -> dict[str, Any]: + """Copy stable discovery fields while normalising list-valued diagnostics.""" + + raw_errors = project.get("errors") or [] + if isinstance(raw_errors, (str, bytes)): + raw_errors = [raw_errors] + return { + "project_id": str(project.get("project_id") or ""), + "name": str(project.get("name") or project.get("path") or ""), + "path": str(project.get("path") or ""), + "config": str(project.get("config") or ""), + "config_path": str(project.get("config_path") or ""), + "role": str(project.get("role") or ""), + "status": str(project.get("status") or ""), + "classification": str(project.get("classification") or ""), + "target_format": str(project.get("target_format") or ""), + "valid": bool(project.get("valid", False)), + "errors": [str(item) for item in raw_errors], + "audit_status": "pending", + "result_status": "pending", + "proposed_changes": [], + "audit": None, + } + + +def _append_project(projects: list[dict[str, Any]], entry: dict[str, Any]) -> None: + """Finalize compatibility fields before retaining one project row.""" + + entry["result_status"] = entry["audit_status"] + audit = entry.get("audit") + if isinstance(audit, Mapping): + # A report must remain diagnostic-only even if a future producer adds a + # plan field. Never leak mutation proposals through this surface. + entry["proposed_changes"] = [] + if isinstance(audit, dict): + audit["proposed_changes"] = [] + projects.append(entry) + + +def build_structure_audit_report( + root_dir: str | Path, + *, + max_depth: int = 4, + include_worktrees: bool = False, + include_ephemeral: bool = False, + include_quarantine: bool = False, +) -> dict[str, Any]: + """Audit every discovered project beneath *root_dir* in stable order. + + Discovery records are processed independently. A bad configuration is + reported as ``invalid``; a path rejected by the execution boundary is + reported as ``boundary_blocked``; config-less folder-role entries are kept + as ``skipped``. One failure therefore cannot make another project vanish + from the report. + """ + + root = Path(root_dir).expanduser().resolve() + depth = max(1, int(max_depth or 1)) + discovered = discover_projects_with_status( + root, + max_depth=depth, + include_worktrees=include_worktrees, + include_ephemeral=include_ephemeral, + include_quarantine=include_quarantine, + ) + # The discovery service normally sorts this already, but sorting here is a + # second deterministic boundary for callers supplying a custom service. + discovered = sorted( + (item for item in discovered if isinstance(item, Mapping)), + key=lambda item: ( + str(item.get("path") or "").casefold(), + str(item.get("project_id") or "").casefold(), + ), + ) + + projects: list[dict[str, Any]] = [] + for discovered_project in discovered: + entry = _project_entry(discovered_project) + relative_path = entry["path"] + try: + project_path = resolve_execution_project_path(root, relative_path) + except (ExecutionProjectPathError, OSError, RuntimeError, ValueError) as exc: + entry["audit_status"] = "boundary_blocked" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + continue + + if not entry["valid"]: + entry["audit_status"] = "invalid" + _append_project(projects, entry) + continue + + # Config-less folder-role entries are useful discovery evidence but do + # not contain enough information to construct a structure contract. + if not entry["config"]: + entry["audit_status"] = "skipped" + entry["errors"].append("project configuration was not discovered") + _append_project(projects, entry) + continue + + try: + loaded = load_config(project_path) + except Exception as exc: # keep one unreadable project from hiding others + entry["audit_status"] = "audit_error" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + continue + config = loaded[0] if isinstance(loaded, tuple) and loaded else None + if not isinstance(config, Mapping): + entry["audit_status"] = "invalid" + entry["errors"].append("project configuration could not be loaded") + _append_project(projects, entry) + continue + + try: + audit = audit_project_structure(project_path, config) + if not isinstance(audit, Mapping): + raise TypeError("structure audit returned a non-mapping result") + # Copy to detach the report from mutable producer dictionaries and + # keep this report strictly read-only. + audit_copy = dict(audit) + audit_copy["proposed_changes"] = [] + entry["audit"] = audit_copy + entry["audit_status"] = "audited" + except Exception as exc: # report the failure; never silently omit it + entry["audit_status"] = "audit_error" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + + status_counts = Counter(str(item["audit_status"]) for item in projects) + finding_count = 0 + unknown_count = 0 + finding_codes: Counter[str] = Counter() + for item in projects: + audit = item.get("audit") + if not isinstance(audit, Mapping): + continue + findings = audit.get("findings") + unknowns = audit.get("unknowns") + if isinstance(findings, list): + finding_count += len(findings) + for finding in findings: + if isinstance(finding, Mapping): + finding_codes[str(finding.get("code") or "unknown")] += 1 + if isinstance(unknowns, list): + unknown_count += len(unknowns) + + summary = { + "project_count": len(projects), + "discovered_count": len(projects), + "audited_count": status_counts.get("audited", 0), + "invalid_count": status_counts.get("invalid", 0), + "boundary_blocked_count": status_counts.get("boundary_blocked", 0), + "skipped_count": status_counts.get("skipped", 0), + "audit_error_count": status_counts.get("audit_error", 0), + "finding_count": finding_count, + "unknown_count": unknown_count, + "status_counts": dict(sorted(status_counts.items())), + "finding_counts": dict(sorted(finding_codes.items())), + } + return { + "schema_version": REPORT_SCHEMA_VERSION, + "root": str(root), + "max_depth": depth, + "projects": projects, + "proposed_changes": [], + "summary": summary, + } + + +def render_structure_audit_json(report: Mapping[str, Any]) -> str: + """Render a report as canonical, newline-terminated JSON.""" + + return json.dumps(dict(report), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def _md(value: Any) -> str: + return str(value if value is not None else "").replace("|", "\\|").replace("\n", " ") + + +def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: + """Render a compact deterministic Markdown report.""" + + summary = report.get("summary") if isinstance(report, Mapping) else {} + summary = summary if isinstance(summary, Mapping) else {} + projects = report.get("projects") if isinstance(report, Mapping) else [] + projects = projects if isinstance(projects, list) else [] + lines = [ + "# Project Structure Audit", + "", + f"Root: `{_md(report.get('root', ''))}`", + f"Max depth: `{_md(report.get('max_depth', ''))}`", + "", + "## Summary", + "", + "| Metric | Count |", + "| --- | ---: |", + ] + for key in ( + "project_count", + "audited_count", + "invalid_count", + "boundary_blocked_count", + "skipped_count", + "audit_error_count", + "finding_count", + "unknown_count", + ): + lines.append(f"| {_md(key.replace('_', ' '))} | {_md(summary.get(key, 0))} |") + lines.extend( + [ + "", + "## Projects", + "", + "| Path | Name | Role | Lifecycle | Audit status | Findings | Unknowns | Errors |", + "| --- | --- | --- | --- | ---: | ---: | ---: | --- |", + ] + ) + for item in projects: + if not isinstance(item, Mapping): + continue + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] + unknowns = audit.get("unknowns") if isinstance(audit.get("unknowns"), list) else [] + lines.append( + "| " + + " | ".join( + [ + _md(item.get("path", "")), + _md(item.get("name", "")), + _md(item.get("role", "")), + _md(item.get("status", "")), + _md(item.get("audit_status", "")), + str(len(findings)), + str(len(unknowns)), + _md("; ".join(str(error) for error in (item.get("errors") or []))), + ] + ) + + " |" + ) + lines.extend(["", "## Findings", ""]) + any_findings = False + for item in projects: + if not isinstance(item, Mapping): + continue + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] + if findings: + any_findings = True + lines.append(f"### `{_md(item.get('path', ''))}`") + for finding in findings: + if isinstance(finding, Mapping): + code = _md(finding.get("code", "finding")) + detail = ", ".join( + f"{_md(k)}={_md(v)}" for k, v in sorted(finding.items()) if k != "code" + ) + lines.append(f"- `{code}`" + (f": {detail}" if detail else "")) + else: + lines.append(f"- {_md(finding)}") + lines.append("") + if not any_findings: + lines.append("No structure findings.") + return "\n".join(lines).rstrip() + "\n" + + +def render_structure_audit_report(report: Mapping[str, Any], *, output_format: str = "markdown") -> str: + """Render *report* as ``markdown`` (default) or ``json``.""" + + selected = str(output_format or "markdown").strip().lower() + if selected == "json": + return render_structure_audit_json(report) + if selected in {"markdown", "md"}: + return render_structure_audit_markdown(report) + raise ValueError("output_format must be 'json' or 'markdown'") + + +# Compatibility names used by early callers of the all-project read-only +# surface. They intentionally return the same canonical report envelope. +def audit_discovered_projects( + root_dir: str | Path, + *, + max_depth: int = 4, + include_worktrees: bool = False, + include_ephemeral: bool = False, + include_quarantine: bool = False, +) -> dict[str, Any]: + return build_structure_audit_report( + root_dir, + max_depth=max_depth, + include_worktrees=include_worktrees, + include_ephemeral=include_ephemeral, + include_quarantine=include_quarantine, + ) + + +audit_all_projects = audit_discovered_projects + + +__all__ = [ + "REPORT_SCHEMA_VERSION", + "build_structure_audit_report", + "audit_discovered_projects", + "audit_all_projects", + "render_structure_audit_json", + "render_structure_audit_markdown", + "render_structure_audit_report", +] diff --git a/hub_core/structure_inventory.py b/hub_core/structure_inventory.py index c86a99f..fd351ea 100644 --- a/hub_core/structure_inventory.py +++ b/hub_core/structure_inventory.py @@ -18,6 +18,28 @@ {".csv", ".tsv", ".txt", ".parquet", ".json", ".xlsx", ".xls", ".h5", ".hdf5", ".feather"} ) _FIGURE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".svg", ".pdf", ".eps", ".tif", ".tiff"}) +_PATH_REFERENCE_KEYS = frozenset( + { + "asset", + "assets", + "file", + "files", + "input", + "inputs", + "lock", + "locks", + "manifest", + "manifests", + "output", + "outputs", + "path", + "paths", + "script", + "scripts", + "source", + "sources", + } +) def _relative_path(value: object) -> str | None: @@ -29,6 +51,21 @@ def _relative_path(value: object) -> str | None: return path.as_posix() +def _is_configured_path(trail: tuple[str, ...]) -> bool: + """Return whether the scalar sits under a path-bearing config key. + + Config metadata contains many dotted strings (schema versions, helper + module names, and prose descriptions) that look like paths to a generic + suffix check. Explicit path-bearing keys remain the authoritative signal, + including lock-file keys such as ``environment.python_lock``. + """ + + return any( + (key := part.lower()) in _PATH_REFERENCE_KEYS or key.endswith("_lock") + for part in trail + ) + + def _walk_references(value: object, trail: tuple[str, ...] = ()) -> Iterable[tuple[tuple[str, ...], str]]: if isinstance(value, Mapping): for key in sorted(value, key=str): @@ -38,7 +75,7 @@ def _walk_references(value: object, trail: tuple[str, ...] = ()) -> Iterable[tup yield from _walk_references(item, (*trail, str(index))) else: path = _relative_path(value) - if path is not None and ("/" in path or PurePosixPath(path).suffix): + if path is not None and _is_configured_path(trail): yield trail, path diff --git a/orchestrator.py b/orchestrator.py index 7ea6dbb..be88d7b 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -119,6 +119,8 @@ def _apply_cli_preset(config: dict, preset_name: str) -> None: def _selector_kind(args: argparse.Namespace) -> str: + if getattr(args, "audit_structure", False): + return "audit_structure" if args.check_all: return "check_all" if args.project: @@ -237,6 +239,20 @@ def main(): parser.add_argument( "--check-all", action="store_true", help="Run all discoverable projects and write a regression report" ) + parser.add_argument( + "--audit-structure", + action="store_true", + help=( + "Inspect the declared structure of all discoverable projects without executing pipelines " + "or modifying project files" + ), + ) + parser.add_argument( + "--audit-structure-format", + choices=["json", "markdown"], + default=None, + help="Output format for --audit-structure (default: markdown)", + ) parser.add_argument( "--reformat-journal", type=str, @@ -330,6 +346,7 @@ def main(): "--check-regression": args.check_regression, "--docker": args.docker or args.docker_build, "--reformat-journal": bool(args.reformat_journal), + "--audit-structure": args.audit_structure, } conflicts = [name for name, active in conflicting_modes.items() if active] if conflicts: @@ -355,6 +372,71 @@ def main(): sys.stdout.write("Error: --readiness-format requires --readiness-manifest.\n") return 1 + if args.audit_structure_format and not args.audit_structure: + sys.stdout.write("Error: --audit-structure-format requires --audit-structure.\n") + return 1 + + if args.audit_structure: + conflicting_modes = { + "--project": bool(args.project), + "--list-projects/--status": args.list_projects or args.status, + "--list-root-only": args.list_root_only, + "--init/--wizard": args.init or args.wizard, + "--check-all": args.check_all, + "--read-fingerprint": bool(args.read_fingerprint), + "--inject-fingerprint": args.inject_fingerprint, + "--sweep": args.sweep, + "--comparison": args.comparison, + "--freeze-golden": args.freeze_golden, + "--check-regression": args.check_regression, + "--docker": args.docker or args.docker_build, + "--reformat-journal": bool(args.reformat_journal), + "--readiness-manifest": bool(args.readiness_manifest), + "--readiness-format": bool(args.readiness_format), + "--external-raw-root": bool(args.external_raw_root), + "--preset": bool(args.preset), + "--force": args.force, + "--strict-lock": args.strict_lock, + "--step": args.step != "all", + "--regression-baseline": args.regression_baseline != "ignore", + } + conflicts = [name for name, active in conflicting_modes.items() if active] + if conflicts: + sys.stdout.write( + "Error: --audit-structure is an independent read-only mode and cannot be combined with " + + ", ".join(conflicts) + + ".\n" + ) + return 1 + + root_dir = inferred_root_dir + hub_path = inferred_hub_path + attempt_provenance = build_attempt_provenance( + surface="cli", + step="audit_structure", + selector_kind=_selector_kind(args), + hub_path=hub_path, + ) + _emit_attempt_provenance(attempt_provenance) + try: + from hub_core import structure_audit_report as structure_audit_api + + build_structure_audit_report = structure_audit_api.build_structure_audit_report + render_structure_audit_report = getattr(structure_audit_api, "render_structure_audit_report", None) + report = build_structure_audit_report(root_dir, max_depth=args.scan_depth) + output_format = args.audit_structure_format or "markdown" + if render_structure_audit_report is not None: + output = render_structure_audit_report(report, output_format=output_format) + elif output_format == "json": + output = structure_audit_api.render_structure_audit_json(report) + else: + output = structure_audit_api.render_structure_audit_markdown(report) + except (AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError) as exc: + sys.stdout.write(f"Error: unable to audit project structure: {exc}\n") + return 1 + sys.stdout.write(output) + return 0 + root_dir = inferred_root_dir hub_path = inferred_hub_path attempt_provenance = build_attempt_provenance( diff --git a/tests/test_orchestrator_structure_audit.py b/tests/test_orchestrator_structure_audit.py new file mode 100644 index 0000000..54ad973 --- /dev/null +++ b/tests/test_orchestrator_structure_audit.py @@ -0,0 +1,126 @@ +"""CLI coverage for the independent, read-only structure audit mode.""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import hub_core +import hub_core.structure_audit_report # noqa: F401 # ensure package attribute exists for the test double +import orchestrator + + +def _report_module(build_calls: list[tuple[object, int]], render_calls: list[str]) -> types.ModuleType: + module = types.ModuleType("hub_core.structure_audit_report") + + def build_structure_audit_report(root_dir: object, *, max_depth: int = 4) -> dict[str, object]: + build_calls.append((root_dir, max_depth)) + return {"schema_version": "test", "projects": []} + + def render_structure_audit_report(report: dict[str, object], *, output_format: str = "markdown") -> str: + del report + render_calls.append(output_format) + if output_format == "json": + return json.dumps({"format": output_format}) + return "# Structure audit\n" + + module.build_structure_audit_report = build_structure_audit_report + module.render_structure_audit_report = render_structure_audit_report + return module + + +def test_audit_structure_json_is_independent_and_emits_stdout_only_report(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + stdout = io.StringIO() + stderr = io.StringIO() + + with ( + patch.object( + sys, + "argv", + ["orchestrator.py", "--audit-structure", "--audit-structure-format", "json", "--scan-depth", "2"], + ), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + patch("orchestrator.run_analysis") as run_analysis, + patch("orchestrator.run_plots") as run_plots, + patch("orchestrator.run_check_all") as run_check_all, + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + result = orchestrator.main() + + assert result == 0 + assert json.loads(stdout.getvalue()) == {"format": "json"} + assert build_calls == [(str(tmp_path), 2)] + assert render_calls == ["json"] + run_analysis.assert_not_called() + run_plots.assert_not_called() + run_check_all.assert_not_called() + + +def test_audit_structure_defaults_to_markdown_and_does_not_touch_project_files(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + project_file = tmp_path / "project_config.yaml" + project_file.write_text("project:\n name: untouched\n", encoding="utf-8") + original = project_file.read_bytes() + + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure"]), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + ): + result = orchestrator.main() + + assert result == 0 + assert render_calls == ["markdown"] + assert project_file.read_bytes() == original + + +def test_audit_structure_rejects_execution_and_selector_modes(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + + for extra in (("--project", "module"), ("--check-all",), ("--list-projects",)): + stdout = io.StringIO() + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure", *extra]), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + contextlib.redirect_stdout(stdout), + ): + result = orchestrator.main() + + assert result == 1 + assert "independent read-only mode" in stdout.getvalue() + + assert build_calls == [] + assert render_calls == [] + + +def test_audit_structure_format_requires_audit_mode() -> None: + stdout = io.StringIO() + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure-format", "json"]), + contextlib.redirect_stdout(stdout), + ): + result = orchestrator.main() + + assert result == 1 + assert "requires --audit-structure" in stdout.getvalue() diff --git a/tests/test_structure_audit_report.py b/tests/test_structure_audit_report.py new file mode 100644 index 0000000..9e65db2 --- /dev/null +++ b/tests/test_structure_audit_report.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import hub_core.structure_audit_report as report_module + + +def _discovered(*items): + defaults = { + "project_id": "id", + "name": "Project", + "path": "project", + "config": "project_config.yaml", + "role": "module", + "status": "active", + "classification": "official", + "target_format": "nature", + "valid": True, + "errors": [], + } + return [{**defaults, **item} for item in items] + + +def test_build_report_audits_in_stable_path_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + projects = _discovered( + {"project_id": "b", "name": "B", "path": "b"}, + {"project_id": "a", "name": "A", "path": "a"}, + ) + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: projects) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: ({"project": {"name": path.name}}, "cfg", "hash")) + monkeypatch.setattr( + report_module, + "audit_project_structure", + lambda path, config: {"roles": {}, "graph": {}, "findings": [{"code": "x"}], "unknowns": []}, + ) + report = report_module.build_structure_audit_report(tmp_path) + assert [item["path"] for item in report["projects"]] == ["a", "b"] + assert report["summary"]["audited_count"] == 2 + assert report["summary"]["finding_count"] == 2 + + +def test_invalid_and_boundary_blocked_entries_are_retained(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + projects = _discovered( + {"project_id": "invalid", "path": "invalid", "valid": False, "errors": ["bad yaml"]}, + {"project_id": "alias", "path": "alias"}, + ) + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: projects) + + def resolve(root, path): + if path == "alias": + raise report_module.ExecutionProjectPathError("outside root") + return root / path + + monkeypatch.setattr(report_module, "resolve_execution_project_path", resolve) + report = report_module.build_structure_audit_report(tmp_path) + statuses = {item["project_id"]: item["audit_status"] for item in report["projects"]} + assert statuses == {"alias": "boundary_blocked", "invalid": "invalid"} + assert report["summary"]["boundary_blocked_count"] == 1 + assert report["summary"]["invalid_count"] == 1 + + +def test_json_renderer_is_canonical_and_markdown_contains_summary(): + report = {"summary": {"project_count": 0}, "projects": [], "root": "/tmp/root", "max_depth": 4} + encoded = report_module.render_structure_audit_report(report, output_format="json") + assert json.loads(encoded) == report + assert encoded.endswith("\n") + markdown = report_module.render_structure_audit_report(report) + assert "# Project Structure Audit" in markdown + assert "project count" in markdown + + +def test_report_uses_documented_schema_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: []) + report = report_module.build_structure_audit_report(tmp_path) + assert report["schema_version"] == "figops.project-structure-audit-report.v1" + + +def test_renderer_rejects_unknown_format(): + with pytest.raises(ValueError): + report_module.render_structure_audit_report({}, output_format="xml") + + +def test_loader_exception_is_retained_as_audit_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "project").mkdir() + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: _discovered({})) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: (_ for _ in ()).throw(OSError("unreadable"))) + report = report_module.build_structure_audit_report(tmp_path) + assert report["projects"][0]["audit_status"] == "audit_error" + assert "unreadable" in report["projects"][0]["errors"] + + +def test_audit_proposals_are_stripped_from_read_only_report(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "project").mkdir() + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: _discovered({})) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: ({"project": {}}, None, None)) + monkeypatch.setattr( + report_module, + "audit_project_structure", + lambda path, config: {"findings": [], "unknowns": [], "proposed_changes": [{"copy": "x"}]}, + ) + report = report_module.build_structure_audit_report(tmp_path) + assert report["projects"][0]["proposed_changes"] == [] + assert report["projects"][0]["audit"]["proposed_changes"] == [] + assert report["proposed_changes"] == [] diff --git a/tests/test_structure_inventory.py b/tests/test_structure_inventory.py index 9b88475..ac26c32 100644 --- a/tests/test_structure_inventory.py +++ b/tests/test_structure_inventory.py @@ -60,3 +60,82 @@ def test_audit_is_diagnostic_only_and_flags_raw_output(tmp_path: Path) -> None: assert audit["proposed_changes"] == [] assert any(item["code"] == "raw_output" for item in audit["findings"]) + + +def test_configured_reference_precedes_extension_and_name_heuristics(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = {"analysis": [{"script": "legacy/plot.py"}]} + script = tmp_path / "legacy" / "plot.py" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + inventory = build_structure_inventory(tmp_path, config) + + unknown = next(item for item in inventory["unknowns"] if item["path"] == "legacy/plot.py") + assert unknown["candidate"] == { + "candidate_role": "analysis_scripts", + "confidence": 1.0, + "reason": "configured relationship declares the semantic role", + } + + +def test_conflicting_configured_relationships_remain_unknown_for_review(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = {"analysis": [{"script": "legacy/plot.py"}]} + config["figures"] = [{"script": "legacy/plot.py"}] + script = tmp_path / "legacy" / "plot.py" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + audit = audit_project_structure(tmp_path, config) + + unknown = next(item for item in audit["unknowns"] if item["path"] == "legacy/plot.py") + assert unknown["candidate"] == { + "candidate_role": "unknown", + "confidence": 1.0, + "reason": "ambiguous configured relationships: analysis_scripts, figure_scripts", + } + + +def test_inventory_ignores_metadata_scalars_and_dotted_module_names(tmp_path: Path) -> None: + config = _config() + config["schema_version"] = "1.1" + config["project"] = {"description": "Public-safe fixture for materials/polymer analysis."} + config["pipeline"] = { + "analysis": [ + { + "domain_helper": "materials_polymer.signal_smooth_baseline", + "inputs": ["raw/input.csv"], + "outputs": ["results/data/intermediate/clean.csv"], + } + ] + } + + inventory = build_structure_inventory(tmp_path, config) + ignored = { + "1.1", + "Public-safe fixture for materials/polymer analysis.", + "materials_polymer.signal_smooth_baseline", + } + graph_ids = {node["id"] for node in inventory["graph"]["nodes"]} + + assert graph_ids.isdisjoint(ignored) + assert all(item.get("path") not in ignored for item in inventory["findings"]) + + +def test_inventory_walks_explicit_file_references_without_suffixes(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = { + "analysis": [ + { + "script": "analysis_runner", + "inputs": ["input_dataset"], + "outputs": ["derived_result"], + } + ] + } + + inventory = build_structure_inventory(tmp_path, config) + graph_ids = {node["id"] for node in inventory["graph"]["nodes"]} + + assert {"analysis_runner", "input_dataset", "derived_result"} <= graph_ids From 1639fd2ed063f504b9331c535751b2183786711e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 08:21:50 +0900 Subject: [PATCH 06/21] Harden structure review boundaries --- docs/ROADMAP.md | 13 ++ docs/architecture.md | 18 +++ docs/project-structure-contract.md | 71 ++++++++ ...roject-structure-runtime-integrity-plan.md | 18 +++ hub_core/structure_audit_report.py | 144 ++++++++++++++++- ...t_phase4_structure_contract_regressions.py | 153 ++++++++++++++++++ tests/test_structure_audit_report.py | 86 ++++++++++ 7 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 tests/test_phase4_structure_contract_regressions.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e14f83e..6dd56c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -128,6 +128,19 @@ Current release-candidate checkpoint: only; it retains invalid/boundary-blocked project rows for review, keeps `proposed_changes` empty, and is not a runtime manifest, durable result, or evidence receipt. +- Structure migration follows the finding-to-plan selection matrix in + `docs/project-structure-contract.md`: `invalid`, `boundary_blocked`, + `skipped`, `audit_error`, and ambiguous/heuristic unknowns remain report-only + (unknowns and proposed mappings are candidate-only). Only explicit reviewed + `approved_mappings` and typed config edits form a copy-only plan. A dry-run + returns the deterministic `plan_digest` and bound + `FIGOPS-APPLY-` token; apply requires the identical reviewed + inputs and token. The token proves plan integrity and exact replay, not + independent human identity or attestation; the current workflow does not + close self-approval. A host-issued `approval_receipt` or immutable + reviewed-plan authority is deferred to Phase 5. Audit/plan control evidence + never becomes a runtime manifest or durable result, and runtime remains + external to the project. --- diff --git a/docs/architecture.md b/docs/architecture.md index db20223..7d2f614 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -346,6 +346,21 @@ projects as diagnostic rows instead of silently dropping them. Its report schema is `figops.project-structure-audit-report.v1`; aggregate and per-project `proposed_changes` are always empty on this surface. +Selection follows the canonical matrix in +[`docs/project-structure-contract.md`](project-structure-contract.md): +`invalid`, `boundary_blocked`, `skipped`, and `audit_error` rows are report-only; +ambiguous/heuristic `unknowns` and `proposed_mappings` are candidate-only; and +only explicit, reviewer-supplied `approved_mappings` (plus typed config edits) +can form a copy-only plan. A reviewed dry-run returns a deterministic +`plan_digest` and its `FIGOPS-APPLY-` confirmation token. Apply +requires the identical reviewed inputs and token, and fails closed on stale +identity/configuration, collisions, unresolved dependencies, or token mismatch. +The token proves integrity and exact replay of that plan; it does not prove an +independent human identity, reviewer authority, or attestation, and the current +workflow does not close self-approval. A host-issued `approval_receipt` (or +equivalent immutable reviewed-plan authority) bound to reviewer identity and +the plan digest is a Phase 5 gap, not a current capability. + The emitted structure report is diagnostic output, not a runtime manifest, durable result, or evidence receipt. It describes current structure findings for review; it must not be treated as a promoted artifact or copied into a @@ -353,6 +368,9 @@ project's `results/` tree as if it were research output. Runtime job state, logs, caches, snapshots, and detailed manifests remain under the external runtime root, while durable results and receipts remain under their declared project role roots. +Plans, digests, and confirmation tokens are likewise control-plane evidence; +they do not move runtime state into `results/` or turn a diagnostic finding into +a durable research result. ## Why this shape diff --git a/docs/project-structure-contract.md b/docs/project-structure-contract.md index 7deca9e..f11bed9 100644 --- a/docs/project-structure-contract.md +++ b/docs/project-structure-contract.md @@ -78,6 +78,77 @@ fails closed when the native guarantee is unavailable. Failure rollback removes only newly created files whose hashes still match the reviewed plan; FigOps never deletes or moves raw inputs. +### Finding-to-plan selection matrix + +The audit report is a selection aid, not an approval list. The following matrix +is normative for the transition from a read-only finding to a reviewed copy +plan: + +| Report item | Surface/status | Selection class | Plan/apply effect | +| --- | --- | --- | --- | +| Invalid project/configuration | `audit_status: invalid` | Report-only | Retain the row and errors; do not inspect, propose, or apply mappings until the project is repaired and audited again. | +| Execution-path rejection | `audit_status: boundary_blocked` | Report-only | Retain the row and boundary error; no project files may be selected from this row. | +| Config-less discovery entry | `audit_status: skipped` | Report-only | Retain the discovery evidence; provide a valid project configuration before any plan can be built. | +| Loader or audit exception | `audit_status: audit_error` | Report-only | Preserve the error and rerun after it is resolved; an exception never becomes a mapping candidate. | +| Audited structural finding | `audit.findings[]` (for example `collision`, `stale_reference`, or `provenance_incomplete`) | Report-only diagnostic | Findings inform review and may block a safe plan, but are never copied into `approved_mappings`. | +| Semantic role proposal | `audit.unknowns[]` with one candidate, or `proposed_mappings[]` | Candidate-only | A candidate role, confidence, or destination is a suggestion only; a reviewer must choose the explicit source, destination, and role. | +| Ambiguous/heuristic unknown | An `unknowns[]` candidate with conflicting or heuristic evidence | Report-only candidate | Keep the item unresolved. It may be discussed during review, but it cannot enter a plan until the reviewer supplies an explicit mapping. | +| Unresolved proposal/dependency | `unresolved_proposals[]` or `hardcoded_unresolved_references[]` | Report-only blocker | Keep it unresolved; apply is refused while it can affect a copied artifact. FigOps never guesses or rewrites arbitrary source text. | +| Reviewed mapping | `approved_mappings[]` (with any typed `config_diff`) | Explicit plan input | Only this reviewer-supplied list is eligible to enter a copy-only plan. Every source, destination, and semantic role is validated and bound to declared roots. | +| Reviewed dry-run plan | `plan_digest` and `confirmation_token` | Review checkpoint | The canonical plan digest is deterministic and the returned token binds the exact plan; dry-run writes nothing. | +| Exact reviewed apply | `dry_run: false`, `move_policy: copy` | Apply confirmation | Re-submit the identical reviewed inputs and token. Stale hashes/config, changed inputs, collisions, unresolved dependencies, or token mismatch fail closed. | + +In particular, an ambiguous/heuristic `unknown` remains report-only even when a +candidate role is displayed, while a high-confidence `proposed_mapping` remains +candidate-only. Neither is an implicit approval. +`approved_mappings` must be authored from the reviewed evidence (or an +intentional, documented mapping) and must not be synthesized by replaying the +audit output. + +### Approval-token workflow + +1. Run `figops.describe` with `kind: project_structure` or the CLI + `--audit-structure` mode. Treat `invalid`, `boundary_blocked`, `skipped`, + and `audit_error` rows as report-only. Treat `findings`, ambiguous/heuristic + `unknowns`, `proposed_mappings`, and unresolved proposals as evidence for + review, not as approvals. +2. Request `figops.normalize_project_structure` with `dry_run: true` and + `move_policy: adopt` to inspect candidate mappings. Select only the entries + the reviewer accepts, then submit those as explicit `approved_mappings` (and + any typed compare-and-swap `config_diff`) with `move_policy: copy` and + `dry_run: true`. +3. Record the returned `plan_digest` and `confirmation_token`. The digest is a + SHA-256 of the canonical plan payload (sorted semantic entries and verified + source/config identities, excluding the self-referential digest); the token + is `FIGOPS-APPLY-`. This proves integrity and exact replay of + the reviewed plan, not the independent identity, role, authorization, or + attestation of the person or process that supplied it. It is a review + checkpoint, not a write. +4. Apply only by resubmitting the identical project path, mappings, config edits, + unresolved-reference list, and `plan_digest`-bound token with + `move_policy: copy` and `dry_run: false`. The apply path revalidates source + identity, configuration, containment, collisions, and the token before any + copy, then emits the copy receipt and validation result. +5. Verify the receipt and validation. Original inputs remain byte-identical; + cleanup is separate and user-authorized. A plan, digest, or token is control + evidence, not a research result, runtime manifest, or evidence receipt. + +### Phase 4 boundary and Phase 5 gap + +Phase 4 keeps the mapping policy explicit: only reviewer-supplied +`approved_mappings` and typed config edits may enter the copy-only plan. The +current `FIGOPS-APPLY-` token binds the exact canonical payload and +the verified source/config identities, but it does not establish independent +human identity, reviewer authority, or an attestation. The current workflow +therefore does not close self-approval; reviewer provenance remains an +out-of-band policy/process requirement rather than a machine-enforced claim. + +The next Phase 5 gap is a host-issued `approval_receipt` (or equivalent +immutable reviewed-plan authority) bound to the plan digest and reviewed +inputs, with verifiable reviewer identity/role, authorization, and attestation +semantics. Until that authority exists and is consumed by apply, no plan token +or copy receipt may be described as independent approval evidence. + ## Runtime and durable results Launcher-owned runtime state is external to the project and disposable. Jobs, diff --git a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md index 91190bb..e04f86f 100644 --- a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md +++ b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md @@ -319,6 +319,24 @@ Organization is a reviewable transaction, not an eager formatter. 8. **Report:** retain rollback manifest. Original cleanup is always a separate, user-authorized operation outside the organizer. +The normative finding-to-plan selection matrix and approval-token syntax live in +[`docs/project-structure-contract.md`](../project-structure-contract.md). In +summary, invalid, boundary-blocked, skipped, and audit-error projects are +report-only; ambiguous/heuristic unknowns and proposed mappings are +candidate-only; and only explicit reviewer-supplied `approved_mappings` plus +typed config edits enter a copy-only plan. A reviewed dry-run fixes a +deterministic `plan_digest` and `FIGOPS-APPLY-` token. Apply must +repeat the identical reviewed inputs with that token and remains blocked by +stale identities, collisions, unresolved dependencies, or token mismatch. +The token proves integrity and exact replay of the canonical plan, not +independent reviewer identity, authority, or attestation; the current workflow +does not close self-approval. A host-issued `approval_receipt` or equivalent +immutable reviewed-plan authority, bound to reviewer identity/role and the plan +digest, remains a Phase 5 gap. +Audit reports, plans, digests, and tokens are control evidence, not runtime +manifests, durable results, or evidence receipts; runtime remains externally +rooted and disposable. + Low-confidence, multi-role, content-sensitive, unreferenced, and collision cases stay unresolved. Extension-only classification may not cross a role boundary. Any hard-coded script/import/config dependency that cannot be represented as a diff --git a/hub_core/structure_audit_report.py b/hub_core/structure_audit_report.py index 52f897b..20ebae9 100644 --- a/hub_core/structure_audit_report.py +++ b/hub_core/structure_audit_report.py @@ -206,6 +206,106 @@ def _md(value: Any) -> str: return str(value if value is not None else "").replace("|", "\\|").replace("\n", " ") +def _project_sort_key(item: Mapping[str, Any]) -> tuple[str, str, str, str]: + """Return a stable key for report rows supplied by a caller. + + ``build_structure_audit_report`` already emits rows in this order, but the + renderer also accepts a report mapping from another producer. Sorting at + the rendering boundary keeps Markdown deterministic without mutating that + producer's data. + """ + + path = str(item.get("path") or "") + project_id = str(item.get("project_id") or "") + name = str(item.get("name") or "") + return (path.casefold(), path, project_id.casefold(), name.casefold()) + + +def _mapping_detail(value: Any, *, fallback: str) -> tuple[str, str]: + """Extract a path/reason pair from a diagnostic value without mutation.""" + + if isinstance(value, Mapping): + path = str(value.get("path") or value.get("name") or "") + reason = str(value.get("reason") or value.get("message") or "") + if not reason: + reason = fallback + return path, reason + text = str(value if value is not None else "").strip() + return "", text or fallback + + +def _unknown_rows(projects: list[Any]) -> list[tuple[str, str, str, str, str]]: + """Flatten unknown diagnostics into deterministic render-only rows. + + The final tuple contains project path, unknown path, candidate role, + reason, and project id. Keeping this as a separate flattened view makes + it straightforward to enumerate every unknown while leaving the JSON + report untouched. + """ + + rows: list[tuple[str, str, str, str, str]] = [] + for item in projects: + if not isinstance(item, Mapping): + continue + project_path = str(item.get("path") or item.get("name") or "") + project_id = str(item.get("project_id") or "") + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + unknowns = audit.get("unknowns") if isinstance(audit.get("unknowns"), list) else [] + for unknown in unknowns: + unknown_path, reason = _mapping_detail(unknown, fallback="no reason provided") + candidate_role = "" + if isinstance(unknown, Mapping): + candidate = unknown.get("candidate") + if isinstance(candidate, Mapping): + candidate_role = str(candidate.get("candidate_role") or "") + candidate_reason = str(candidate.get("reason") or "") + if candidate_reason: + reason = candidate_reason + elif candidate is not None: + candidate_role = str(candidate) + rows.append((project_path, unknown_path, candidate_role, reason, project_id)) + return sorted( + rows, + key=lambda row: ( + row[0].casefold(), + row[1].casefold(), + row[2].casefold(), + row[3].casefold(), + row[4].casefold(), + ), + ) + + +def _audit_error_rows(projects: list[Any]) -> list[tuple[str, str, str, str]]: + """Flatten project-level audit errors into deterministic render-only rows.""" + + rows: list[tuple[str, str, str, str]] = [] + for item in projects: + if not isinstance(item, Mapping): + continue + project_path = str(item.get("path") or item.get("name") or "") + project_id = str(item.get("project_id") or "") + errors = item.get("errors") + if isinstance(errors, (str, bytes)): + errors = [errors] + if not isinstance(errors, list): + continue + for error in errors: + error_path, reason = _mapping_detail(error, fallback="unspecified audit error") + rows.append((error_path or project_path, reason, project_path, project_id)) + return sorted( + rows, + key=lambda row: ( + row[2].casefold(), + row[2], + row[0].casefold(), + row[0], + row[1].casefold(), + row[3].casefold(), + ), + ) + + def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: """Render a compact deterministic Markdown report.""" @@ -235,6 +335,10 @@ def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: "unknown_count", ): lines.append(f"| {_md(key.replace('_', ' '))} | {_md(summary.get(key, 0))} |") + ordered_projects = sorted( + (item for item in projects if isinstance(item, Mapping)), + key=_project_sort_key, + ) lines.extend( [ "", @@ -244,9 +348,7 @@ def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: "| --- | --- | --- | --- | ---: | ---: | ---: | --- |", ] ) - for item in projects: - if not isinstance(item, Mapping): - continue + for item in ordered_projects: audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] unknowns = audit.get("unknowns") if isinstance(audit.get("unknowns"), list) else [] @@ -268,9 +370,7 @@ def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: ) lines.extend(["", "## Findings", ""]) any_findings = False - for item in projects: - if not isinstance(item, Mapping): - continue + for item in ordered_projects: audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] if findings: @@ -288,6 +388,38 @@ def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: lines.append("") if not any_findings: lines.append("No structure findings.") + + lines.extend(["", "## Unknowns", ""]) + unknown_rows = _unknown_rows(ordered_projects) + if unknown_rows: + current_project = None + for project_path, unknown_path, candidate_role, reason, _project_id in unknown_rows: + if project_path != current_project: + if current_project is not None: + lines.append("") + lines.append(f"### `{_md(project_path)}`") + current_project = project_path + detail = [] + if candidate_role: + detail.append(f"candidate role `{_md(candidate_role)}`") + detail.append(f"reason: {_md(reason)}") + lines.append(f"- `{_md(unknown_path or '(unspecified path)')}`: " + "; ".join(detail)) + else: + lines.append("No unknown paths.") + + lines.extend(["", "## Audit Errors", ""]) + error_rows = _audit_error_rows(ordered_projects) + if error_rows: + current_project = None + for error_path, reason, project_path, _project_id in error_rows: + if project_path != current_project: + if current_project is not None: + lines.append("") + lines.append(f"### `{_md(project_path)}`") + current_project = project_path + lines.append(f"- `{_md(error_path or '(project path unavailable)')}`: {_md(reason)}") + else: + lines.append("No audit errors.") return "\n".join(lines).rstrip() + "\n" diff --git a/tests/test_phase4_structure_contract_regressions.py b/tests/test_phase4_structure_contract_regressions.py new file mode 100644 index 0000000..87c7469 --- /dev/null +++ b/tests/test_phase4_structure_contract_regressions.py @@ -0,0 +1,153 @@ +"""Focused Phase 4 regression coverage for reviewed structure operations.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hub_core.mcp import GraphHubMCPServer +from hub_core.project_normalization import plan_normalize_project +from hub_core.structure_path_security import capture_project_root +from hub_core.structure_plan import ( + build_structure_plan, + canonical_plan_digest, + confirmation_token, +) +from hub_core.structure_role_binding import validate_role_destination_bindings + + +def _tree_snapshot(root: Path) -> tuple[str, ...]: + return tuple( + sorted( + path.relative_to(root).as_posix() + for path in root.rglob("*") + if "__pycache__" not in path.parts + ) + ) + + +def _structured(response: dict) -> dict: + return response["structuredContent"] + + +def test_plan_digest_and_token_are_stable_for_semantically_identical_plans(tmp_path: Path) -> None: + (tmp_path / "legacy").mkdir() + (tmp_path / "legacy" / "a.csv").write_bytes(b"a\n1\n") + (tmp_path / "legacy" / "b.csv").write_bytes(b"b\n2\n") + mappings = [ + {"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}, + {"source": "legacy/b.csv", "destination": "raw/b.csv", "role": "raw"}, + ] + + first = build_structure_plan(tmp_path, mappings) + second = build_structure_plan(tmp_path, list(reversed(mappings))) + + assert canonical_plan_digest(first) == canonical_plan_digest(second) + assert first["digest"] == second["digest"] + assert confirmation_token(first) == confirmation_token(second) + + # Presentation-only dictionary ordering must not change the reviewed identity. + reordered = json.loads(json.dumps(second, ensure_ascii=False)) + reordered = {key: reordered[key] for key in reversed(list(reordered))} + assert canonical_plan_digest(first) == canonical_plan_digest(reordered) + + +@pytest.mark.parametrize( + "malformed", + [ + {"path": "structure.roots.raw", "before": "raw", "after": "raw"}, + {"path": [], "before": "raw", "after": "raw"}, + {"path": ["structure", True], "before": "raw", "after": "raw"}, + {"path": ["structure", "roots", "raw"], "before": "raw"}, + ], +) +def test_build_structure_plan_rejects_malformed_config_diff(tmp_path: Path, malformed: dict) -> None: + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_bytes(b"x\n1\n") + + with pytest.raises(ValueError): + build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + config_diff=[malformed], + ) + + +def test_validate_role_destination_bindings_rejects_unknown_role(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="unknown structure role"): + validate_role_destination_bindings( + tmp_path, + [{"role": "script.unknown", "destination": "hub_scripts/analysis/x.py"}], + config_path=tmp_path / "project_config.yaml", + config_update=None, + root_identity=capture_project_root(tmp_path), + planned_hash=None, + ) + + +@pytest.mark.parametrize("destination", ["hub_scripts/figures/x.py", "outside/x.py"]) +def test_validate_role_destination_bindings_rejects_sibling_or_outside_destination( + tmp_path: Path, destination: str +) -> None: + with pytest.raises(ValueError, match="not bound"): + validate_role_destination_bindings( + tmp_path, + [{"role": "script.analysis", "destination": destination}], + config_path=tmp_path / "project_config.yaml", + config_update=None, + root_identity=capture_project_root(tmp_path), + planned_hash=None, + ) + + +def test_plan_normalize_copy_requires_approvals_but_adopt_is_read_only(tmp_path: Path) -> None: + source = tmp_path / "plot.py" + source.write_text("print('plot')\n", encoding="utf-8") + before = _tree_snapshot(tmp_path) + + with pytest.raises(ValueError, match="approved_mappings"): + plan_normalize_project(project_path=tmp_path, move_policy="copy") + + adopted = plan_normalize_project(project_path=tmp_path, move_policy="adopt") + + assert adopted["adopt_existing"] is True + assert adopted["entries"] == [] + assert _tree_snapshot(tmp_path) == before + + +def test_normalize_project_structure_apply_requires_exact_confirmation_token(tmp_path: Path) -> None: + project = tmp_path / "LegacyGraph" + project.mkdir() + (project / "plot.py").write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer(research_root=tmp_path) + arguments = { + "project_path": str(project), + "move_policy": "copy", + "approved_mappings": [ + {"source": "plot.py", "destination": "hub_scripts/figures/plot.py", "role": "script.figure"} + ], + } + + planned = _structured(server.call_tool("figops.normalize_project_structure", {**arguments, "dry_run": True})) + rejected = _structured( + server.call_tool( + "figops.normalize_project_structure", + {**arguments, "dry_run": False, "confirmation_token": planned["confirmation_token"] + "x"}, + ) + ) + + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_PLAN_REJECTED" + assert not (project / "hub_scripts" / "figures" / "plot.py").exists() + + applied = _structured( + server.call_tool( + "figops.normalize_project_structure", + {**arguments, "dry_run": False, "confirmation_token": planned["confirmation_token"]}, + ) + ) + assert applied["status"] in {"ok", "warning"} + assert (project / "hub_scripts" / "figures" / "plot.py").read_text(encoding="utf-8") == "print('plot')\n" diff --git a/tests/test_structure_audit_report.py b/tests/test_structure_audit_report.py index 9e65db2..1f27254 100644 --- a/tests/test_structure_audit_report.py +++ b/tests/test_structure_audit_report.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from pathlib import Path @@ -110,3 +111,88 @@ def test_audit_proposals_are_stripped_from_read_only_report(tmp_path: Path, monk assert report["projects"][0]["proposed_changes"] == [] assert report["projects"][0]["audit"]["proposed_changes"] == [] assert report["proposed_changes"] == [] + + +def test_markdown_enumerates_unknown_paths_reasons_and_audit_errors(): + report = { + "root": "/tmp/root", + "max_depth": 4, + "summary": {"project_count": 2}, + "projects": [ + { + "project_id": "b", + "path": "b", + "audit": { + "findings": [], + "unknowns": [ + { + "path": "legacy/plot.R", + "candidate": { + "candidate_role": "unknown", + "reason": "ambiguous candidates: analysis_scripts, figure_scripts", + }, + } + ], + }, + "errors": ["configuration unreadable"], + }, + { + "project_id": "a", + "path": "a", + "audit": { + "findings": [], + "unknowns": [{"path": "misc.bin", "reason": "no semantic declaration"}], + }, + "errors": [{"path": "a/project_config.yaml", "reason": "invalid YAML"}], + }, + ], + } + + markdown = report_module.render_structure_audit_markdown(report) + + assert "## Unknowns" in markdown + assert "`legacy/plot.R`" in markdown + assert "ambiguous candidates: analysis_scripts, figure_scripts" in markdown + assert "`misc.bin`" in markdown + assert "no semantic declaration" in markdown + assert "## Audit Errors" in markdown + assert "`a/project_config.yaml`: invalid YAML" in markdown + assert "`b`: configuration unreadable" in markdown + + +def test_markdown_diagnostic_sections_are_deterministic_and_do_not_mutate_report(): + report = { + "root": "/tmp/root", + "max_depth": 4, + "summary": {"project_count": 2}, + "projects": [ + { + "project_id": "b", + "path": "b", + "audit": { + "findings": [], + "unknowns": [ + {"path": "z.txt", "reason": "z reason"}, + {"path": "a.txt", "reason": "a reason"}, + ], + }, + "errors": ["z error", "a error"], + }, + { + "project_id": "a", + "path": "a", + "audit": {"findings": [], "unknowns": [{"path": "m.txt", "reason": "m reason"}]}, + "errors": ["m error"], + }, + ], + } + before = copy.deepcopy(report) + + first = report_module.render_structure_audit_markdown(report) + second = report_module.render_structure_audit_markdown(report) + + assert first == second + assert report == before + assert first.index("### `a`") < first.index("### `b`") + assert first.index("`a.txt`") < first.index("`z.txt`") + assert first.index("`b`: a error") < first.index("`b`: z error") From e97697d53e003b658005b58cb12746eaff7b011e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 09:55:07 +0900 Subject: [PATCH 07/21] Fail closed on unresolved structure dependencies --- docs/ROADMAP.md | 36 ++- docs/architecture.md | 32 ++- docs/project-structure-contract.md | 45 ++- ...roject-structure-runtime-integrity-plan.md | 36 ++- hub_core/dependency_python_inspection.py | 233 +++++++++++++++ hub_core/dependency_r_inspection.py | 268 ++++++++++++++++++ hub_core/dependency_scan_common.py | 126 ++++++++ hub_core/dependency_script_inspection.py | 184 ++++++++++++ hub_core/project_normalization.py | 99 ++++++- hub_core/structure_apply.py | 7 + hub_core/structure_plan.py | 9 + tests/test_dependency_script_inspection.py | 208 ++++++++++++++ tests/test_phase5_dependency_integration.py | 102 +++++++ tests/test_structure_apply.py | 30 +- 14 files changed, 1403 insertions(+), 12 deletions(-) create mode 100644 hub_core/dependency_python_inspection.py create mode 100644 hub_core/dependency_r_inspection.py create mode 100644 hub_core/dependency_scan_common.py create mode 100644 hub_core/dependency_script_inspection.py create mode 100644 tests/test_dependency_script_inspection.py create mode 100644 tests/test_phase5_dependency_integration.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6dd56c0..f709e1d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -97,7 +97,17 @@ Current release-candidate checkpoint: inventory/audit/plan/role-binding/apply, launcher-authorized external-raw execution, native no-replace result promotion, durable receipts, measured policy evidence, and verified project-script claims including conservative - dynamic-annotation discovery. CI run + dynamic-annotation discovery. The conservative dependency-scanner facade + `dependency_script_inspection.analyze_dependency_script` now delegates + bounded language-specific extraction to `dependency_python_inspection.py` and + `dependency_r_inspection.py`, with shared path predicates and deterministic + result ordering in `dependency_scan_common.py`. It reports deterministic + Python/R static candidates, unresolved hard-coded/dynamic references, and + incomplete scans without guessing roles; scanner output is evidence only, + while unresolved or incomplete findings are plan blockers, not approvals. + `role_roots` resolves a literal only through its most-specific declared + terminal semantic root; grouping roots `scripts` and `results` never clear + blockers, and equal-depth terminal ties remain unresolved. CI run [`29689087108`](https://github.com/Moonweave-Research/figops/actions/runs/29689087108) passed for source head `9e4d340b718529bd0f65ba46b2124dda718918a2`: macOS full pytest was 2,322 passed, 22 skipped, and 104 subtests, including the native @@ -138,9 +148,17 @@ Current release-candidate checkpoint: inputs and token. The token proves plan integrity and exact replay, not independent human identity or attestation; the current workflow does not close self-approval. A host-issued `approval_receipt` or immutable - reviewed-plan authority is deferred to Phase 5. Audit/plan control evidence - never becomes a runtime manifest or durable result, and runtime remains - external to the project. + reviewed-plan authority rooted in a host trust root is deferred to Phase 5. + Approval authority remains Phase 5/open until that authority exists and is + consumed by apply. Audit/plan control evidence never becomes a runtime + manifest or durable result, and runtime remains external to the project. + +- Structure normalization applies a fail-closed guard: a plan containing any + `hardcoded_unresolved_references` or `unresolved_proposals` is rejected before + copy, even with a valid digest and confirmation token. Parse/read/unsupported + language failures and dynamic dependency expressions set the scanner's + incomplete signal; no filename, extension, or directory heuristic can assign + a semantic role. --- @@ -182,6 +200,10 @@ hub_core/ structure_plan.py # deterministic reviewed copy plan structure_role_binding.py # approved destinations bound to declared roots structure_apply.py # write-gated copy-only apply transaction + dependency_script_inspection.py # dependency scanner facade and evidence API + dependency_python_inspection.py # bounded Python dependency extraction helper + dependency_r_inspection.py # bounded R dependency extraction helper + dependency_scan_common.py # shared path predicates and result ordering runtime_boundary.py # project/result/runtime disjointness atomic_no_clobber.py # native consuming same-FS no-replace publication durable_promotion.py # staged same-filesystem result promotion @@ -435,6 +457,12 @@ and scale/profile resolution into `themes.font_token_resolver`. The public passed through explicitly. With `themes.journal_theme` now below 800 lines, no tracked Python module exceeds the current architecture split signal. +The dependency scanner split keeps `hub_core.dependency_script_inspection` as +the compatibility facade while moving bounded Python and R extraction into +`hub_core.dependency_python_inspection` and +`hub_core.dependency_r_inspection`; shared path predicates and deterministic +evidence ordering live in `hub_core.dependency_scan_common`. + The current execution plan for that maintenance track lives in `docs/specs/2026-06-28-large-module-decomposition-plan.md`. diff --git a/docs/architecture.md b/docs/architecture.md index 7d2f614..28b0d5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,10 @@ hub_core/structure_plan.py # deterministic reviewed copy plan hub_core/structure_role_binding.py # destination -> declared role-root binding hub_core/structure_stage_cleanup.py # ownership-safe private-stage/lease cleanup hub_core/structure_apply.py # token/CAS-guarded copy-only transaction +hub_core/dependency_script_inspection.py # dependency scanner facade and evidence API +hub_core/dependency_python_inspection.py # bounded Python dependency extraction helper +hub_core/dependency_r_inspection.py # bounded R dependency extraction helper +hub_core/dependency_scan_common.py # shared path predicates and result ordering hub_core/runtime_boundary.py # project/result/runtime disjointness hub_core/atomic_no_clobber.py # native consuming no-replace namespace move hub_core/durable_promotion.py # destination-filesystem staged promotion @@ -213,6 +217,22 @@ every approved destination back to its declared semantic root, while `structure_stage_cleanup.py` owns transaction-private stage and lease cleanup. Reviewed mutation is isolated in `structure_apply.py` and remains copy-only. +`dependency_script_inspection.py` is the public read-only facade for bounded +Python/R dependency evidence. It delegates language-specific extraction to +`dependency_python_inspection.py` and `dependency_r_inspection.py`, while +`dependency_scan_common.py` owns shared path predicates and deterministic +deduplication/order. Its `analyze_dependency_script(...)` API returns +deterministic static candidates plus `hardcoded_unresolved_references` and a +`dependency_scan_incomplete` signal. It never executes scripts or guesses a +semantic role: a caller-provided `role_roots` mapping resolves a literal only +through its most-specific declared terminal semantic root. Grouping roots +`scripts` and `results` never clear blockers, and equal-depth terminal matches +remain unresolved. Parse/read/unsupported-language failures and dynamic path +expressions remain incomplete evidence and block a migration plan. +`structure_apply.py` also rejects non-empty `unresolved_proposals` before any +copy, so a valid digest/token cannot turn scanner evidence or an unresolved +proposal into an apply approval. + Runtime and durable-result mechanics are separate from structure discovery. `runtime_boundary.py` enforces project/result/runtime disjointness, `atomic_no_clobber.py` provides the only publication primitive: Windows @@ -359,7 +379,17 @@ The token proves integrity and exact replay of that plan; it does not prove an independent human identity, reviewer authority, or attestation, and the current workflow does not close self-approval. A host-issued `approval_receipt` (or equivalent immutable reviewed-plan authority) bound to reviewer identity and -the plan digest is a Phase 5 gap, not a current capability. +the plan digest and rooted in a host trust root is a Phase 5 gap, not a current +capability. Approval authority remains Phase 5/open until that authority exists +and is consumed by apply. + +During planning, `analyze_dependency_script` output is evidence only. Static +imports/path literals are not role approvals; dynamic paths and parse/read or +unsupported-language failures set the incomplete signal and remain plan +blockers, and no role is guessed from names or extensions. The apply guard +rejects any non-empty `hardcoded_unresolved_references` or +`unresolved_proposals` before copying, even when the reviewed digest/token is +otherwise valid. The emitted structure report is diagnostic output, not a runtime manifest, durable result, or evidence receipt. It describes current structure findings diff --git a/docs/project-structure-contract.md b/docs/project-structure-contract.md index f11bed9..27a9e39 100644 --- a/docs/project-structure-contract.md +++ b/docs/project-structure-contract.md @@ -78,6 +78,45 @@ fails closed when the native guarantee is unavailable. Failure rollback removes only newly created files whose hashes still match the reviewed plan; FigOps never deletes or moves raw inputs. +### Conservative dependency-script evidence + +Before a migration plan is reviewed, project script references may be inspected +with the read-only API +`hub_core.dependency_script_inspection.analyze_dependency_script(script, +suffix=None, *, language=None, script_path=None, role_roots=None)`. The +canonical function (also exposed through descriptive `inspect_*`/`scan_*` +aliases) accepts Python or R source text, or a `Path`, and returns deterministic, +JSON-friendly evidence with these fields: + +- `inspectable`: whether the source was parsed by the bounded Python/R scanner; +- `dependency_scan_incomplete`: whether any part of the dependency surface could + not be resolved safely; +- `static_candidates`: imports and obvious literal file/path references found by + the scanner; and +- `hardcoded_unresolved_references`: dynamic paths, unmapped literals, and + read/parse/language diagnostics that must remain blockers for review. + +The scanner never executes a script, rewrites source, or assigns a role from a +filename, extension, or directory name. A literal path is resolved only when +the caller supplies `role_roots` and the scanner selects its most-specific +declared terminal semantic root. Grouping roots `scripts` and `results` never +clear blockers, and equal-depth terminal matches remain unresolved; otherwise +the path remains a `hardcoded_path` reference. Dynamic +path expressions are reported as unresolved and set +`dependency_scan_incomplete`. Read, unsupported language, or parse failures set +`inspectable: false`, preserve a diagnostic, and set +`dependency_scan_incomplete`; a partially scanned script is therefore +incomplete evidence, not a clean result. The planner folds these references +into `hardcoded_unresolved_references`; scanner output is evidence and a plan +only; unresolved or incomplete scanner findings are plan blockers, never +approved mappings. + +The apply guard is fail-closed even when a plan digest and confirmation token +are valid: `structure_apply.apply_structure_plan` rejects a non-empty +`hardcoded_unresolved_references` **or** `unresolved_proposals` before any +project-root identity check or copy. An unresolved proposal is consequently a +plan blocker, not a warning that can be bypassed by replaying a reviewed token. + ### Finding-to-plan selection matrix The audit report is a selection aid, not an approval list. The following matrix @@ -146,8 +185,10 @@ out-of-band policy/process requirement rather than a machine-enforced claim. The next Phase 5 gap is a host-issued `approval_receipt` (or equivalent immutable reviewed-plan authority) bound to the plan digest and reviewed inputs, with verifiable reviewer identity/role, authorization, and attestation -semantics. Until that authority exists and is consumed by apply, no plan token -or copy receipt may be described as independent approval evidence. +semantics and rooted in a host trust root. Until that authority exists and is +consumed by apply, no scanner output, plan token, unresolved-proposal result, or +copy receipt may be described as independent approval evidence. Approval +authority remains Phase 5/open. ## Runtime and durable results diff --git a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md index e04f86f..2343897 100644 --- a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md +++ b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md @@ -332,7 +332,8 @@ The token proves integrity and exact replay of the canonical plan, not independent reviewer identity, authority, or attestation; the current workflow does not close self-approval. A host-issued `approval_receipt` or equivalent immutable reviewed-plan authority, bound to reviewer identity/role and the plan -digest, remains a Phase 5 gap. +digest and rooted in a host trust root, remains a Phase 5 gap. Approval +authority is Phase 5/open until that authority exists and is consumed by apply. Audit reports, plans, digests, and tokens are control evidence, not runtime manifests, durable results, or evidence receipts; runtime remains externally rooted and disposable. @@ -344,6 +345,37 @@ reviewed compare-and-swap edit remains an `unresolved_dependency`. Migration apply is blocked while even one such dependency can affect a copied artifact; warnings are insufficient and the tool may not guess or rewrite arbitrary source. +### Conservative dependency-script inspection + +The migration planner uses the read-only +`hub_core.dependency_script_inspection.analyze_dependency_script` API for +bounded dependency evidence. It accepts Python or R source text (or a `Path`) +and optional `suffix`/`language`, `script_path`, and explicit `role_roots` +arguments. The deterministic JSON-friendly result contains `inspectable`, +`dependency_scan_incomplete`, `static_candidates`, and +`hardcoded_unresolved_references`. Static candidates include imports and +obvious literal file/path references, but they are evidence only: the scanner +never executes a script, rewrites source, or guesses `raw`, `results`, script, +or any other semantic role from a name, extension, or directory. + +A literal path is cleared only through the most-specific declared terminal +semantic root selected by the caller-provided `role_roots` mapping. Grouping +roots `scripts` and `results` never clear blockers, and equal-depth terminal +matches remain unresolved. Otherwise it remains an unresolved `hardcoded_path` +entry. Dynamic path expressions remain unresolved and set +`dependency_scan_incomplete`; read errors, unsupported languages, and parse +failures set `inspectable: false`, preserve a diagnostic, and set the same +incomplete signal. Thus a partial or failed scan is incomplete evidence and a +plan blocker, not a clean pass. The planner carries these entries into +`hardcoded_unresolved_references`; scanner output never becomes an approved +mapping. + +`structure_apply.apply_structure_plan` enforces the corresponding fail-closed +guard: a non-empty `hardcoded_unresolved_references` **or** +`unresolved_proposals` rejects the plan before project-root identity checks or +copy, even when the plan digest and confirmation token are valid. An unresolved +proposal is therefore a plan blocker, not a warning or an approval surrogate. + ## 8. Seven P1 corrections ### P1-1 Calculation evidence binds to the real artifact @@ -451,7 +483,7 @@ approval, or fulfillment of the complete Definition of Done. | WP3 | implementation complete | Neutral v1.1/v2 defaults, independent validation targets, and artifact-derived policy measurements are integrated. | | WP4 | implementation complete | v1.1 role/DAG/alias validation and legacy 1.0 in-memory resolution are integrated with config parsing and templates. | | WP5 | implementation complete | Scaffolding and normalization consume the shared `project_layout.py` inventory. | -| WP6 | implementation complete | `structure_inventory`, `structure_audit`, `structure_plan`, and `structure_role_binding` use semantic/reference precedence and bind approved destinations to declared roots. | +| WP6 | implementation complete | `structure_inventory`, `structure_audit`, `structure_plan`, and `structure_role_binding` use semantic/reference precedence and bind approved destinations to declared roots. The conservative `dependency_script_inspection.analyze_dependency_script` API supplies deterministic Python/R dependency evidence; parse/dynamic/incomplete findings remain blockers, and apply rejects non-empty `unresolved_proposals` fail-closed. | | WP7 | implementation complete | Reviewed application is copy-only, token/CAS guarded, rollback-aware, and publishes a verified sibling stage only through the native consuming no-replace primitive; race winners are preserved. | | WP8 | implementation complete; independent adversarial gate green | Runtime containment, pre-execution external-raw verification, eligible-result promotion, staged durable publication, and runtime-independent receipt verification are integrated across CLI and MCP producers. Handle-bound rollback deletion closes the hash-to-unlink swap window; the independent rollback suite passed 34 tests with two platform skips. | | WP9 | implementation complete | v2 exposes structure detail through `figops.describe`; compatibility apply remains write-gated without expanding the seven-tool default surface. | diff --git a/hub_core/dependency_python_inspection.py b/hub_core/dependency_python_inspection.py new file mode 100644 index 0000000..83a8753 --- /dev/null +++ b/hub_core/dependency_python_inspection.py @@ -0,0 +1,233 @@ +"""Conservative static dependency extraction for Python scripts. + +The scanner is deliberately read-only and bounded: it uses :mod:`ast` to +collect imports and literal arguments to common path APIs, while leaving +dynamic expressions unresolved for caller review. +""" + +from __future__ import annotations + +import ast +from typing import Any + +from .dependency_scan_common import ( + _is_external_url, + _is_static_local_path, + _looks_like_path, +) + +_PYTHON_PATH_CALLS = { + "open", + "path", + "read_csv", + "read_table", + "read_fwf", + "read_excel", + "read_json", + "read_parquet", + "read_feather", + "read_pickle", + "loadtxt", + "genfromtxt", + "fromfile", + "load_workbook", + "read_text", + "read_bytes", + "write_text", + "write_bytes", + "savefig", + "to_csv", + "to_excel", + "to_json", + "to_parquet", + "to_feather", +} +_PYTHON_LITERAL_PATH_CALLS = _PYTHON_PATH_CALLS - { + # These are methods on an already selected Path object; their first + # argument is content, not another path. Avoid turning ``write_text`` + # labels into dependency candidates. + "read_text", + "read_bytes", + "write_text", + "write_bytes", +} +_PYTHON_PATH_KEYWORDS = { + "path", + "filepath", + "filename", + "file", + "fname", + "name", + # Common pandas/numpy/scipy and workbook-loader spellings. These are + # path-bearing arguments even when the call has no positional argument. + "filepath_or_buffer", + "path_or_buf", + "fname_or_buf", + "file_or_buf", + "io", +} + + +def _line(node: ast.AST) -> int: + return int(getattr(node, "lineno", 0) or 0) + + +def _column(node: ast.AST) -> int: + return int(getattr(node, "col_offset", 0) or 0) + + +def _call_name(call: ast.Call) -> str: + function = call.func + if isinstance(function, ast.Name): + return function.id.lower() + if isinstance(function, ast.Attribute): + return function.attr.lower() + return "" + + +def _constant_path_string(node: ast.AST) -> str | None: + """Resolve a bounded ``Path("literal")`` wrapper around a path value.""" + + direct = _constant_string(node) + if direct is not None: + return direct + if isinstance(node, ast.Call) and _call_name(node) == "path" and node.args: + return _constant_string(node.args[0]) + return None + + +def _constant_string(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + # A bounded static concatenation is still an explicit literal dependency. + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _constant_string(node.left) + right = _constant_string(node.right) + if left is not None and right is not None and len(left) + len(right) <= 4096: + return left + right + return None + + +def _static_assignments(tree: ast.AST) -> dict[str, str]: + assignments: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + value = _constant_path_string(node.value) + if value is None: + continue + for target in node.targets: + if isinstance(target, ast.Name): + assignments[target.id] = value + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + value = _constant_path_string(node.value) if node.value is not None else None + if value is not None: + assignments[node.target.id] = value + return assignments + + +def _python_candidates(script_text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]: + """Extract static Python dependency candidates and unresolved findings.""" + + try: + tree = ast.parse(script_text, mode="exec") + except (SyntaxError, ValueError, TypeError): + return [], [{"kind": "parse_error", "source": "python", "reason": "Python syntax could not be parsed."}], True + + assignments = _static_assignments(tree) + candidates: list[dict[str, Any]] = [] + unresolved: list[dict[str, Any]] = [] + incomplete = False + + def add_candidate(kind: str, path: str, node: ast.AST, source: str) -> None: + text = path.strip() + if not text: + return + candidates.append( + { + "kind": kind, + "path": text, + "source": source, + "line": _line(node), + "column": _column(node), + } + ) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + add_candidate("import", alias.name, node, "python:import") + elif isinstance(node, ast.ImportFrom): + module = "." * int(node.level or 0) + (node.module or "") + if module: + add_candidate("import", module, node, "python:from_import") + elif node.level: + add_candidate("import", "." * node.level, node, "python:relative_import") + + if isinstance(node, ast.Call): + name = _call_name(node) + if name not in _PYTHON_PATH_CALLS: + continue + # ``Path.write_text/read_text`` receive content/options, not a + # path. Their enclosing ``Path("...")`` call is still visited + # separately, so only path-bearing methods should inspect a first + # positional argument here. + arguments: list[ast.AST] = ( + list(node.args[:1]) if name in _PYTHON_LITERAL_PATH_CALLS else [] + ) + arguments.extend( + keyword.value + for keyword in node.keywords + if keyword.arg is not None and keyword.arg.lower() in _PYTHON_PATH_KEYWORDS + ) + if not arguments: + continue + found_static = False + for argument in arguments: + path = _constant_path_string(argument) + if path is None and isinstance(argument, ast.Name): + path = assignments.get(argument.id) + if path is not None and name in _PYTHON_LITERAL_PATH_CALLS and _is_static_local_path(path): + add_candidate("path_literal", path, argument, f"python:{name}") + found_static = True + elif path is None: + incomplete = True + unresolved.append( + { + "kind": "dynamic_path", + "source": f"python:{name}", + "line": _line(argument) or _line(node), + "reference": ast.unparse(argument)[:240], + "reason": "file path expression is not statically resolvable", + } + ) + elif name in _PYTHON_LITERAL_PATH_CALLS: + # Do not silently discard an empty or remote literal from + # a recognised path API. It cannot be materialised as a + # project dependency and therefore remains a blocker. + incomplete = True + unresolved.append( + { + "kind": "external_path" if _is_external_url(path) else "invalid_path_literal", + "source": f"python:{name}", + "line": _line(argument) or _line(node), + "reference": path[:240], + "reason": ( + "path literal names an external URL" + if _is_external_url(path) + else "path literal is empty" + ), + } + ) + if not found_static and not any(_constant_string(argument) is not None for argument in arguments): + incomplete = True + + # Also retain obvious path literals outside a recognised call. This is + # useful for assignments later consumed by a wrapper we cannot evaluate. + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str) and _looks_like_path(node.value): + add_candidate("path_literal", node.value, node, "python:path_literal") + + return candidates, unresolved, incomplete + + +__all__ = ["_python_candidates"] diff --git a/hub_core/dependency_r_inspection.py b/hub_core/dependency_r_inspection.py new file mode 100644 index 0000000..e4f70d5 --- /dev/null +++ b/hub_core/dependency_r_inspection.py @@ -0,0 +1,268 @@ +"""Conservative static dependency extraction for R scripts. + +R does not provide a safe standard AST in the runtime used by FigOps, so this +scanner uses a quote/comment-aware bounded call lexer. Dynamic expressions and +malformed delimiters remain unresolved findings instead of being guessed. +""" + +from __future__ import annotations + +import re +from typing import Any + +from .dependency_scan_common import _is_external_url, _is_static_local_path + +_R_PATH_CALLS = { + "source", + "read.csv", + "read_csv", + "read.delim", + "read_delim", + "read.table", + "read_table", + "readr::read_csv", + "readr::read_delim", + "readlines", + "readlines", + "readr::read_lines", + "readr::read_rds", + "readr::read_rda", + "readr::read_rds", + "readr::write_csv", + "readr::write_delim", + "readr::write_lines", + "readr::write_rds", + "readr::write_rds", + "readr::write_rds", + "readr::read_file", + "readr::write_file", + "readr::read_lines", + "readr::read_table", + "readr::read_csv", + "readr::read_tsv", + "readr::read_delim", + "readr::read_fwf", + "readr::read_log", + "readr::read_rds", + "readr::write_csv", + "readr::write_tsv", + "readr::write_delim", + "readr::write_rds", + "readr::write_excel_csv", + "readr::write_excel_csv2", + "readr::write_lines", + "readr::write_file", + "readRDS", + "readLines", + "load", + "save", + "saveRDS", + "write.csv", + "write.table", + "writeLines", + "file", + "file.exists", + "normalizePath", +} +_R_PATH_KEYWORDS = {"file", "path", "file_name", "filename", "name", "description"} +_R_CALL_RE = re.compile(r"(? tuple[bool, str | None]: + """Validate quotes/comments/brackets enough for safe call extraction.""" + + stack: list[str] = [] + quote: str | None = None + escaped = False + comment = False + pairs = { + "(": ")", + "[": "]", + "{": "}", + } + for char in script_text: + if comment: + if char in "\r\n": + comment = False + continue + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char == "#": + comment = True + elif char in {'"', "'", "`"}: + quote = char + elif char in pairs: + stack.append(char) + elif char in pairs.values(): + if not stack or pairs[stack.pop()] != char: + return False, "R delimiters are unbalanced." + if quote is not None: + return False, "R string literal is unterminated." + if stack: + return False, "R delimiters are unbalanced." + return True, None + + +def _r_matching_call(script_text: str, opening: int) -> int | None: + depth = 1 + quote: str | None = None + escaped = False + comment = False + for index in range(opening + 1, len(script_text)): + char = script_text[index] + if comment: + if char in "\r\n": + comment = False + continue + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char == "#": + comment = True + elif char in {'"', "'", "`"}: + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index + return None + + +def _r_split_arguments(text: str) -> list[str]: + values: list[str] = [] + start = 0 + depth = 0 + quote: str | None = None + escaped = False + for index, char in enumerate(text): + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char in {'"', "'", "`"}: + quote = char + elif char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif char == "," and depth == 0: + values.append(text[start:index].strip()) + start = index + 1 + tail = text[start:].strip() + if tail: + values.append(tail) + return values + + +def _r_string(value: str) -> str | None: + value = value.strip() + if len(value) < 2 or value[0] not in {'"', "'"} or value[-1] != value[0]: + return None + body = value[1:-1] + # Keep the evaluator intentionally bounded; R's full string semantics are + # not needed to identify an explicit path candidate. + return re.sub(r"\\([\\\"'])", r"\1", body) + + +def _r_candidates(script_text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]: + """Extract static R dependency candidates and unresolved findings.""" + + valid, reason = _r_scan_state(script_text) + if not valid: + return [], [{"kind": "parse_error", "source": "r", "reason": reason or "R source could not be parsed."}], True + + candidates: list[dict[str, Any]] = [] + unresolved: list[dict[str, Any]] = [] + incomplete = False + for match in _R_CALL_RE.finditer(script_text): + raw_name = match.group(1) + name = raw_name.lower() + canonical = name if "::" not in name else name + if canonical not in {item.lower() for item in _R_PATH_CALLS} and name.split("::")[-1] not in { + item.lower() for item in _R_PATH_CALLS + }: + continue + opening = match.end() - 1 + closing = _r_matching_call(script_text, opening) + if closing is None: + # The full scanner should already have caught this, but retain a + # fail-closed diagnostic if a future lexer change misses it. + return [], [{"kind": "parse_error", "source": "r", "reason": "R call delimiters are unbalanced."}], True + raw_arguments = _r_split_arguments(script_text[opening + 1 : closing]) + arguments: list[str] = [] + for index, argument in enumerate(raw_arguments): + named = re.match(r"^\s*([A-Za-z.][A-Za-z0-9_.]*)\s*=\s*(.*)$", argument, re.DOTALL) + if named: + if named.group(1).lower() in _R_PATH_KEYWORDS: + arguments.append(named.group(2).strip()) + elif index == 0: + arguments.append(argument.strip()) + if not arguments: + continue + found_static = False + for argument in arguments: + path = _r_string(argument) + if path is not None and _is_static_local_path(path): + line = script_text.count("\n", 0, match.start()) + 1 + candidates.append( + { + "kind": "path_literal", + "path": path.strip(), + "source": f"r:{raw_name}", + "line": line, + "column": match.start(), + } + ) + found_static = True + elif path is None: + incomplete = True + unresolved.append( + { + "kind": "dynamic_path", + "source": f"r:{raw_name}", + "line": script_text.count("\n", 0, match.start()) + 1, + "reference": argument[:240], + "reason": "file path expression is not statically resolvable", + } + ) + else: + # A recognised R file API with an empty or remote literal is + # not a clean scan: the reference cannot be represented as a + # local project dependency, so retain a fail-closed finding. + incomplete = True + unresolved.append( + { + "kind": "external_path" if _is_external_url(path) else "invalid_path_literal", + "source": f"r:{raw_name}", + "line": script_text.count("\n", 0, match.start()) + 1, + "reference": path[:240], + "reason": ( + "path literal names an external URL" + if _is_external_url(path) + else "path literal is empty" + ), + } + ) + if not found_static and arguments: + incomplete = True + return candidates, unresolved, incomplete + + +__all__ = ["_r_candidates"] diff --git a/hub_core/dependency_scan_common.py b/hub_core/dependency_scan_common.py new file mode 100644 index 0000000..441aa5e --- /dev/null +++ b/hub_core/dependency_scan_common.py @@ -0,0 +1,126 @@ +"""Shared low-level helpers for conservative dependency scanners. + +This module intentionally contains no language-specific parsing. Python and +R scanners use the same bounded path predicates and deterministic result +ordering so the facade can preserve one stable evidence contract. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +_PATH_SUFFIXES = { + ".csv", + ".tsv", + ".txt", + ".dat", + ".parquet", + ".json", + ".jsonl", + ".xlsx", + ".xls", + ".h5", + ".hdf5", + ".feather", + ".pkl", + ".pickle", + ".rds", + ".rda", + ".rdata", + ".yaml", + ".yml", + ".toml", + ".ini", + ".png", + ".jpg", + ".jpeg", + ".svg", + ".pdf", + ".eps", + ".tif", + ".tiff", + ".py", + ".r", +} + + +def _is_external_url(value: str) -> bool: + """Return whether a literal names a remote URL rather than a project path.""" + + return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value.strip())) + + +def _looks_like_path(value: str) -> bool: + """Return true only for an obvious file/path literal. + + Labels, package names, and arbitrary prose are intentionally ignored. A + URL is not treated as a project dependency because it cannot be safely + materialised by a copy-only project migration. + """ + + text = value.strip() + if not text or len(text) > 4096 or _is_external_url(text): + return False + if text.startswith(("./", "../", "/", "~/", "\\")) or "/" in text or "\\" in text: + return True + return Path(text).suffix.lower() in _PATH_SUFFIXES + + +def _is_static_local_path(value: str) -> bool: + """Recognise any non-empty local literal passed to a path-bearing API. + + A suffix is not required here: ``read_csv("input")``, ``open("README")``, + and ``Path("workspace")`` are all explicit path references. The broader + ``_looks_like_path`` predicate remains intentionally conservative for + string literals found outside a recognised path API. + """ + + text = value.strip() + return bool(text) and len(text) <= 4096 and not _is_external_url(text) + + +def _deduplicate(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return deterministic scanner evidence without duplicate findings.""" + + unique: dict[tuple[Any, ...], dict[str, Any]] = {} + for item in items: + # A path literal found through a recognised file API and through the + # fallback literal walk is one dependency, not two. Keep separate + # source locations, however, because two calls on different lines may + # require independent review. + if item.get("kind") == "path_literal": + key = ( + item.get("kind"), + item.get("path"), + ) + elif item.get("kind") == "hardcoded_path": + key = ( + item.get("kind"), + item.get("path"), + item.get("line"), + item.get("column"), + ) + else: + key = tuple((field, item.get(field)) for field in sorted(item)) + unique.setdefault(key, item) + return sorted( + unique.values(), + key=lambda item: ( + int(item.get("line", 0) or 0), + int(item.get("column", 0) or 0), + str(item.get("kind", "")), + str(item.get("path", item.get("reference", ""))), + str(item.get("source", "")), + ), + ) + + +__all__ = [ + "_PATH_SUFFIXES", + "_deduplicate", + "_is_external_url", + "_is_static_local_path", + "_looks_like_path", +] diff --git a/hub_core/dependency_script_inspection.py b/hub_core/dependency_script_inspection.py new file mode 100644 index 0000000..61f10a4 --- /dev/null +++ b/hub_core/dependency_script_inspection.py @@ -0,0 +1,184 @@ +"""Conservative, read-only discovery of script dependencies. + +This facade preserves the public scanner API while delegating language-specific +lexing to :mod:`dependency_python_inspection` and +:mod:`dependency_r_inspection`. The returned mapping is JSON-friendly and +deterministic evidence for review; it never executes or rewrites source files. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from .dependency_python_inspection import _python_candidates +from .dependency_r_inspection import _r_candidates +from .dependency_scan_common import _deduplicate + +_SUPPORTED_SUFFIXES = {".py", ".python", ".r"} +_GROUPING_ROLE_ROOTS = {"scripts", "results"} + + +def _suffix(value: object, script_path: object = None) -> str: + """Normalise a language/suffix hint without inferring a role.""" + + candidate = value + if candidate is None and script_path is not None: + candidate = Path(script_path).suffix + text = str(candidate or "").strip().lower() + if text in {"python", "py"}: + return ".py" + if text in {"r", ".r"}: + return ".r" + if text and not text.startswith("."): + text = "." + text + return text + + +def _explicit_role(path: str, role_roots: Mapping[str, str] | None) -> str | None: + """Resolve the most-specific declared terminal root, without guessing. + + The v1.1 contract deliberately nests semantic roots below the aggregate + ``scripts`` and ``results`` roots. A dependency below + ``hub_scripts/analysis`` therefore has two lexical matches, but the + terminal ``analysis_scripts`` declaration is the only actionable one. + Aggregate roots never clear a blocker by themselves, and equal-depth + matches remain unresolved. + """ + + if not isinstance(role_roots, Mapping): + return None + normalized = path.replace("\\", "/") + if normalized.startswith("/") or normalized.startswith("../") or "/../" in normalized: + return None + normalized = normalized.lstrip("./") + matches: list[tuple[int, str]] = [] + for role, root in role_roots.items(): + if not isinstance(role, str) or not isinstance(root, str) or not root.strip(): + continue + if role in _GROUPING_ROLE_ROOTS: + continue + prefix = root.replace("\\", "/").strip("/") + if normalized == prefix or normalized.startswith(prefix + "/"): + depth = len(tuple(part for part in prefix.split("/") if part)) + matches.append((depth, role)) + if not matches: + return None + deepest = max(depth for depth, _ in matches) + roles = [role for depth, role in matches if depth == deepest] + return roles[0] if len(roles) == 1 else None + + +def analyze_dependency_script( + script: str | Path, + suffix: str | None = None, + *, + language: str | None = None, + script_path: str | Path | None = None, + role_roots: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Inspect Python/R source and return deterministic dependency evidence. + + ``role_roots`` is optional and is intentionally an explicit mapping. A + path resolves only to the most-specific terminal semantic root; aggregate + ``scripts``/``results`` roots are excluded and equal-depth matches remain + unresolved. Paths matching zero or multiple actionable roots are retained + in ``hardcoded_unresolved_references`` rather than being assigned a guessed + role. Parsing errors return ``inspectable=False`` and + ``dependency_scan_incomplete=True``; no exception escapes for malformed + source or an unreadable :class:`~pathlib.Path`. + """ + + source_path = script_path + if isinstance(script, Path): + source_path = source_path or script + if suffix is None: + suffix = script.suffix + try: + script_text = script.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + {"kind": "read_error", "source": str(script), "reason": "script could not be read."} + ], + } + elif isinstance(script, str): + script_text = script + else: + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + {"kind": "input_error", "source": "script", "reason": "script source must be text or a Path."} + ], + } + + normalized_suffix = _suffix(language or suffix, source_path) + if normalized_suffix not in _SUPPORTED_SUFFIXES: + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + { + "kind": "unsupported_language", + "source": normalized_suffix or "unknown", + "reason": "only Python and R source are statically inspectable.", + } + ], + } + + if normalized_suffix == ".py": + candidates, unresolved, incomplete = _python_candidates(script_text) + else: + candidates, unresolved, incomplete = _r_candidates(script_text) + + candidates = _deduplicate(candidates) + unresolved = _deduplicate(unresolved) + + # A literal path is actionable evidence but remains unresolved until the + # caller supplies an exact, unambiguous terminal semantic role mapping. + # Aggregate roots are deliberately excluded and ties remain unresolved. + # This is the important boundary: the scanner never decides that ``data/`` + # means raw, results, or any other semantic role. + for candidate in candidates: + if candidate.get("kind") != "path_literal": + continue + path = str(candidate.get("path") or "") + if _explicit_role(path, role_roots) is not None: + continue + unresolved.append( + { + "kind": "hardcoded_path", + "path": path, + "source": candidate.get("source", ""), + "line": candidate.get("line", 0), + "column": candidate.get("column", 0), + "reason": "hard-coded path has no single explicit declared role mapping", + } + ) + + return { + "inspectable": not any(item.get("kind") == "parse_error" for item in unresolved), + "dependency_scan_incomplete": bool(incomplete), + "static_candidates": candidates, + "hardcoded_unresolved_references": _deduplicate(unresolved), + } + + +# Descriptive aliases keep the API discoverable for callers that use +# ``inspect`` or ``scan`` terminology while preserving one implementation. +inspect_dependency_script = analyze_dependency_script +scan_dependency_script = analyze_dependency_script +inspect_script_dependencies = analyze_dependency_script + +__all__ = [ + "analyze_dependency_script", + "inspect_dependency_script", + "scan_dependency_script", + "inspect_script_dependencies", +] diff --git a/hub_core/project_normalization.py b/hub_core/project_normalization.py index 1f3eee3..e3bd78a 100644 --- a/hub_core/project_normalization.py +++ b/hub_core/project_normalization.py @@ -10,6 +10,7 @@ from .adapters import select_adapters from .config_parser import ALLOWED_TARGET_FORMATS, load_yaml_with_unique_keys +from .dependency_script_inspection import analyze_dependency_script from .project_layout import SCAFFOLD_MANIFEST_FILENAME, build_scaffold_manifest from .project_structure_contract import resolve_project_structure from .structure_apply import apply_structure_plan @@ -186,17 +187,41 @@ def plan_normalize_project( project_path = _project_root_path(project_path, must_exist_dir=True) if move_policy == "copy" and approved_mappings is None: raise ValueError("copy normalization requires explicit approved_mappings from a reviewed dry-run.") + if approved_mappings is not None and not isinstance(approved_mappings, list): + approved_mappings = list(approved_mappings) config, style_config_path = _load_project_config(project_path) proposed_mappings, unresolved_proposals = _propose_normalization_mappings( project_path, config=config, include_raw=include_raw, ) + approved_sources = { + source + for mapping in approved_mappings or [] + if isinstance(mapping, Mapping) + for source in [mapping.get("source")] + if isinstance(source, str) + } + unresolved_proposals = [ + proposal + for proposal in unresolved_proposals + if proposal.get("source") not in approved_sources + ] + dependency_blockers = _scan_normalization_script_dependencies( + project_path, + config=config, + proposed_mappings=proposed_mappings, + approved_mappings=approved_mappings, + ) plan = build_structure_plan( project_path, approved_mappings or [], config_diff=config_diff or [], - hardcoded_unresolved_references=hardcoded_unresolved_references or [], + hardcoded_unresolved_references=[ + *(hardcoded_unresolved_references or []), + *dependency_blockers, + ], + unresolved_proposals=unresolved_proposals, ) plan.update( { @@ -204,7 +229,6 @@ def plan_normalize_project( "adopt_existing": move_policy == "adopt", "include_raw": include_raw, "proposed_mappings": proposed_mappings, - "unresolved_proposals": unresolved_proposals, "style_summary": _style_summary(style_config_path), } ) @@ -246,6 +270,77 @@ def _iter_normalization_files(project_path: Path) -> list[Path]: return sorted(files, key=lambda item: item.relative_to(project_path).as_posix()) +def _scan_normalization_script_dependencies( + project_path: Path, + *, + config: Mapping[str, Any], + proposed_mappings: list[dict[str, Any]], + approved_mappings: list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + """Collect dependency blockers for the exact script sources under review. + + Static dependency evidence never becomes a role or mapping. In copy mode, + only explicitly reviewed script sources are inspected so an unrelated + legacy script cannot block an otherwise reviewed subset of a project. + Adopt-mode previews inspect the script candidates that discovery proposed. + """ + + if approved_mappings is not None: + script_sources = { + str(mapping["source"]) + for mapping in approved_mappings + if isinstance(mapping, Mapping) + and isinstance(mapping.get("source"), str) + and isinstance(mapping.get("role"), str) + and mapping["role"].startswith("script.") + } + else: + script_sources = { + str(mapping["source"]) + for mapping in proposed_mappings + if isinstance(mapping.get("source"), str) + and isinstance(mapping.get("role"), str) + and mapping["role"].startswith("script.") + } + if not script_sources: + return [] + + contract = resolve_project_structure(config, project_root=project_path) + role_roots = dict(contract.roots) + blockers: list[dict[str, Any]] = [] + for source in sorted(script_sources): + try: + script_path = _safe_destination(project_path, source) + result = analyze_dependency_script( + script_path, + script_path=source, + role_roots=role_roots, + ) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + blockers.append( + { + "kind": "dependency_scan_incomplete", + "script": source, + "reason": f"dependency scan failed safely: {type(exc).__name__}", + } + ) + continue + + for reference in result.get("hardcoded_unresolved_references") or []: + blocker = dict(reference) if isinstance(reference, Mapping) else {"reference": str(reference)} + blocker["script"] = source + blockers.append(blocker) + if result.get("dependency_scan_incomplete"): + blockers.append( + { + "kind": "dependency_scan_incomplete", + "script": source, + "reason": "dependency scan could not inspect the complete script safely.", + } + ) + return blockers + + def _propose_normalization_mappings( project_path: Path, *, diff --git a/hub_core/structure_apply.py b/hub_core/structure_apply.py index 0032458..e08a4ad 100644 --- a/hub_core/structure_apply.py +++ b/hub_core/structure_apply.py @@ -509,12 +509,19 @@ def apply_structure_plan( validate_confirmation_token(plan, confirmation_token) if plan.get("version") != PLAN_VERSION: raise ValueError(f"Unsupported structure plan version: {plan.get('version')!r}.") + for field in ("hardcoded_unresolved_references", "unresolved_proposals"): + if field not in plan: + raise ValueError(f"Structure plan is missing the required {field} field for its version.") + if not isinstance(plan[field], list): + raise ValueError(f"Structure plan {field} must be a list.") if plan.get("operation") != "copy_only": raise ValueError("Only copy_only structure plans can be applied.") if plan.get("collisions"): raise FileExistsError("Structure plan contains destination collisions.") if plan.get("hardcoded_unresolved_references"): raise RuntimeError("Structure plan has unresolved hard-coded dependencies.") + if plan.get("unresolved_proposals"): + raise RuntimeError("Structure plan has unresolved normalization proposals.") root = Path(str(plan.get("project_root"))).absolute() try: root_identity = capture_project_root(root) diff --git a/hub_core/structure_plan.py b/hub_core/structure_plan.py index eab393d..34fe85c 100644 --- a/hub_core/structure_plan.py +++ b/hub_core/structure_plan.py @@ -63,6 +63,7 @@ def build_structure_plan( *, config_diff: Iterable[Mapping[str, Any]] = (), hardcoded_unresolved_references: Iterable[object] = (), + unresolved_proposals: Iterable[object] = (), ) -> dict[str, Any]: """Create a deterministic plan solely from explicitly approved mappings.""" @@ -124,6 +125,13 @@ def build_structure_plan( ], key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True), ) + normalized_unresolved_proposals = sorted( + [ + dict(item) if isinstance(item, Mapping) else {"proposal": str(item)} + for item in unresolved_proposals + ], + key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True), + ) config_path = root / "project_config.yaml" config_sha256: str | None = None config_identity: dict[str, int] | None = None @@ -162,6 +170,7 @@ def build_structure_plan( "config_identity": config_identity, "config_update": config_update, "hardcoded_unresolved_references": unresolved, + "unresolved_proposals": normalized_unresolved_proposals, "total_bytes": sum(item["size"] for item in entries), "rollback_journal": rollback, } diff --git a/tests/test_dependency_script_inspection.py b/tests/test_dependency_script_inspection.py new file mode 100644 index 0000000..41378ff --- /dev/null +++ b/tests/test_dependency_script_inspection.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hub_core.dependency_script_inspection import analyze_dependency_script + + +def test_python_imports_and_path_literals_are_explicit_candidates() -> None: + result = analyze_dependency_script( + "import pandas as pd\n" + "from pathlib import Path\n" + "frame = pd.read_csv(\"data/input.csv\")\n", + ".py", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + candidates = result["static_candidates"] + assert any(item["kind"] == "import" and item["path"] == "pandas" for item in candidates) + assert any(item["kind"] == "import" and item["path"] == "pathlib" for item in candidates) + assert [item["path"] for item in candidates if item["kind"] == "path_literal"] == ["data/input.csv"] + assert result["hardcoded_unresolved_references"] + + +def test_r_source_and_read_calls_are_explicit_candidates() -> None: + result = analyze_dependency_script( + 'source("scripts/helper.R")\n' + 'values <- read.csv("data/input.csv")\n', + ".r", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + assert [item["path"] for item in result["static_candidates"]] == [ + "scripts/helper.R", + "data/input.csv", + ] + assert len(result["hardcoded_unresolved_references"]) == 2 + + +def test_suffixless_python_path_api_literals_are_candidates() -> None: + result = analyze_dependency_script( + "from pathlib import Path\n" + "frame = read_csv('input')\n" + "path = Path('workspace')\n" + "handle = open('README')\n", + ".py", + ) + + assert result["dependency_scan_incomplete"] is False + assert {item["path"] for item in result["static_candidates"] if item["kind"] == "path_literal"} == { + "input", + "workspace", + "README", + } + + wrapped = analyze_dependency_script("handle = open(Path('wrapped'))", ".py") + assert wrapped["dependency_scan_incomplete"] is False + assert [item["path"] for item in wrapped["static_candidates"] if item["kind"] == "path_literal"] == [ + "wrapped" + ] + + content = analyze_dependency_script("Path('output').write_text(content_value)", ".py") + assert content["dependency_scan_incomplete"] is False + assert [item["path"] for item in content["static_candidates"] if item["kind"] == "path_literal"] == [ + "output" + ] + + assigned = analyze_dependency_script("input_path = Path('assigned')\nread_csv(input_path)", ".py") + assert assigned["dependency_scan_incomplete"] is False + assert [item["path"] for item in assigned["static_candidates"] if item["kind"] == "path_literal"] == [ + "assigned" + ] + + +def test_suffixless_r_read_and_source_literals_are_candidates() -> None: + result = analyze_dependency_script( + 'source("helper")\n' + 'values <- read.csv("input")\n', + ".r", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + assert [item["path"] for item in result["static_candidates"]] == ["helper", "input"] + assert len(result["hardcoded_unresolved_references"]) == 2 + + +def test_python_path_keyword_variants_remain_unresolved_when_dynamic() -> None: + result = analyze_dependency_script( + "frame = read_csv(filepath_or_buffer=input_path)\n", + ".py", + ) + + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "dynamic_path" + + +def test_python_syntax_failure_fails_closed() -> None: + result = analyze_dependency_script("if True print('broken')", ".py") + + assert result["inspectable"] is False + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "parse_error" + + +def test_r_unbalanced_call_fails_closed() -> None: + result = analyze_dependency_script('values <- read.csv("data/input.csv"', ".r") + + assert result["inspectable"] is False + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "parse_error" + + +def test_dynamic_path_is_incomplete_and_unresolved() -> None: + result = analyze_dependency_script("frame = read_csv(input_path)", ".py") + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "dynamic_path" + + +def test_unmapped_path_is_not_assigned_a_guessed_role() -> None: + result = analyze_dependency_script('frame = read_csv("data/input.csv")', ".py") + + unresolved = result["hardcoded_unresolved_references"] + assert len(unresolved) == 1 + assert unresolved[0]["kind"] == "hardcoded_path" + assert "role mapping" in unresolved[0]["reason"] + + +def test_explicit_role_root_can_resolve_a_literal_without_guessing() -> None: + result = analyze_dependency_script( + 'frame = read_csv("raw/input.csv")', + ".py", + role_roots={"raw": "raw"}, + ) + + assert result["static_candidates"][0]["path"] == "raw/input.csv" + assert result["hardcoded_unresolved_references"] == [] + + +def test_nested_role_roots_resolve_only_the_terminal_declared_role() -> None: + result = analyze_dependency_script( + 'frame = read_csv("hub_scripts/analysis/input")', + ".py", + role_roots={ + "scripts": "hub_scripts", + "analysis_scripts": "hub_scripts/analysis", + }, + ) + + assert result["hardcoded_unresolved_references"] == [] + + +def test_aggregate_role_root_does_not_clear_a_dependency() -> None: + result = analyze_dependency_script( + 'frame = read_csv("hub_scripts/input")', + ".py", + role_roots={"scripts": "hub_scripts"}, + ) + + assert result["hardcoded_unresolved_references"][0]["kind"] == "hardcoded_path" + + +@pytest.mark.parametrize( + ("suffix", "source"), + [ + (".py", "frame = read_csv('https://example.test/input.csv')"), + (".r", 'frame <- read.csv("https://example.test/input.csv")'), + ], +) +def test_remote_path_literals_remain_fail_closed(suffix: str, source: str) -> None: + result = analyze_dependency_script(source, suffix) + + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "external_path" + + +def test_result_is_deterministic() -> None: + source = 'b = read_csv("b.csv")\na = read_csv("a.csv")\n' + + assert analyze_dependency_script(source, ".py") == analyze_dependency_script(source, ".py") + + +def test_unresolved_scanner_output_can_be_handed_to_apply_gate(tmp_path: Path) -> None: + from hub_core.structure_apply import apply_structure_plan + from hub_core.structure_plan import build_structure_plan, confirmation_token + + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_text("x\n1\n", encoding="utf-8") + evidence = analyze_dependency_script('frame = read_csv("legacy/input.csv")', ".py") + plan = build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + hardcoded_unresolved_references=evidence["hardcoded_unresolved_references"], + ) + + with pytest.raises(RuntimeError, match="hard-coded"): + apply_structure_plan(plan, confirmation_token=confirmation_token(plan)) diff --git a/tests/test_phase5_dependency_integration.py b/tests/test_phase5_dependency_integration.py new file mode 100644 index 0000000..ab46e43 --- /dev/null +++ b/tests/test_phase5_dependency_integration.py @@ -0,0 +1,102 @@ +"""Focused Phase 5 coverage for normalization dependency evidence.""" + +from __future__ import annotations + +from pathlib import Path + +from hub_core.project_normalization import plan_normalize_project + + +def _tree_snapshot(root: Path) -> tuple[str, ...]: + return tuple(sorted(path.relative_to(root).as_posix() for path in root.rglob("*"))) + + +def _approved_analysis_mapping() -> list[dict[str, str]]: + return [ + { + "source": "scripts/analysis.py", + "destination": "hub_scripts/analysis/analysis.py", + "role": "script.analysis", + } + ] + + +def test_copy_plan_reports_hardcoded_dependency_for_approved_script(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=_approved_analysis_mapping(), + ) + + blockers = plan["hardcoded_unresolved_references"] + assert any( + blocker.get("kind") == "hardcoded_path" + and blocker.get("path") == "data/input.csv" + and blocker.get("script") == "scripts/analysis.py" + for blocker in blockers + ) + + +def test_adopt_plan_is_read_only_while_previewing_script_dependencies(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + before = _tree_snapshot(tmp_path) + + plan = plan_normalize_project(project_path=tmp_path, move_policy="adopt") + + assert plan["adopt_existing"] is True + assert plan["entries"] == [] + assert plan["proposed_mappings"] + assert _tree_snapshot(tmp_path) == before + + +def test_declared_raw_role_root_resolves_script_data_dependency(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + (tmp_path / "project_config.yaml").write_text( + "structure:\n roots:\n raw: data\n", + encoding="utf-8", + ) + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=_approved_analysis_mapping(), + ) + + assert plan["hardcoded_unresolved_references"] == [] + + +def test_unsupported_script_language_is_a_dependency_scan_blocker(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.jl" + script.parent.mkdir() + script.write_text("CSV.read(\"data/input.csv\")\n", encoding="utf-8") + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=[ + { + "source": "scripts/analysis.jl", + "destination": "hub_scripts/analysis/analysis.jl", + "role": "script.analysis", + } + ], + ) + + assert any( + blocker.get("kind") == "unsupported_language" + and blocker.get("script") == "scripts/analysis.jl" + for blocker in plan["hardcoded_unresolved_references"] + ) + assert any( + blocker.get("kind") == "dependency_scan_incomplete" + and blocker.get("script") == "scripts/analysis.jl" + for blocker in plan["hardcoded_unresolved_references"] + ) diff --git a/tests/test_structure_apply.py b/tests/test_structure_apply.py index d3ed0e0..21855f7 100644 --- a/tests/test_structure_apply.py +++ b/tests/test_structure_apply.py @@ -9,7 +9,7 @@ from hub_core.atomic_no_clobber import AtomicNoClobberUnavailable from hub_core.structure_apply import apply_structure_plan -from hub_core.structure_plan import build_structure_plan, confirmation_token +from hub_core.structure_plan import build_structure_plan, canonical_plan_digest, confirmation_token def _test_no_clobber_move(source: Path, destination: Path) -> None: @@ -42,6 +42,22 @@ def test_copy_apply_requires_matching_token_and_preserves_original(tmp_path: Pat assert (tmp_path / "raw" / "input.csv").read_bytes() == source.read_bytes() +@pytest.mark.parametrize("missing_field", ["hardcoded_unresolved_references", "unresolved_proposals"]) +def test_apply_rejects_v2_plan_missing_scanner_fields(tmp_path: Path, missing_field: str) -> None: + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_bytes(b"original") + plan = _plan(tmp_path) + del plan[missing_field] + plan["digest"] = canonical_plan_digest(plan) + + with pytest.raises(ValueError, match=rf"missing the required {missing_field} field"): + apply_structure_plan(plan, confirmation_token=confirmation_token(plan)) + + assert not (tmp_path / "raw" / "input.csv").exists() + assert source.read_bytes() == b"original" + + def test_apply_rejects_stale_source_collision_and_unresolved_reference(tmp_path: Path) -> None: source = tmp_path / "legacy" / "input.csv" source.parent.mkdir() @@ -67,6 +83,18 @@ def test_apply_rejects_stale_source_collision_and_unresolved_reference(tmp_path: with pytest.raises(RuntimeError, match="hard-coded"): apply_structure_plan(unresolved, confirmation_token=confirmation_token(unresolved)) + unresolved_proposal = build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + unresolved_proposals=[{"source": "notes.md", "reason": "ambiguous"}], + ) + assert unresolved_proposal["unresolved_proposals"] == [{"source": "notes.md", "reason": "ambiguous"}] + with pytest.raises(RuntimeError, match="normalization proposals"): + apply_structure_plan( + unresolved_proposal, + confirmation_token=confirmation_token(unresolved_proposal), + ) + def test_apply_race_never_clobbers_competing_destination(tmp_path: Path, monkeypatch) -> None: source = tmp_path / "legacy" / "input.csv" From eea9f730962bdd9bf0b57c45453fcbf2314a3f53 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 15:13:01 +0900 Subject: [PATCH 08/21] Add host-rooted structure approval authority Keep token and result receipts separate from approval, enforce secure MCP receipt validation, and recheck authority at the mutation boundary. --- docs/ROADMAP.md | 16 +- docs/architecture.md | 16 +- docs/project-structure-contract.md | 28 +- ...roject-structure-runtime-integrity-plan.md | 156 +++++- hub_core/approval_authority.py | 503 ++++++++++++++++++ hub_core/mcp/schemas.py | 16 +- hub_core/mcp/server.py | 35 ++ hub_core/mcp/structure_schemas.py | 163 +++--- hub_core/mcp/tools/project_tools.py | 164 ++++++ hub_core/project_normalization.py | 4 + hub_core/structure_apply.py | 81 +-- hub_core/structure_apply_preflight.py | 137 +++++ tests/test_approval_authority.py | 109 ++++ tests/test_mcp_host_approval.py | 194 +++++++ 14 files changed, 1474 insertions(+), 148 deletions(-) create mode 100644 hub_core/approval_authority.py create mode 100644 hub_core/structure_apply_preflight.py create mode 100644 tests/test_approval_authority.py create mode 100644 tests/test_mcp_host_approval.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f709e1d..a86f5b2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -147,10 +147,18 @@ Current release-candidate checkpoint: `FIGOPS-APPLY-` token; apply requires the identical reviewed inputs and token. The token proves plan integrity and exact replay, not independent human identity or attestation; the current workflow does not - close self-approval. A host-issued `approval_receipt` or immutable - reviewed-plan authority rooted in a host trust root is deferred to Phase 5. - Approval authority remains Phase 5/open until that authority exists and is - consumed by apply. Audit/plan control evidence never becomes a runtime + close self-approval. The Phase 6 host-rooted approval authority contract is + defined in the canonical + [`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract) + and requires a canonical payload, host capability/signature trust proof, + currentness/revocation checks, and fail-closed apply ordering. Approval + is enforced when secure MCP mode sets `require_host_approval: true` and is + backed by the host-owned process-local `ApprovalAuthorityRoot`; the host + receipt is rechecked at the mutation boundary. Default compatibility mode + remains token-only for backward compatibility, so audit/plan control + evidence, LLM JSON, and copy/runtime/durable/evidence receipts are not + approval. The Phase 6/release gate remains open until the production launcher + enables secure mode. Audit/plan control evidence never becomes a runtime manifest or durable result, and runtime remains external to the project. - Structure normalization applies a fail-closed guard: a plan containing any diff --git a/docs/architecture.md b/docs/architecture.md index 28b0d5e..5b8ee08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -377,11 +377,17 @@ requires the identical reviewed inputs and token, and fails closed on stale identity/configuration, collisions, unresolved dependencies, or token mismatch. The token proves integrity and exact replay of that plan; it does not prove an independent human identity, reviewer authority, or attestation, and the current -workflow does not close self-approval. A host-issued `approval_receipt` (or -equivalent immutable reviewed-plan authority) bound to reviewer identity and -the plan digest and rooted in a host trust root is a Phase 5 gap, not a current -capability. Approval authority remains Phase 5/open until that authority exists -and is consumed by apply. +workflow does not close self-approval. The Phase 6 host-rooted approval +authority contract is defined in the canonical +[`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract): +it requires a host capability or signature, canonical bindings, currentness and +revocation checks, and fail-closed apply ordering. Secure MCP mode now enforces +the contract with the host-owned process-local `ApprovalAuthorityRoot`, an +opaque `approval_receipt_id`, and a mutation-boundary recheck. The default +compatibility mode remains token-only for backward compatibility; its token, +LLM JSON, and copy/runtime/durable/evidence receipts are not approval. The +Phase 6/release gate remains open until the production launcher enables secure +mode. During planning, `analyze_dependency_script` output is evidence only. Static imports/path literals are not role approvals; dynamic paths and parse/read or diff --git a/docs/project-structure-contract.md b/docs/project-structure-contract.md index 27a9e39..b1fba4c 100644 --- a/docs/project-structure-contract.md +++ b/docs/project-structure-contract.md @@ -167,12 +167,16 @@ audit output. unresolved-reference list, and `plan_digest`-bound token with `move_policy: copy` and `dry_run: false`. The apply path revalidates source identity, configuration, containment, collisions, and the token before any - copy, then emits the copy receipt and validation result. + copy, then emits the copy receipt and validation result. Secure MCP mode + (`require_host_approval: true`) additionally requires the host-rooted proof + defined in the canonical SSOT; absent that proof, no copy or config write is + authorized. Compatibility mode remains token-only and is not independent + approval. 5. Verify the receipt and validation. Original inputs remain byte-identical; cleanup is separate and user-authorized. A plan, digest, or token is control evidence, not a research result, runtime manifest, or evidence receipt. -### Phase 4 boundary and Phase 5 gap +### Phase 4 boundary and Phase 6 authority contract Phase 4 keeps the mapping policy explicit: only reviewer-supplied `approved_mappings` and typed config edits may enter the copy-only plan. The @@ -182,13 +186,19 @@ human identity, reviewer authority, or an attestation. The current workflow therefore does not close self-approval; reviewer provenance remains an out-of-band policy/process requirement rather than a machine-enforced claim. -The next Phase 5 gap is a host-issued `approval_receipt` (or equivalent -immutable reviewed-plan authority) bound to the plan digest and reviewed -inputs, with verifiable reviewer identity/role, authorization, and attestation -semantics and rooted in a host trust root. Until that authority exists and is -consumed by apply, no scanner output, plan token, unresolved-proposal result, or -copy receipt may be described as independent approval evidence. Approval -authority remains Phase 5/open. +The Phase 6 host-rooted authority contract is normative in the canonical +[runtime-integrity SSOT](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract). +It defines the minimum canonical approval payload, host capability/signature +trust proof, currentness and revocation checks, mutation ordering, and +adversarial acceptance criteria. Secure MCP mode (`require_host_approval: true`) +now supplies the process-local host-owned `ApprovalAuthorityRoot`, requires an +opaque `approval_receipt_id`, and rechecks the receipt at the mutation boundary; +missing or changed authority fails closed. Default compatibility mode remains +token-only for backward compatibility, so its plan token, scanner output, +unresolved-proposal result, or copy/runtime/durable/evidence receipt is not +independent approval evidence. LLM-authored JSON is review input, not host +authority. The Phase 6/release gate remains open until the production launcher +enables secure mode. ## Runtime and durable results diff --git a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md index 2343897..585c002 100644 --- a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md +++ b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md @@ -332,12 +332,164 @@ The token proves integrity and exact replay of the canonical plan, not independent reviewer identity, authority, or attestation; the current workflow does not close self-approval. A host-issued `approval_receipt` or equivalent immutable reviewed-plan authority, bound to reviewer identity/role and the plan -digest and rooted in a host trust root, remains a Phase 5 gap. Approval -authority is Phase 5/open until that authority exists and is consumed by apply. +digest and rooted in a host trust root, is specified by the Phase 6 contract +below. A process-local implementation now exists: the exact host-owned +`ApprovalAuthorityRoot` mints immutable approval records, and secure MCP +normalization (`require_host_approval: true`) verifies the host receipt and +rechecks it at the mutation boundary. The production launcher does not yet +enable that secure mode, so approval authority and the release gate remain +Phase 6/open until production launch is host-approval-enabled. Audit reports, plans, digests, and tokens are control evidence, not runtime manifests, durable results, or evidence receipts; runtime remains externally rooted and disposable. +### Phase 6 host-rooted approval authority contract + +Phase 6 is the normative authority boundary for structure migration. It closes +the self-approval gap without making the planner, an LLM, or a project file an +authority. Secure mode (`require_host_approval: true`) now enforces this +contract: missing/untrusted roots, missing or invalid receipts, stale/revoked +records, binding mismatches, and mutation-boundary revocation fail closed. The +default compatibility mode remains token-only for backward compatibility; its +valid plan and `FIGOPS-APPLY-` token prove replay integrity only +and MUST NOT be described as independent approval. Phase 6 and the release gate +remain open until the production launcher enables secure mode. + +The following prove integrity, provenance, replay, or execution lineage, but do +**not** prove approval or reviewer authority: the `FIGOPS-APPLY-` +token; any source/config/environment provenance; a copy, runtime, durable, or +evidence receipt; an audit report or plan; and any LLM-authored JSON field such +as `approved`, `reviewer`, or `authorization`. These values may be inputs to +review, but none can authorize mutation when presented by the planner, model, +project, or runtime filesystem. + +#### Minimum canonical approval payload + +The host approval is a canonical `figops_approval/1` payload. Its signed or +capability-bound bytes include every field below (empty lists are still hashed, +not omitted): + +```json +{ + "schema": "figops_approval/1", + "receipt_id": "", + "plan_digest": "", + "project_root_identity": "", + "config_identity": { + "relative_path": "project_config.yaml", + "sha256": "" + }, + "approved_mappings_digest": "", + "config_diff_digest": "", + "unresolved_digest": "", + "reviewer": { + "subject": "", + "role": "" + }, + "issued_at": "", + "expires_at": "", + "currentness": { + "state": "current", + "checked_at": "", + "revocation_epoch": "" + }, + "revocation": { + "state": "not_revoked", + "checked_at": "" + } +} +``` + +The canonicalization rules are deterministic (UTF-8 JSON, sorted object keys, +no insignificant whitespace, fixed digest encoding). `project_root_identity` +is derived from the launcher-resolved, normalized root and its stable filesystem +identity; it is not accepted from project configuration. `config_identity` is +the reviewed config path relative to that root plus its exact bytes; for a +config-less legacy project, an explicit `null` identity is bound into the plan +and cannot be supplied later by the model. The three +component digests bind the exact mappings, typed compare-and-swap config edits, +and unresolved-reference set used to compute `plan_digest`; changing order, +content, or even an empty-versus-omitted value changes the digest. Reviewer +subject and role are assertions only when the host trust policy authorizes them. +The process-local implementation stores the unresolved component as separate +digests for `hardcoded_unresolved_references` and `unresolved_proposals` (plus +the reviewed-entry digest); these are the concrete encoding of the minimum +`unresolved_digest` binding and are all rechecked. + +The payload MUST be accompanied by one of these out-of-band authority proofs: + +1. a host capability handle resolved and consumed through a launcher/host + authority channel, with the host returning the canonical payload and its + current, non-revoked status; or +2. a host signature over the canonical payload, verified against a + launcher/operator-pinned trust root and key identifier (for example, + `trust_root_id`, `key_id`, `algorithm`, and detached `signature`). + +The current secure MCP implementation uses the first form through a +process-local `ApprovalAuthorityRoot`: the root object is supplied by the host +at server construction, cannot be copied, and is required by object identity +when `verify_approval_authority` checks an `approval_receipt_id`. The secure +`figops.normalize_project_structure` schema accepts only that opaque receipt +ID; self-described approval JSON is rejected. The default compatibility +constructor leaves `require_host_approval` false and therefore intentionally +retains token-only behavior; compatibility apply is not an independent approval +path and must not be presented as one. + +Trust roots, capability validation, reviewer-role policy, revocation state, and +currentness are host/operator state. They MUST NOT be supplied by the LLM, +project config, plan JSON, runtime manifest, or a durable/evidence receipt. A +missing, unknown, malformed, expired, revoked, stale, or unverifiable proof +fails closed. An LLM response that contains the same JSON without a host proof +is untrusted data, even if it says `approved: true` or reproduces a valid token. + +#### Apply ordering and revalidation + +Apply performs these steps in order, with no file or config mutation before step +4 succeeds: + +1. Parse the reviewed plan and host payload; reject non-canonical or + self-described authority fields. +2. Recompute `plan_digest`, `approved_mappings_digest`, `config_diff_digest`, + and `unresolved_digest`; verify the `FIGOPS-APPLY-` token, + project-root identity, config identity, containment, and all stale/collision + and unresolved-dependency guards. +3. Verify the host capability or signature against the pinned trust root, + enforce the reviewer role, and require `issued_at <= now < expires_at` plus + host-confirmed current, non-revoked `receipt_id`/`revocation_epoch`. +4. Acquire the apply transaction lease and repeat the identity, digest, and + host-currentness checks at the mutation boundary. A capability is consumed + according to host policy; a revoked, expired, or otherwise changed approval + aborts before staging. +5. Execute the existing copy-only transaction: destination-filesystem sibling + staging, containment/hash verification, fsync where supported, native + same-filesystem no-replace publication, and typed config CAS. Revalidate + approval currentness before any subsequent mutation, then emit a copy + receipt that records the approval `receipt_id` as lineage, never as a + replacement for the approval proof. + +#### Adversarial acceptance criteria + +In secure MCP mode, a Phase 6 implementation must fail closed on the following +cases before any copy or config write. Compatibility mode may retain its +historical token-only behavior for backward compatibility, but it must expose no +host-approval status and must never describe that behavior as independent +approval or as satisfying the Phase 6/release gate. + +| Adversarial input | Required result | +| --- | --- | +| Valid plan/token/provenance or durable/runtime/evidence receipt but no host proof | Secure mode rejects as unauthorised with no mutation; compatibility mode may follow its legacy token-only path but cannot claim approval. | +| LLM JSON containing `approved: true`, reviewer fields, or a forged receipt | Reject; model/project data is not an authority. | +| Signature over a payload whose plan, root, config, mappings, diff, unresolved set, reviewer, or validity window changed | Reject signature/digest mismatch. | +| Approval for another project root, config hash, plan digest, or mapping/diff/unresolved digest | Reject binding mismatch. | +| Unknown trust root/key, malformed capability, missing host policy, or unavailable authority channel | Reject and fail closed. | +| Expired, revoked, stale, non-current, or replayed one-time capability | Reject before staging; preserve all existing files. | +| Source/config identity, collision, unresolved dependency, or root containment changes after approval | Recompute and reject before mutation. | +| Revocation or expiry races after preflight | Mutation-boundary/currentness recheck aborts before the affected mutation. | + +The focused adversarial suite MUST exercise each row with both CLI and MCP apply +surfaces where available, and must verify byte-identical originals, no partial +config rewrite, and no destination clobber on every rejection. + Low-confidence, multi-role, content-sensitive, unreferenced, and collision cases stay unresolved. Extension-only classification may not cross a role boundary. Any hard-coded script/import/config dependency that cannot be represented as a diff --git a/hub_core/approval_authority.py b/hub_core/approval_authority.py new file mode 100644 index 0000000..76d8739 --- /dev/null +++ b/hub_core/approval_authority.py @@ -0,0 +1,503 @@ +"""Process-local approval authority for reviewed structure plans. + +This module deliberately keeps approval authority separate from runtime, +durable-result, and evidence receipts. A record is only authoritative when it +was minted by the exact host-owned :class:`ApprovalAuthorityRoot` supplied to +the verifier. Copying a record to a mapping (or serialising it to JSON) never +creates authority. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from types import MappingProxyType +from typing import Any, Mapping, NoReturn + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_RECEIPT_ID_RE = re.compile(r"^approval:sha256:[0-9a-f]{64}$") +_UTC_TIMESTAMP_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + + +class ApprovalAuthorityError(ValueError): + """Raised when an approval authority contract cannot be constructed.""" + + +def _fail(message: str) -> NoReturn: + raise ApprovalAuthorityError(f"approval authority {message}") + + +def _jsonable(value: Any) -> Any: + """Convert immutable values back to plain deterministic JSON values.""" + + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + _fail("JSON object keys must be strings") + result[key] = _jsonable(child) + return result + if isinstance(value, tuple): + return [_jsonable(child) for child in value] + if isinstance(value, list): + return [_jsonable(child) for child in value] + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def _freeze_json(value: Any) -> Any: + """Deep-freeze JSON data so records cannot be changed through aliases.""" + + if isinstance(value, Mapping): + result = {key: _freeze_json(child) for key, child in value.items()} + if any(not isinstance(key, str) for key in result): + _fail("JSON object keys must be strings") + return MappingProxyType(result) + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(child) for child in value) + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def canonical_json_bytes(value: Any) -> bytes: + """Return deterministic UTF-8 JSON bytes for a JSON-compatible value.""" + + try: + return json.dumps( + _jsonable(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise ApprovalAuthorityError(f"JSON canonicalization failed: {exc}") from exc + + +def _sha256_bytes(value: Any) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _valid_digest(value: Any, field: str) -> str: + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + _fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def _text(value: Any, field: str, *, max_length: int = 512) -> str: + if not isinstance(value, str) or not value or value != value.strip() or len(value) > max_length: + _fail(f"{field} must be a non-empty canonical string") + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): + _fail(f"{field} may not contain control characters") + return value + + +def _timestamp(value: Any, field: str) -> str: + if isinstance(value, datetime): + if value.tzinfo is None or value.utcoffset() is None: + _fail(f"{field} must be timezone-aware") + value = value.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + if not isinstance(value, str) or _UTC_TIMESTAMP_RE.fullmatch(value) is None: + _fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError as exc: + raise ApprovalAuthorityError(f"approval authority {field} must be a real timestamp") from exc + return value + + +def _parse_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def canonical_plan_digest(plan: Mapping[str, Any]) -> str: + """Hash a plan excluding its self-referential ``digest`` fields. + + Structure plans use ``digest``; callers using ``plan_digest`` receive the + same treatment. A declared digest, when present, must match this value. + """ + + if not isinstance(plan, Mapping): + _fail("plan must be a mapping") + payload = {key: value for key, value in plan.items() if key not in {"digest", "plan_digest"}} + return _sha256_bytes(payload) + + +def _checked_plan_digest(plan: Mapping[str, Any]) -> str: + digest = canonical_plan_digest(plan) + for field in ("digest", "plan_digest"): + if field in plan and plan[field] != digest: + _fail(f"plan {field} is stale or invalid") + return digest + + +def _field_digest(plan: Mapping[str, Any], *fields: str) -> str: + for field in fields: + if field in plan: + return _sha256_bytes(plan[field]) + # Omission is distinct from an explicitly reviewed empty list. + return _sha256_bytes({"__missing_field__": fields[0]}) + + +def _identity(plan: Mapping[str, Any], field: str, *, allow_none: bool = False) -> Any: + value = plan.get(field) + if value is None and allow_none: + return None + if value is None: + _fail(f"plan {field} is required") + return _freeze_json(value) + + +@dataclass(frozen=True, slots=True) +class ApprovalBinding: + """Immutable canonical binding for one host-issued approval.""" + + plan_digest: str + project_root_identity: Any + config_sha256: str | None + config_identity: Any + reviewed_entries_digest: str + approved_mappings_digest: str + config_diff_digest: str + hardcoded_unresolved_references_digest: str + unresolved_proposals_digest: str + reviewer_identity: str + reviewer_role: str + issued_at: str + expires_at: str + current: bool = True + revoked: bool = False + superseded: bool = False + superseded_by: str | None = None + supersedes: str | None = None + + def canonical_payload(self) -> dict[str, Any]: + return { + "plan_digest": self.plan_digest, + "project_root_identity": _jsonable(self.project_root_identity), + "config_sha256": self.config_sha256, + "config_identity": _jsonable(self.config_identity), + "reviewed_entries_digest": self.reviewed_entries_digest, + "approved_mappings_digest": self.approved_mappings_digest, + "config_diff_digest": self.config_diff_digest, + "hardcoded_unresolved_references_digest": self.hardcoded_unresolved_references_digest, + "unresolved_proposals_digest": self.unresolved_proposals_digest, + "reviewer_identity": self.reviewer_identity, + "reviewer_role": self.reviewer_role, + "issued_at": self.issued_at, + "expires_at": self.expires_at, + "current": self.current, + "revoked": self.revoked, + "superseded": self.superseded, + "superseded_by": self.superseded_by, + "supersedes": self.supersedes, + } + + +def canonical_approval_binding_bytes(binding: ApprovalBinding) -> bytes: + if not isinstance(binding, ApprovalBinding): + _fail("binding must be an ApprovalBinding") + return canonical_json_bytes(binding.canonical_payload()) + + +def approval_binding_digest(binding: ApprovalBinding) -> str: + return hashlib.sha256(canonical_approval_binding_bytes(binding)).hexdigest() + + +@dataclass(frozen=True, slots=True, init=False) +class ApprovalRecord: + """Immutable approval record minted by an authority root.""" + + receipt_id: str + binding: ApprovalBinding + _authority_owner: object + + def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - public construction is forbidden + raise TypeError("ApprovalRecord instances must be issued by ApprovalAuthorityRoot") + + @classmethod + def _mint(cls, owner: object, receipt_id: str, binding: ApprovalBinding) -> "ApprovalRecord": + record = object.__new__(cls) + object.__setattr__(record, "receipt_id", receipt_id) + object.__setattr__(record, "binding", binding) + object.__setattr__(record, "_authority_owner", owner) + return record + + @property + def plan_digest(self) -> str: + return self.binding.plan_digest + + @property + def current(self) -> bool: + return self.binding.current + + @property + def revoked(self) -> bool: + return self.binding.revoked + + @property + def superseded(self) -> bool: + return self.binding.superseded + + @property + def canonical_digest(self) -> str: + return approval_binding_digest(self.binding) + + +@dataclass(frozen=True, slots=True) +class ApprovalVerificationResult: + """Fail-closed result returned by :func:`verify_approval_authority`.""" + + valid: bool + reason: str + receipt_id: str | None = None + record: ApprovalRecord | None = None + + def __bool__(self) -> bool: + return self.valid + + +class _AuthorityState: + """Mutable lifecycle index entry; never exposed as an authority value.""" + + __slots__ = ("record", "current", "revoked", "superseded_by") + + def __init__(self, record: ApprovalRecord) -> None: + self.record = record + self.current = True + self.revoked = False + self.superseded_by: str | None = None + + +class ApprovalAuthorityRoot: + """Host-owned, process-local trust root and approval index.""" + + __slots__ = ("_records",) + + def __init__(self) -> None: + self._records: dict[str, _AuthorityState] = {} + + def __copy__(self) -> "ApprovalAuthorityRoot": # pragma: no cover - defensive boundary + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def __deepcopy__(self, memo: dict[int, Any]) -> "ApprovalAuthorityRoot": # pragma: no cover + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def issue_approval( + self, + plan: Mapping[str, Any], + *, + reviewer_identity: str, + reviewer_role: str, + issued_at: str | datetime, + expires_at: str | datetime, + supersedes: str | None = None, + ) -> ApprovalRecord: + plan_digest = _checked_plan_digest(plan) + project_root_identity = _identity(plan, "project_root_identity") + config_identity = _identity(plan, "config_identity", allow_none=True) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + reviewer_identity = _text(reviewer_identity, "reviewer_identity") + reviewer_role = _text(reviewer_role, "reviewer_role", max_length=128) + issued_at = _timestamp(issued_at, "issued_at") + expires_at = _timestamp(expires_at, "expires_at") + if _parse_timestamp(expires_at) <= _parse_timestamp(issued_at): + _fail("expires_at must be later than issued_at") + supersedes = None if supersedes is None else _text(supersedes, "supersedes", max_length=80) + if supersedes is not None: + if _RECEIPT_ID_RE.fullmatch(supersedes) is None: + _fail("supersedes must be an approval receipt id") + old = self._records.get(supersedes) + if old is None: + _fail("supersedes references an unknown receipt id") + if old.revoked: + _fail("a revoked approval cannot be superseded") + if not old.current: + _fail("only a current approval can be superseded") + + binding = ApprovalBinding( + plan_digest=plan_digest, + project_root_identity=project_root_identity, + config_sha256=config_sha256, + config_identity=config_identity, + reviewed_entries_digest=_field_digest(plan, "reviewed_entries", "entries"), + approved_mappings_digest=_field_digest(plan, "approved_mappings"), + config_diff_digest=_field_digest(plan, "config_diff"), + hardcoded_unresolved_references_digest=_field_digest(plan, "hardcoded_unresolved_references"), + unresolved_proposals_digest=_field_digest(plan, "unresolved_proposals"), + reviewer_identity=reviewer_identity, + reviewer_role=reviewer_role, + issued_at=issued_at, + expires_at=expires_at, + supersedes=supersedes, + ) + receipt_id = f"approval:sha256:{approval_binding_digest(binding)}" + if receipt_id in self._records: + _fail("an approval with the same immutable binding already exists") + record = ApprovalRecord._mint(self, receipt_id, binding) + self._records[receipt_id] = _AuthorityState(record) + if supersedes is not None: + old = self._records[supersedes] + old.current = False + old.superseded_by = receipt_id + return record + + def issue(self, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + """Short alias for :meth:`issue_approval`.""" + + return self.issue_approval(plan, **kwargs) + + def revoke(self, receipt_id: str) -> None: + state = self._state_for(receipt_id) + if state.revoked: + return + state.revoked = True + state.current = False + + def revoke_approval(self, receipt_id: str) -> None: + self.revoke(receipt_id) + + def supersede(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + kwargs["supersedes"] = receipt_id + return self.issue_approval(plan, **kwargs) + + def supersede_approval(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + return self.supersede(receipt_id, plan, **kwargs) + + def get(self, receipt_id: str) -> ApprovalRecord | None: + state = self._records.get(receipt_id) + return None if state is None else state.record + + def lookup(self, receipt_id: str) -> ApprovalRecord | None: + """Return an immutable record snapshot without exposing the index.""" + + return self.get(receipt_id) + + def records(self) -> tuple[ApprovalRecord, ...]: + return tuple(state.record for state in self._records.values()) + + def _state_for(self, receipt_id: str) -> _AuthorityState: + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + _fail("receipt_id must be an approval:sha256: identifier") + state = self._records.get(receipt_id) + if state is None: + _fail("unknown receipt_id") + return state + + def _lookup(self, receipt_id: str) -> _AuthorityState | None: + if not isinstance(receipt_id, str): + return None + return self._records.get(receipt_id) + + +def _result( + valid: bool, + reason: str, + receipt_id: str | None, + record: ApprovalRecord | None = None, +) -> ApprovalVerificationResult: + return ApprovalVerificationResult(valid, reason, receipt_id, record) + + +def verify_approval_authority( + plan: Mapping[str, Any], + receipt_id: str, + trusted_root: ApprovalAuthorityRoot | None = None, + now: str | datetime | None = None, +) -> ApprovalVerificationResult: + """Verify an approval against an exact host-owned root and current plan.""" + + if type(trusted_root) is not ApprovalAuthorityRoot: + return _result(False, "missing_or_untrusted_root", receipt_id if isinstance(receipt_id, str) else None) + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + return _result(False, "missing_or_invalid_receipt_id", receipt_id if isinstance(receipt_id, str) else None) + state = trusted_root._lookup(receipt_id) + if state is None: + return _result(False, "unknown_receipt_id", receipt_id) + record = state.record + if record._authority_owner is not trusted_root: + return _result(False, "untrusted_record", receipt_id, record) + if not isinstance(plan, Mapping): + return _result(False, "invalid_plan", receipt_id, record) + try: + plan_digest = canonical_plan_digest(plan) + if any(field in plan and plan[field] != plan_digest for field in ("digest", "plan_digest")): + return _result(False, "plan_digest_mismatch", receipt_id, record) + if plan_digest != record.binding.plan_digest: + return _result(False, "plan_digest_mismatch", receipt_id, record) + if _freeze_json(plan.get("project_root_identity")) != record.binding.project_root_identity: + return _result(False, "project_root_identity_mismatch", receipt_id, record) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + if config_sha256 != record.binding.config_sha256: + return _result(False, "config_sha256_mismatch", receipt_id, record) + if _freeze_json(plan.get("config_identity")) != record.binding.config_identity: + return _result(False, "config_identity_mismatch", receipt_id, record) + checks = ( + ("reviewed_entries_digest", _field_digest(plan, "reviewed_entries", "entries")), + ("approved_mappings_digest", _field_digest(plan, "approved_mappings")), + ("config_diff_digest", _field_digest(plan, "config_diff")), + ( + "hardcoded_unresolved_references_digest", + _field_digest(plan, "hardcoded_unresolved_references"), + ), + ("unresolved_proposals_digest", _field_digest(plan, "unresolved_proposals")), + ) + for field, actual in checks: + if actual != getattr(record.binding, field): + return _result(False, f"{field}_mismatch", receipt_id, record) + check_time = ( + _parse_timestamp(_timestamp(now, "now")) + if now is not None + else datetime.now(UTC).replace(microsecond=0) + ) + if check_time < _parse_timestamp(record.binding.issued_at): + return _result(False, "issued_in_future", receipt_id, record) + if check_time >= _parse_timestamp(record.binding.expires_at): + return _result(False, "expired", receipt_id, record) + except (ApprovalAuthorityError, TypeError, ValueError): + return _result(False, "invalid_plan", receipt_id, record) + if state.revoked or record.binding.revoked: + return _result(False, "revoked", receipt_id, record) + if state.superseded_by is not None or record.binding.superseded: + return _result(False, "superseded", receipt_id, record) + if not state.current or not record.binding.current: + return _result(False, "not_current", receipt_id, record) + return _result(True, "valid", receipt_id, record) + + +verify_approval = verify_approval_authority +ApprovalAuthority = ApprovalAuthorityRoot + + +__all__ = [ + "ApprovalAuthority", + "ApprovalAuthorityError", + "ApprovalAuthorityRoot", + "ApprovalBinding", + "ApprovalRecord", + "ApprovalVerificationResult", + "approval_binding_digest", + "canonical_approval_binding_bytes", + "canonical_json_bytes", + "canonical_plan_digest", + "verify_approval", + "verify_approval_authority", +] diff --git a/hub_core/mcp/schemas.py b/hub_core/mcp/schemas.py index fe89ff3..6f85282 100644 --- a/hub_core/mcp/schemas.py +++ b/hub_core/mcp/schemas.py @@ -110,7 +110,10 @@ def describe_figops_surface() -> dict[str, Any]: def list_tool_definitions( - *, profile: str | None = None, write_tools_enabled: bool | None = None + *, + profile: str | None = None, + write_tools_enabled: bool | None = None, + require_host_approval: bool = False, ) -> list[dict[str, Any]]: supported_render_plot_types = _supported_render_plot_types() root_arg = {"type": "string", "description": "Project scan root. Defaults to FigOps research root."} @@ -645,7 +648,7 @@ def list_tool_definitions( } ), ), - build_normalize_project_structure_definition(), + build_normalize_project_structure_definition(include_host_approval=require_host_approval), ToolDefinition( "figops.batch_check", "Run a bounded project discovery and validation batch check with optional runtime manifest logging.", @@ -682,6 +685,15 @@ def list_tool_definitions( ), ] serialized = [definition.to_dict() for definition in definitions] + normalize_definition = next( + (definition for definition in serialized if definition["name"] == "figops.normalize_project_structure"), + None, + ) + if normalize_definition is not None and require_host_approval: + normalize_definition["description"] = ( + "Propose migration mappings or apply an explicitly reviewed copy-only structure plan. " + "Non-dry-run copy apply requires host-issued approval in this secure mode." + ) if profile is None: return serialized return select_tool_definitions(serialized, profile=profile, write_tools_enabled=bool(write_tools_enabled)) diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index 0c2f9e1..c7af484 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Callable +from hub_core.approval_authority import ApprovalAuthorityRoot from hub_core.path_identity import canonical_path, canonical_relative_to from hub_core.redaction import redact_secrets, redact_text @@ -75,9 +76,41 @@ def __init__( write_tools_enabled: bool | None = None, surface_profile: str | None = None, require_initialize: bool = False, + require_host_approval: bool = False, + host_authority_root: ApprovalAuthorityRoot | None = None, + host_authority_index: ApprovalAuthorityRoot | None = None, + # Explicit aliases keep the trust channel discoverable to callers + # using the approval-domain terminology. These are constructor-only + # values and are never loaded from tool arguments, project files, or + # environment variables. + approval_authority_root: ApprovalAuthorityRoot | None = None, + approval_authority_index: ApprovalAuthorityRoot | None = None, + host_authority: ApprovalAuthorityRoot | None = None, + approval_authority: ApprovalAuthorityRoot | None = None, ) -> None: self.require_initialize = require_initialize self.initialized = False + authority_candidates = [ + candidate + for candidate in ( + host_authority_root, + host_authority_index, + approval_authority_root, + approval_authority_index, + host_authority, + approval_authority, + ) + if candidate is not None + ] + if authority_candidates and any( + candidate is not authority_candidates[0] for candidate in authority_candidates[1:] + ): + raise ValueError("Only one identical host approval authority root/index may be supplied.") + self.host_authority_root = authority_candidates[0] if authority_candidates else None + self.host_authority_index = self.host_authority_root + self.approval_authority_root = self.host_authority_root + self.approval_authority_index = self.host_authority_root + self.require_host_approval = bool(require_host_approval) if config is None: resolved_config = McpServerConfig.from_env() elif isinstance(config, McpServerConfig): @@ -102,6 +135,7 @@ def list_tool_definitions(self) -> list[dict[str, Any]]: return schema_list_tool_definitions( profile=self.surface_profile, write_tools_enabled=self.write_tools_enabled, + require_host_approval=self.require_host_approval, ) def callable_tool_definitions(self) -> list[dict[str, Any]]: @@ -115,6 +149,7 @@ def callable_tool_definitions(self) -> list[dict[str, Any]]: return schema_list_tool_definitions( profile=self.surface_profile, write_tools_enabled=True, + require_host_approval=self.require_host_approval, ) @staticmethod diff --git a/hub_core/mcp/structure_schemas.py b/hub_core/mcp/structure_schemas.py index 0aceb5f..aebfcff 100644 --- a/hub_core/mcp/structure_schemas.py +++ b/hub_core/mcp/structure_schemas.py @@ -89,84 +89,99 @@ def build_project_structure_schemas() -> tuple[ ) -def build_normalize_project_structure_definition() -> ToolDefinition: +def build_normalize_project_structure_definition(*, include_host_approval: bool = False) -> ToolDefinition: """Build the stable normalization tool contract.""" - return ToolDefinition( - "figops.normalize_project_structure", - "Propose migration mappings or apply an explicitly reviewed copy-only structure plan.", - object_schema( - { - "project_path": {"type": "string"}, - "dry_run": { - "type": "boolean", - "default": True, - "description": ( - "Preview without writing files. Defaults True like scaffold_project and " - "batch_check; the two render tools default dry_run False." - ), - }, - "move_policy": { - "type": "string", - "enum": ["adopt", "copy", "move", "symlink"], - "default": "adopt", - "description": ( - "adopt returns read-only proposals; copy requires approved_mappings. " - "move and symlink remain accepted only to return a stable deprecation error." - ), - }, - "include_raw": {"type": "boolean", "default": False}, - "overwrite": { - "type": "boolean", - "default": False, - "description": "Deprecated compatibility argument; true always fails closed.", - }, - "approved_mappings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "source": {"type": "string"}, - "destination": {"type": "string"}, - "role": {"type": "string"}, - }, - "required": ["source", "destination", "role"], - "additionalProperties": False, - }, - "description": "Explicit mappings accepted by the user after reviewing an adopt proposal.", - }, - "config_diff": { - "type": "array", - "items": {"type": "object"}, - "description": "Reviewed typed project_config.yaml compare-and-swap edits.", - }, - "hardcoded_unresolved_references": { - "type": "array", - "items": {}, - "description": "Unresolved dependencies that intentionally block apply.", - }, - "confirmation_token": { - "type": "string", - "description": "Exact token returned by the reviewed copy-only dry-run.", + input_properties: dict[str, Any] = { + "project_path": {"type": "string"}, + "dry_run": { + "type": "boolean", + "default": True, + "description": ( + "Preview without writing files. Defaults True like scaffold_project and " + "batch_check; the two render tools default dry_run False." + ), + }, + "move_policy": { + "type": "string", + "enum": ["adopt", "copy", "move", "symlink"], + "default": "adopt", + "description": ( + "adopt returns read-only proposals; copy requires approved_mappings. " + "move and symlink remain accepted only to return a stable deprecation error." + ), + }, + "include_raw": {"type": "boolean", "default": False}, + "overwrite": { + "type": "boolean", + "default": False, + "description": "Deprecated compatibility argument; true always fails closed.", + }, + "approved_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": {"type": "string"}, + "destination": {"type": "string"}, + "role": {"type": "string"}, }, + "required": ["source", "destination", "role"], + "additionalProperties": False, }, - required=["project_path"], - ), - standard_output_schema( + "description": "Explicit mappings accepted by the user after reviewing an adopt proposal.", + }, + "config_diff": { + "type": "array", + "items": {"type": "object"}, + "description": "Reviewed typed project_config.yaml compare-and-swap edits.", + }, + "hardcoded_unresolved_references": { + "type": "array", + "items": {}, + "description": "Unresolved dependencies that intentionally block apply.", + }, + "confirmation_token": { + "type": "string", + "description": "Exact token returned by the reviewed copy-only dry-run.", + }, + } + output_properties: dict[str, Any] = { + "project_root": {"type": "string"}, + "planned_paths": {"type": "array", "items": {"type": "string"}}, + "manifest": {"type": "object"}, + "config_path": {"type": "string"}, + "style_summary": {"type": "object"}, + "validation": {"type": "object"}, + "proposed_mappings": {"type": "array", "items": {"type": "object"}}, + "unresolved_proposals": {"type": "array", "items": {"type": "object"}}, + "plan_digest": {"type": "string"}, + "confirmation_token": {"type": "string"}, + "originals_preserved": {"type": "boolean"}, + "rollback_journal": {"type": "object"}, + "provenance_receipt": {"type": "object"}, + } + if include_host_approval: + input_properties["approval_receipt_id"] = { + "type": "string", + "description": ( + "Host-issued approval receipt id resolved out-of-band from the trusted authority root. " + "Never provide approval JSON or reviewer fields in tool arguments." + ), + } + output_properties.update( { - "project_root": {"type": "string"}, - "planned_paths": {"type": "array", "items": {"type": "string"}}, - "manifest": {"type": "object"}, - "config_path": {"type": "string"}, - "style_summary": {"type": "object"}, - "validation": {"type": "object"}, - "proposed_mappings": {"type": "array", "items": {"type": "object"}}, - "unresolved_proposals": {"type": "array", "items": {"type": "object"}}, - "plan_digest": {"type": "string"}, - "confirmation_token": {"type": "string"}, - "originals_preserved": {"type": "boolean"}, - "rollback_journal": {"type": "object"}, - "provenance_receipt": {"type": "object"}, + "approval_receipt_id": {"type": ["string", "null"]}, + "host_approval_required": {"type": "boolean"}, + "approval_status": { + "type": "string", + "enum": ["not_required", "required", "verified", "rejected"], + }, } - ), + ) + return ToolDefinition( + "figops.normalize_project_structure", + "Propose migration mappings or apply an explicitly reviewed copy-only structure plan.", + object_schema(input_properties, required=["project_path"]), + standard_output_schema(output_properties), ) diff --git a/hub_core/mcp/tools/project_tools.py b/hub_core/mcp/tools/project_tools.py index c7bbc38..12ebde3 100644 --- a/hub_core/mcp/tools/project_tools.py +++ b/hub_core/mcp/tools/project_tools.py @@ -1,10 +1,14 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any +from hub_core.approval_authority import verify_approval_authority from hub_core.project_normalization import ( NORMALIZATION_CONFIRMATION_REQUIRED, + NORMALIZATION_HOST_APPROVAL_REJECTED, + NORMALIZATION_HOST_APPROVAL_REQUIRED, NORMALIZATION_OVERWRITE_DISABLED, NORMALIZATION_PLAN_REJECTED, NORMALIZATION_POLICY_DEPRECATED, @@ -17,6 +21,62 @@ from hub_core.structure_plan import confirmation_token as structure_confirmation_token +class _HostApprovalRejected(PermissionError): + """Raised when mutation-boundary host approval revalidation fails.""" + + +_HOST_AUTHORITY_ARGUMENT_KEYS = frozenset( + { + "approval", + "approval_json", + "approval_payload", + "approval_record", + "approval_receipt", + "approval_status", + "algorithm", + "attestation", + "approved", + "authority", + "authority_index", + "authority_root", + "authorization", + "capability", + "capability_handle", + "currentness", + "host_approval", + "host_approval_receipt", + "host_authority", + "host_authorization", + "key_id", + "receipt", + "receipt_id", + "reviewer", + "review_record", + "reviewer_identity", + "reviewer_role", + "revocation", + "revocation_epoch", + "signature", + "trust_root", + "trust_root_id", + } +) + + +def _self_described_authority_keys(arguments: dict[str, Any]) -> list[str]: + def canonical_key(key: object) -> str: + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip()).replace("-", "_").casefold() + + return sorted( + { + str(key) + for key in arguments + if canonical_key(key) in _HOST_AUTHORITY_ARGUMENT_KEYS + }, + key=str.casefold, + ) + + class McpProjectToolsMixin: """Project scaffold and normalization MCP tool handlers.""" @@ -190,6 +250,37 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An "plan_digest": manifest["digest"], "confirmation_token": token, } + if self.require_host_approval: + common.update( + { + "host_approval_required": True, + "approval_receipt_id": ( + arguments.get("approval_receipt_id") + if isinstance(arguments.get("approval_receipt_id"), str) + else None + ), + "approval_status": "required", + } + ) + forbidden_authority_keys = _self_described_authority_keys(arguments) + if forbidden_authority_keys: + common["approval_status"] = "rejected" + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Self-described approval fields are not a host authority.", + errors=[ + "Tool arguments may contain only approval_receipt_id; rejected authority fields: " + + ", ".join(forbidden_authority_keys) + + "." + ], + manual_review_needed=True, + is_dry_run=dry_run, + error_category="validation", + error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, + **common, + ) if dry_run and move_policy == "adopt": return self._envelope( "figops.normalize_project_structure", @@ -234,6 +325,54 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An error_code=NORMALIZATION_CONFIRMATION_REQUIRED, **common, ) + if self.require_host_approval: + approval_receipt_id = arguments.get("approval_receipt_id") + verification = verify_approval_authority( + manifest, + approval_receipt_id, + self.host_authority_root, + ) + if not verification.valid: + reason = verification.reason.replace("_", " ") + common["approval_status"] = "rejected" + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Host approval is required before normalization apply.", + errors=[f"Host approval receipt was rejected: {reason}."], + manual_review_needed=True, + is_dry_run=False, + error_category="validation", + error_code=( + NORMALIZATION_HOST_APPROVAL_REQUIRED + if verification.reason + in { + "missing_or_untrusted_root", + "missing_trusted_root", + "untrusted_root", + "missing_or_invalid_receipt_id", + } + else NORMALIZATION_HOST_APPROVAL_REJECTED + ), + **common, + ) + common["approval_status"] = "verified" + pre_apply_verifier = None + if self.require_host_approval: + def _revalidate_host_approval(_root: Path, current_plan: dict[str, Any]) -> None: + boundary_verification = verify_approval_authority( + current_plan, + approval_receipt_id, + self.host_authority_root, + ) + if not boundary_verification.valid: + raise _HostApprovalRejected( + "Host approval changed before mutation boundary: " + f"{boundary_verification.reason.replace('_', ' ')}." + ) + + pre_apply_verifier = _revalidate_host_approval try: self._resolve_execution_project_path(arguments.get("project_path")) except ValueError as exc: @@ -254,6 +393,21 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An manifest, hub_path=self.hub_path, confirmation_token=supplied_token, + pre_apply_verifier=pre_apply_verifier, + ) + except _HostApprovalRejected as exc: + common["approval_status"] = "rejected" + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Host approval was rejected at the mutation boundary.", + errors=[str(exc)], + manual_review_needed=True, + is_dry_run=False, + error_category="validation", + error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, + **common, ) except (FileExistsError, OSError, PermissionError, RuntimeError, ValueError) as exc: return self._envelope( @@ -270,6 +424,15 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An ) validation = self._validation_summary(config_path) validation_failed = validation.get("checked") is True and validation.get("valid") is False + approval_response_fields = ( + { + "approval_receipt_id": common["approval_receipt_id"], + "host_approval_required": common["host_approval_required"], + "approval_status": common["approval_status"], + } + if self.require_host_approval + else {} + ) return self._envelope( "figops.normalize_project_structure", arguments, @@ -296,6 +459,7 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An unresolved_proposals=common["unresolved_proposals"], plan_digest=applied["plan_digest"], confirmation_token=token, + **approval_response_fields, originals_preserved=applied["originals_preserved"], rollback_journal=applied["rollback_journal"], provenance_receipt=applied["provenance_receipt"], diff --git a/hub_core/project_normalization.py b/hub_core/project_normalization.py index e3bd78a..3d32aea 100644 --- a/hub_core/project_normalization.py +++ b/hub_core/project_normalization.py @@ -42,6 +42,8 @@ NORMALIZATION_REVIEW_REQUIRED = "FIGOPS_NORMALIZATION_REVIEW_REQUIRED" NORMALIZATION_CONFIRMATION_REQUIRED = "FIGOPS_NORMALIZATION_CONFIRMATION_REQUIRED" NORMALIZATION_PLAN_REJECTED = "FIGOPS_NORMALIZATION_PLAN_REJECTED" +NORMALIZATION_HOST_APPROVAL_REQUIRED = "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" +NORMALIZATION_HOST_APPROVAL_REJECTED = "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" def plan_scaffold_project( @@ -242,6 +244,7 @@ def apply_normalize_project( hub_path: Path | None = None, overwrite: bool = False, confirmation_token: str | None = None, + pre_apply_verifier: Callable[[Path, Mapping[str, Any]], None] | None = None, post_apply_verifier: Callable[[Path, Mapping[str, Any]], Mapping[str, Any] | None] | None = None, ) -> dict[str, Any]: """Apply only an immutable copy plan with explicit confirmation.""" @@ -254,6 +257,7 @@ def apply_normalize_project( return apply_structure_plan( manifest, confirmation_token=confirmation_token, + pre_apply_verifier=pre_apply_verifier, post_apply_verifier=post_apply_verifier, ) diff --git a/hub_core/structure_apply.py b/hub_core/structure_apply.py index e08a4ad..680f52d 100644 --- a/hub_core/structure_apply.py +++ b/hub_core/structure_apply.py @@ -7,7 +7,7 @@ import shutil import stat import uuid -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any, Callable, Mapping from .atomic_no_clobber import ( @@ -15,6 +15,10 @@ AtomicNoClobberUnavailable, atomic_no_clobber_move, ) +from .structure_apply_preflight import ( + materialize_parent_witness, + prepare_structure_entries, +) from .structure_path_security import ( DirectoryWitness, assert_directory_witness, @@ -52,27 +56,6 @@ def _sha256(path: Path) -> str: return digest.hexdigest() -def _inside(root: Path, value: object) -> Path: - if not isinstance(value, str): - raise ValueError("Plan paths must be strings.") - rel = PurePosixPath(value) - if ( - rel.is_absolute() - or rel.as_posix() != value - or ".." in rel.parts - or any(":" in part for part in rel.parts) - or "\\" in value - ): - raise ValueError("Plan path escapes the project root.") - path = root.joinpath(*rel.parts) - current = root - for part in rel.parts[:-1]: - current /= part - if current.is_symlink(): - raise ValueError(f"Plan path traverses a symlink: {value}") - return path - - def _fsync_parent(path: Path) -> None: if os.name == "nt": return @@ -502,6 +485,7 @@ def apply_structure_plan( plan: Mapping[str, Any], *, confirmation_token: str, + pre_apply_verifier: Callable[[Path, Mapping[str, Any]], None] | None = None, post_apply_verifier: Callable[[Path, Mapping[str, Any]], Mapping[str, Any] | None] | None = None, ) -> dict[str, Any]: """Apply a reviewed plan without moving, linking, replacing, or deleting originals.""" @@ -585,7 +569,9 @@ def apply_structure_plan( root_identity=root_identity, planned_hash=planned_config_hash if isinstance(planned_config_hash, str) else None, ) - prepared: list[tuple[Mapping[str, Any], Path, Path, DirectoryWitness]] = [] + # With no verifier, retain the historical eager parent preparation. When + # a verifier is supplied, destination-parent creation is deferred until + # after the final authority gate so rejection cannot mutate the namespace. created: list[tuple[Path, str, tuple[int, int]]] = [] config_replacement_identity: tuple[int, int] | None = None config_original_guard: Path | None = None @@ -594,37 +580,28 @@ def apply_structure_plan( held_directory_leases: list[Any] = [] config_replaced = False try: - for entry in entries: - if set(entry) != {"source", "destination", "role", "sha256", "size", "source_identity"}: - raise ValueError("Plan entry shape is invalid or contains an executable operation.") - if not isinstance(entry["source_identity"], Mapping) or set(entry["source_identity"]) != { - "device", - "inode", - }: - raise ValueError("Plan source identity is invalid.") - source = _inside(root, entry["source"]) - destination = _inside(root, entry["destination"]) - with open_bound_source( - root, - entry["source"], - root_identity=root_identity, - planned_identity=dict(entry["source_identity"]), - ) as source_handle: - source_hash, source_size = hash_handle(source_handle) - if source_size != entry["size"] or source_hash != entry["sha256"]: - raise RuntimeError(f"Planned source changed after review: {entry['source']}") - parent_relative = PurePosixPath(entry["destination"]).parent.as_posix() - parent_witness = capture_directory_witness( - root, - parent_relative, + prepared = prepare_structure_entries( + entries, + root=root, + root_identity=root_identity, + defer_parent_creation=pre_apply_verifier is not None, + ) + + # All reviewed-plan, root/config identity, source digest, destination + # collision, and containment checks are complete at this point. A + # host authority callback is deliberately the final gate before any + # staging or publication mutation begins. + if pre_apply_verifier is not None: + pre_apply_verifier(root, plan) + + for prepared_entry in prepared: + entry = prepared_entry.entry + destination = prepared_entry.destination + parent_witness = materialize_parent_witness( + prepared_entry, + root=root, root_identity=root_identity, - create=True, ) - if destination.exists() or destination.is_symlink(): - raise FileExistsError(f"Destination appeared after plan review: {entry['destination']}") - prepared.append((entry, source, destination, parent_witness)) - - for entry, _source, destination, parent_witness in prepared: with lease_directory_witness(parent_witness): assert_directory_witness(parent_witness) with open_bound_source( diff --git a/hub_core/structure_apply_preflight.py b/hub_core/structure_apply_preflight.py new file mode 100644 index 0000000..a5746a5 --- /dev/null +++ b/hub_core/structure_apply_preflight.py @@ -0,0 +1,137 @@ +"""Reviewed-plan destination preflight for structure transactions. + +This module owns plan validation and destination-parent witness preparation. +Destination parents are only created eagerly for the legacy no-verifier path; +when an authority verifier is supplied, existing parent components are +witnessed without mutation and any missing suffix is materialized only after +the verifier has accepted the reviewed plan. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Mapping, Sequence + +from .structure_path_security import ( + DirectoryWitness, + capture_directory_witness, + hash_handle, + open_bound_source, +) + + +@dataclass(frozen=True, slots=True) +class PreparedStructureEntry: + """Source and destination facts validated before the authority gate.""" + + entry: Mapping[str, Any] + source: Path + destination: Path + parent_relative: str + parent_witness: DirectoryWitness | None + + +def inside(root: Path, value: object) -> Path: + """Resolve a canonical project-relative plan path without following links.""" + + if not isinstance(value, str): + raise ValueError("Plan paths must be strings.") + relative = PurePosixPath(value) + if ( + relative.is_absolute() + or relative.as_posix() != value + or ".." in relative.parts + or any(":" in part for part in relative.parts) + or "\\" in value + ): + raise ValueError("Plan path escapes the project root.") + path = root.joinpath(*relative.parts) + current = root + for part in relative.parts[:-1]: + current /= part + if current.is_symlink(): + raise ValueError(f"Plan path traverses a symlink: {value}") + return path + + +def prepare_structure_entries( + entries: Sequence[Mapping[str, Any]], + *, + root: Path, + root_identity: tuple[int, int, int], + defer_parent_creation: bool, +) -> list[PreparedStructureEntry]: + """Validate sources, destinations, and parent witnesses before applying.""" + + prepared: list[PreparedStructureEntry] = [] + for entry in entries: + if set(entry) != {"source", "destination", "role", "sha256", "size", "source_identity"}: + raise ValueError("Plan entry shape is invalid or contains an executable operation.") + if not isinstance(entry["source_identity"], Mapping) or set(entry["source_identity"]) != { + "device", + "inode", + }: + raise ValueError("Plan source identity is invalid.") + source = inside(root, entry["source"]) + destination = inside(root, entry["destination"]) + with open_bound_source( + root, + entry["source"], + root_identity=root_identity, + planned_identity=dict(entry["source_identity"]), + ) as source_handle: + source_hash, source_size = hash_handle(source_handle) + if source_size != entry["size"] or source_hash != entry["sha256"]: + raise RuntimeError(f"Planned source changed after review: {entry['source']}") + parent_relative = PurePosixPath(entry["destination"]).parent.as_posix() + parent_witness: DirectoryWitness | None = None + if defer_parent_creation: + try: + # Validate existing parent components without creating a + # destination directory before the authority gate. + capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=False, + ) + except FileNotFoundError: + pass + else: + parent_witness = capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=True, + ) + if destination.exists() or destination.is_symlink(): + raise FileExistsError(f"Destination appeared after plan review: {entry['destination']}") + prepared.append( + PreparedStructureEntry( + entry=entry, + source=source, + destination=destination, + parent_relative=parent_relative, + parent_witness=parent_witness, + ) + ) + return prepared + + +def materialize_parent_witness( + prepared: PreparedStructureEntry, + *, + root: Path, + root_identity: tuple[int, int, int], +) -> DirectoryWitness: + """Return a reviewed parent witness, creating missing suffixes if needed.""" + + if prepared.parent_witness is not None: + return prepared.parent_witness + return capture_directory_witness( + root, + prepared.parent_relative, + root_identity=root_identity, + create=True, + ) diff --git a/tests/test_approval_authority.py b/tests/test_approval_authority.py new file mode 100644 index 0000000..2d30d5d --- /dev/null +++ b/tests/test_approval_authority.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import copy +from copy import deepcopy + +from hub_core.approval_authority import ( + ApprovalAuthorityRoot, + ApprovalRecord, + canonical_plan_digest, + verify_approval_authority, +) + + +def _plan() -> dict: + plan = { + "version": "2", + "project_root_identity": {"device": 1, "inode": 42}, + "config_sha256": "a" * 64, + "config_identity": {"device": 1, "inode": 99}, + "entries": [{"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}], + "approved_mappings": [{"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}], + "config_diff": [], + "hardcoded_unresolved_references": [], + "unresolved_proposals": [], + } + plan["digest"] = canonical_plan_digest(plan) + return plan + + +def _issue(root: ApprovalAuthorityRoot, plan: dict, **kwargs): + options = { + "reviewer_identity": "principal-investigator:alice", + "reviewer_role": "principal_investigator", + "issued_at": "2026-07-24T00:00:00Z", + "expires_at": "2026-07-25T00:00:00Z", + } + options.update(kwargs) + return root.issue(plan, **options) + + +def test_approval_is_only_valid_through_the_host_owned_root() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + assert verify_approval_authority(plan, record.receipt_id, root, now="2026-07-24T12:00:00Z").valid + assert not verify_approval_authority(plan, record.receipt_id, {"records": {}}, now="2026-07-24T12:00:00Z") + assert not verify_approval_authority(plan, record.receipt_id, None, now="2026-07-24T12:00:00Z") + try: + copy.copy(root) + except TypeError: + pass + else: # pragma: no cover - defensive assertion + raise AssertionError("authority roots must not be copied") + + +def test_self_generated_mapping_or_record_cannot_be_authority() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + copied = deepcopy(record.binding.canonical_payload()) + + assert not verify_approval_authority(plan, copied, root) # type: ignore[arg-type] + try: + ApprovalRecord() # type: ignore[call-arg] + except TypeError: + pass + else: # pragma: no cover - defensive assertion + raise AssertionError("ApprovalRecord must only be minted by the root") + + +def test_stale_or_mutated_plan_is_rejected() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + mutated = deepcopy(plan) + mutated["entries"][0]["destination"] = "raw/changed.csv" + # The old self-referential digest is intentionally retained. + assert not verify_approval_authority(mutated, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + stale = deepcopy(plan) + stale["digest"] = "0" * 64 + assert not verify_approval_authority(stale, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + omitted = deepcopy(plan) + omitted.pop("unresolved_proposals") + omitted["digest"] = canonical_plan_digest(omitted) + assert not verify_approval_authority(omitted, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + +def test_expiry_and_revoke_are_fail_closed() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + assert not verify_approval_authority(plan, record.receipt_id, root, now="2026-07-25T00:00:00Z") + root.revoke(record.receipt_id) + assert not verify_approval_authority(plan, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + +def test_supersede_marks_prior_record_non_current() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + old = _issue(root, plan) + replacement = _issue(root, plan, supersedes=old.receipt_id, issued_at="2026-07-24T01:00:00Z") + + assert not verify_approval_authority(plan, old.receipt_id, root, now="2026-07-24T12:00:00Z") + assert verify_approval_authority(plan, replacement.receipt_id, root, now="2026-07-24T12:00:00Z").valid diff --git a/tests/test_mcp_host_approval.py b/tests/test_mcp_host_approval.py new file mode 100644 index 0000000..f685381 --- /dev/null +++ b/tests/test_mcp_host_approval.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from pathlib import Path + +import hub_core.mcp.tools.project_tools as project_tools +from hub_core.approval_authority import ApprovalAuthorityRoot +from hub_core.mcp import GraphHubMCPServer + + +def _setup(tmp_path: Path, *, authority: ApprovalAuthorityRoot | None = None): + project = tmp_path / "LegacyGraph" + project.mkdir(parents=True) + source = project / "plot.py" + source.write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer( + research_root=tmp_path, + write_tools_enabled=True, + require_host_approval=True, + host_authority_root=authority, + ) + arguments = { + "project_path": str(project), + "move_policy": "copy", + "approved_mappings": [ + {"source": "plot.py", "destination": "hub_scripts/figures/plot.py", "role": "script.figure"} + ], + } + planned = server.call_tool( + "figops.normalize_project_structure", {**arguments, "dry_run": True} + )["structuredContent"] + return server, project, arguments, planned + + +def _apply(server: GraphHubMCPServer, arguments: dict, planned: dict, **extra): + return server.call_tool( + "figops.normalize_project_structure", + { + **arguments, + "dry_run": False, + "confirmation_token": planned["confirmation_token"], + **extra, + }, + )["structuredContent"] + + +def _issue(authority: ApprovalAuthorityRoot, planned: dict, *, expires_at: str = "2030-01-01T00:00:00Z"): + return authority.issue( + planned["manifest"], + reviewer_identity="host:reviewer", + reviewer_role="principal_investigator", + issued_at="2020-01-01T00:00:00Z", + expires_at=expires_at, + ) + + +def test_secure_mode_requires_out_of_band_receipt_and_never_accepts_json(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + + missing = _apply(server, arguments, planned) + assert missing["status"] == "error" + assert missing["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" + assert missing["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + record = _issue(authority, planned) + forged_with_valid_id = _apply( + server, + arguments, + planned, + approval_receipt_id=record.receipt_id, + approval={"approved": True, "reviewer": "model"}, + ) + assert forged_with_valid_id["status"] == "error" + assert forged_with_valid_id["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert not (project / "hub_scripts/figures/plot.py").exists() + + no_root_server, no_root_project, no_root_args, no_root_plan = _setup(tmp_path / "no-root") + no_root = _apply(no_root_server, no_root_args, no_root_plan) + assert no_root["status"] == "error" + assert no_root["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" + assert not (no_root_project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_accepts_only_current_host_issued_receipt(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + + applied = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert applied["status"] == "ok" + assert applied["approval_status"] == "verified" + assert (project / "hub_scripts/figures/plot.py").read_text(encoding="utf-8") == "print('plot')\n" + + +def test_secure_mode_rejects_unknown_root_expired_and_revoked_receipts(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + other = ApprovalAuthorityRoot() + foreign = _issue(other, planned) + unknown = _apply(server, arguments, planned, approval_receipt_id=foreign.receipt_id) + assert unknown["status"] == "error" + assert not (project / "hub_scripts/figures/plot.py").exists() + + expired = _issue(authority, planned, expires_at="2020-01-02T00:00:00Z") + rejected = _apply(server, arguments, planned, approval_receipt_id=expired.receipt_id) + assert rejected["status"] == "error" + authority.revoke(expired.receipt_id) + revoked = _apply(server, arguments, planned, approval_receipt_id=expired.receipt_id) + assert revoked["status"] == "error" + assert not (project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_rejects_plan_mutation_before_copy(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + mutated = { + **arguments, + "approved_mappings": [ + {"source": "plot.py", "destination": "results/figures/plot.py", "role": "script.figure"} + ], + } + rejected = _apply(server, mutated, planned, approval_receipt_id=record.receipt_id) + assert rejected["status"] == "error" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + assert not (project / "results/figures/plot.py").exists() + + +def test_secure_mode_rejects_config_mutation_after_host_review(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + (project / "project_config.yaml").write_text("project: {name: changed}\n", encoding="utf-8") + + rejected = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert rejected["status"] == "error" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_rechecks_revocation_at_mutation_boundary(tmp_path: Path, monkeypatch) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + real_verify = project_tools.verify_approval_authority + verification_calls = 0 + + def revoke_before_boundary(current_plan, receipt_id, trusted_root): + nonlocal verification_calls + verification_calls += 1 + if verification_calls == 2: + authority.revoke(record.receipt_id) + return real_verify(current_plan, receipt_id, trusted_root) + + monkeypatch.setattr(project_tools, "verify_approval_authority", revoke_before_boundary) + rejected = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert verification_calls == 2 + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert rejected["approval_status"] == "rejected" + # The pre-apply callback is the final gate: destination parent creation, + # staging, and config replacement must all happen after it passes. + assert not (project / "hub_scripts").exists() + assert not (project / "hub_scripts/figures/plot.py").exists() + assert not (project / "project_config.yaml").exists() + + +def test_compatibility_mode_keeps_host_approval_fields_out_of_response(tmp_path: Path) -> None: + project = tmp_path / "LegacyGraph" + project.mkdir() + (project / "plot.py").write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer(research_root=tmp_path, write_tools_enabled=True) + response = server.call_tool( + "figops.normalize_project_structure", + {"project_path": str(project), "dry_run": True}, + )["structuredContent"] + assert response["status"] == "ok" + assert "host_approval_required" not in response + assert "approval_status" not in response + assert "approval_receipt_id" not in response + + +def test_secure_mode_advertises_receipt_input_and_mode_outputs(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, _, _, _ = _setup(tmp_path, authority=authority) + definition = next( + item for item in server.list_tool_definitions() if item["name"] == "figops.normalize_project_structure" + ) + assert "approval_receipt_id" in definition["inputSchema"]["properties"] + output_properties = definition["outputSchema"]["properties"] + assert {"host_approval_required", "approval_status", "approval_receipt_id"} <= set(output_properties) + assert "secure mode" in definition["description"] From 92f541bc4098aded4735e938c2358d6bc1f26383 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 15:23:25 +0900 Subject: [PATCH 09/21] Harden host approval trust boundary Reject nested self-described authority fields and split approval encoding from lifecycle verification. --- hub_core/approval_authority.py | 510 ++--------------------- hub_core/approval_authority_codec.py | 229 ++++++++++ hub_core/approval_authority_lifecycle.py | 310 ++++++++++++++ hub_core/mcp/tools/project_tools.py | 125 ++++-- tests/test_mcp_host_approval.py | 24 ++ 5 files changed, 689 insertions(+), 509 deletions(-) create mode 100644 hub_core/approval_authority_codec.py create mode 100644 hub_core/approval_authority_lifecycle.py diff --git a/hub_core/approval_authority.py b/hub_core/approval_authority.py index 76d8739..961f607 100644 --- a/hub_core/approval_authority.py +++ b/hub_core/approval_authority.py @@ -1,491 +1,33 @@ -"""Process-local approval authority for reviewed structure plans. +"""Public compatibility facade for process-local approval authority. -This module deliberately keeps approval authority separate from runtime, -durable-result, and evidence receipts. A record is only authoritative when it -was minted by the exact host-owned :class:`ApprovalAuthorityRoot` supplied to -the verifier. Copying a record to a mapping (or serialising it to JSON) never -creates authority. -""" - -from __future__ import annotations - -import hashlib -import json -import math -import re -from dataclasses import dataclass -from datetime import UTC, datetime -from types import MappingProxyType -from typing import Any, Mapping, NoReturn - -_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") -_RECEIPT_ID_RE = re.compile(r"^approval:sha256:[0-9a-f]{64}$") -_UTC_TIMESTAMP_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") - - -class ApprovalAuthorityError(ValueError): - """Raised when an approval authority contract cannot be constructed.""" - - -def _fail(message: str) -> NoReturn: - raise ApprovalAuthorityError(f"approval authority {message}") - - -def _jsonable(value: Any) -> Any: - """Convert immutable values back to plain deterministic JSON values.""" - - if isinstance(value, Mapping): - result: dict[str, Any] = {} - for key, child in value.items(): - if not isinstance(key, str): - _fail("JSON object keys must be strings") - result[key] = _jsonable(child) - return result - if isinstance(value, tuple): - return [_jsonable(child) for child in value] - if isinstance(value, list): - return [_jsonable(child) for child in value] - if value is None or isinstance(value, (str, bool, int)): - return value - if isinstance(value, float): - if not math.isfinite(value): - _fail("JSON values must be finite") - return value - _fail(f"JSON value has unsupported type {type(value).__name__}") - - -def _freeze_json(value: Any) -> Any: - """Deep-freeze JSON data so records cannot be changed through aliases.""" - - if isinstance(value, Mapping): - result = {key: _freeze_json(child) for key, child in value.items()} - if any(not isinstance(key, str) for key in result): - _fail("JSON object keys must be strings") - return MappingProxyType(result) - if isinstance(value, (list, tuple)): - return tuple(_freeze_json(child) for child in value) - if value is None or isinstance(value, (str, bool, int)): - return value - if isinstance(value, float): - if not math.isfinite(value): - _fail("JSON values must be finite") - return value - _fail(f"JSON value has unsupported type {type(value).__name__}") - - -def canonical_json_bytes(value: Any) -> bytes: - """Return deterministic UTF-8 JSON bytes for a JSON-compatible value.""" - - try: - return json.dumps( - _jsonable(value), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ).encode("utf-8") - except (TypeError, ValueError, OverflowError, RecursionError) as exc: - raise ApprovalAuthorityError(f"JSON canonicalization failed: {exc}") from exc - - -def _sha256_bytes(value: Any) -> str: - return hashlib.sha256(canonical_json_bytes(value)).hexdigest() - - -def _valid_digest(value: Any, field: str) -> str: - if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: - _fail(f"{field} must be a lowercase SHA-256 digest") - return value - - -def _text(value: Any, field: str, *, max_length: int = 512) -> str: - if not isinstance(value, str) or not value or value != value.strip() or len(value) > max_length: - _fail(f"{field} must be a non-empty canonical string") - if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): - _fail(f"{field} may not contain control characters") - return value - - -def _timestamp(value: Any, field: str) -> str: - if isinstance(value, datetime): - if value.tzinfo is None or value.utcoffset() is None: - _fail(f"{field} must be timezone-aware") - value = value.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") - if not isinstance(value, str) or _UTC_TIMESTAMP_RE.fullmatch(value) is None: - _fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") - try: - datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) - except ValueError as exc: - raise ApprovalAuthorityError(f"approval authority {field} must be a real timestamp") from exc - return value - - -def _parse_timestamp(value: str) -> datetime: - return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) - - -def canonical_plan_digest(plan: Mapping[str, Any]) -> str: - """Hash a plan excluding its self-referential ``digest`` fields. - - Structure plans use ``digest``; callers using ``plan_digest`` receive the - same treatment. A declared digest, when present, must match this value. - """ - - if not isinstance(plan, Mapping): - _fail("plan must be a mapping") - payload = {key: value for key, value in plan.items() if key not in {"digest", "plan_digest"}} - return _sha256_bytes(payload) - - -def _checked_plan_digest(plan: Mapping[str, Any]) -> str: - digest = canonical_plan_digest(plan) - for field in ("digest", "plan_digest"): - if field in plan and plan[field] != digest: - _fail(f"plan {field} is stale or invalid") - return digest - - -def _field_digest(plan: Mapping[str, Any], *fields: str) -> str: - for field in fields: - if field in plan: - return _sha256_bytes(plan[field]) - # Omission is distinct from an explicitly reviewed empty list. - return _sha256_bytes({"__missing_field__": fields[0]}) - - -def _identity(plan: Mapping[str, Any], field: str, *, allow_none: bool = False) -> Any: - value = plan.get(field) - if value is None and allow_none: - return None - if value is None: - _fail(f"plan {field} is required") - return _freeze_json(value) - - -@dataclass(frozen=True, slots=True) -class ApprovalBinding: - """Immutable canonical binding for one host-issued approval.""" - - plan_digest: str - project_root_identity: Any - config_sha256: str | None - config_identity: Any - reviewed_entries_digest: str - approved_mappings_digest: str - config_diff_digest: str - hardcoded_unresolved_references_digest: str - unresolved_proposals_digest: str - reviewer_identity: str - reviewer_role: str - issued_at: str - expires_at: str - current: bool = True - revoked: bool = False - superseded: bool = False - superseded_by: str | None = None - supersedes: str | None = None - - def canonical_payload(self) -> dict[str, Any]: - return { - "plan_digest": self.plan_digest, - "project_root_identity": _jsonable(self.project_root_identity), - "config_sha256": self.config_sha256, - "config_identity": _jsonable(self.config_identity), - "reviewed_entries_digest": self.reviewed_entries_digest, - "approved_mappings_digest": self.approved_mappings_digest, - "config_diff_digest": self.config_diff_digest, - "hardcoded_unresolved_references_digest": self.hardcoded_unresolved_references_digest, - "unresolved_proposals_digest": self.unresolved_proposals_digest, - "reviewer_identity": self.reviewer_identity, - "reviewer_role": self.reviewer_role, - "issued_at": self.issued_at, - "expires_at": self.expires_at, - "current": self.current, - "revoked": self.revoked, - "superseded": self.superseded, - "superseded_by": self.superseded_by, - "supersedes": self.supersedes, - } - - -def canonical_approval_binding_bytes(binding: ApprovalBinding) -> bytes: - if not isinstance(binding, ApprovalBinding): - _fail("binding must be an ApprovalBinding") - return canonical_json_bytes(binding.canonical_payload()) - - -def approval_binding_digest(binding: ApprovalBinding) -> str: - return hashlib.sha256(canonical_approval_binding_bytes(binding)).hexdigest() +The implementation is split between two focused modules: +* :mod:`approval_authority_codec` contains canonical JSON, digest, timestamp, + and immutable binding validation. +* :mod:`approval_authority_lifecycle` contains the host-owned trust root, + issuance/revocation/supersession, and fail-closed verification. -@dataclass(frozen=True, slots=True, init=False) -class ApprovalRecord: - """Immutable approval record minted by an authority root.""" - - receipt_id: str - binding: ApprovalBinding - _authority_owner: object - - def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - public construction is forbidden - raise TypeError("ApprovalRecord instances must be issued by ApprovalAuthorityRoot") - - @classmethod - def _mint(cls, owner: object, receipt_id: str, binding: ApprovalBinding) -> "ApprovalRecord": - record = object.__new__(cls) - object.__setattr__(record, "receipt_id", receipt_id) - object.__setattr__(record, "binding", binding) - object.__setattr__(record, "_authority_owner", owner) - return record - - @property - def plan_digest(self) -> str: - return self.binding.plan_digest - - @property - def current(self) -> bool: - return self.binding.current - - @property - def revoked(self) -> bool: - return self.binding.revoked - - @property - def superseded(self) -> bool: - return self.binding.superseded - - @property - def canonical_digest(self) -> str: - return approval_binding_digest(self.binding) - - -@dataclass(frozen=True, slots=True) -class ApprovalVerificationResult: - """Fail-closed result returned by :func:`verify_approval_authority`.""" - - valid: bool - reason: str - receipt_id: str | None = None - record: ApprovalRecord | None = None - - def __bool__(self) -> bool: - return self.valid - - -class _AuthorityState: - """Mutable lifecycle index entry; never exposed as an authority value.""" - - __slots__ = ("record", "current", "revoked", "superseded_by") - - def __init__(self, record: ApprovalRecord) -> None: - self.record = record - self.current = True - self.revoked = False - self.superseded_by: str | None = None - - -class ApprovalAuthorityRoot: - """Host-owned, process-local trust root and approval index.""" - - __slots__ = ("_records",) - - def __init__(self) -> None: - self._records: dict[str, _AuthorityState] = {} - - def __copy__(self) -> "ApprovalAuthorityRoot": # pragma: no cover - defensive boundary - raise TypeError("ApprovalAuthorityRoot cannot be copied") - - def __deepcopy__(self, memo: dict[int, Any]) -> "ApprovalAuthorityRoot": # pragma: no cover - raise TypeError("ApprovalAuthorityRoot cannot be copied") - - def issue_approval( - self, - plan: Mapping[str, Any], - *, - reviewer_identity: str, - reviewer_role: str, - issued_at: str | datetime, - expires_at: str | datetime, - supersedes: str | None = None, - ) -> ApprovalRecord: - plan_digest = _checked_plan_digest(plan) - project_root_identity = _identity(plan, "project_root_identity") - config_identity = _identity(plan, "config_identity", allow_none=True) - config_sha256 = plan.get("config_sha256") - if config_sha256 is not None: - config_sha256 = _valid_digest(config_sha256, "plan config_sha256") - reviewer_identity = _text(reviewer_identity, "reviewer_identity") - reviewer_role = _text(reviewer_role, "reviewer_role", max_length=128) - issued_at = _timestamp(issued_at, "issued_at") - expires_at = _timestamp(expires_at, "expires_at") - if _parse_timestamp(expires_at) <= _parse_timestamp(issued_at): - _fail("expires_at must be later than issued_at") - supersedes = None if supersedes is None else _text(supersedes, "supersedes", max_length=80) - if supersedes is not None: - if _RECEIPT_ID_RE.fullmatch(supersedes) is None: - _fail("supersedes must be an approval receipt id") - old = self._records.get(supersedes) - if old is None: - _fail("supersedes references an unknown receipt id") - if old.revoked: - _fail("a revoked approval cannot be superseded") - if not old.current: - _fail("only a current approval can be superseded") - - binding = ApprovalBinding( - plan_digest=plan_digest, - project_root_identity=project_root_identity, - config_sha256=config_sha256, - config_identity=config_identity, - reviewed_entries_digest=_field_digest(plan, "reviewed_entries", "entries"), - approved_mappings_digest=_field_digest(plan, "approved_mappings"), - config_diff_digest=_field_digest(plan, "config_diff"), - hardcoded_unresolved_references_digest=_field_digest(plan, "hardcoded_unresolved_references"), - unresolved_proposals_digest=_field_digest(plan, "unresolved_proposals"), - reviewer_identity=reviewer_identity, - reviewer_role=reviewer_role, - issued_at=issued_at, - expires_at=expires_at, - supersedes=supersedes, - ) - receipt_id = f"approval:sha256:{approval_binding_digest(binding)}" - if receipt_id in self._records: - _fail("an approval with the same immutable binding already exists") - record = ApprovalRecord._mint(self, receipt_id, binding) - self._records[receipt_id] = _AuthorityState(record) - if supersedes is not None: - old = self._records[supersedes] - old.current = False - old.superseded_by = receipt_id - return record - - def issue(self, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: - """Short alias for :meth:`issue_approval`.""" - - return self.issue_approval(plan, **kwargs) - - def revoke(self, receipt_id: str) -> None: - state = self._state_for(receipt_id) - if state.revoked: - return - state.revoked = True - state.current = False - - def revoke_approval(self, receipt_id: str) -> None: - self.revoke(receipt_id) - - def supersede(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: - kwargs["supersedes"] = receipt_id - return self.issue_approval(plan, **kwargs) - - def supersede_approval(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: - return self.supersede(receipt_id, plan, **kwargs) - - def get(self, receipt_id: str) -> ApprovalRecord | None: - state = self._records.get(receipt_id) - return None if state is None else state.record - - def lookup(self, receipt_id: str) -> ApprovalRecord | None: - """Return an immutable record snapshot without exposing the index.""" - - return self.get(receipt_id) - - def records(self) -> tuple[ApprovalRecord, ...]: - return tuple(state.record for state in self._records.values()) - - def _state_for(self, receipt_id: str) -> _AuthorityState: - if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: - _fail("receipt_id must be an approval:sha256: identifier") - state = self._records.get(receipt_id) - if state is None: - _fail("unknown receipt_id") - return state - - def _lookup(self, receipt_id: str) -> _AuthorityState | None: - if not isinstance(receipt_id, str): - return None - return self._records.get(receipt_id) - - -def _result( - valid: bool, - reason: str, - receipt_id: str | None, - record: ApprovalRecord | None = None, -) -> ApprovalVerificationResult: - return ApprovalVerificationResult(valid, reason, receipt_id, record) - - -def verify_approval_authority( - plan: Mapping[str, Any], - receipt_id: str, - trusted_root: ApprovalAuthorityRoot | None = None, - now: str | datetime | None = None, -) -> ApprovalVerificationResult: - """Verify an approval against an exact host-owned root and current plan.""" - - if type(trusted_root) is not ApprovalAuthorityRoot: - return _result(False, "missing_or_untrusted_root", receipt_id if isinstance(receipt_id, str) else None) - if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: - return _result(False, "missing_or_invalid_receipt_id", receipt_id if isinstance(receipt_id, str) else None) - state = trusted_root._lookup(receipt_id) - if state is None: - return _result(False, "unknown_receipt_id", receipt_id) - record = state.record - if record._authority_owner is not trusted_root: - return _result(False, "untrusted_record", receipt_id, record) - if not isinstance(plan, Mapping): - return _result(False, "invalid_plan", receipt_id, record) - try: - plan_digest = canonical_plan_digest(plan) - if any(field in plan and plan[field] != plan_digest for field in ("digest", "plan_digest")): - return _result(False, "plan_digest_mismatch", receipt_id, record) - if plan_digest != record.binding.plan_digest: - return _result(False, "plan_digest_mismatch", receipt_id, record) - if _freeze_json(plan.get("project_root_identity")) != record.binding.project_root_identity: - return _result(False, "project_root_identity_mismatch", receipt_id, record) - config_sha256 = plan.get("config_sha256") - if config_sha256 is not None: - config_sha256 = _valid_digest(config_sha256, "plan config_sha256") - if config_sha256 != record.binding.config_sha256: - return _result(False, "config_sha256_mismatch", receipt_id, record) - if _freeze_json(plan.get("config_identity")) != record.binding.config_identity: - return _result(False, "config_identity_mismatch", receipt_id, record) - checks = ( - ("reviewed_entries_digest", _field_digest(plan, "reviewed_entries", "entries")), - ("approved_mappings_digest", _field_digest(plan, "approved_mappings")), - ("config_diff_digest", _field_digest(plan, "config_diff")), - ( - "hardcoded_unresolved_references_digest", - _field_digest(plan, "hardcoded_unresolved_references"), - ), - ("unresolved_proposals_digest", _field_digest(plan, "unresolved_proposals")), - ) - for field, actual in checks: - if actual != getattr(record.binding, field): - return _result(False, f"{field}_mismatch", receipt_id, record) - check_time = ( - _parse_timestamp(_timestamp(now, "now")) - if now is not None - else datetime.now(UTC).replace(microsecond=0) - ) - if check_time < _parse_timestamp(record.binding.issued_at): - return _result(False, "issued_in_future", receipt_id, record) - if check_time >= _parse_timestamp(record.binding.expires_at): - return _result(False, "expired", receipt_id, record) - except (ApprovalAuthorityError, TypeError, ValueError): - return _result(False, "invalid_plan", receipt_id, record) - if state.revoked or record.binding.revoked: - return _result(False, "revoked", receipt_id, record) - if state.superseded_by is not None or record.binding.superseded: - return _result(False, "superseded", receipt_id, record) - if not state.current or not record.binding.current: - return _result(False, "not_current", receipt_id, record) - return _result(True, "valid", receipt_id, record) - - -verify_approval = verify_approval_authority -ApprovalAuthority = ApprovalAuthorityRoot +This facade intentionally re-exports the established public API. Consumers +can continue importing from ``hub_core.approval_authority`` without changing +the class identities used by the trust boundary. +""" +from .approval_authority_codec import ( + ApprovalAuthorityError, + ApprovalBinding, + approval_binding_digest, + canonical_approval_binding_bytes, + canonical_json_bytes, + canonical_plan_digest, +) +from .approval_authority_lifecycle import ( + ApprovalAuthority, + ApprovalAuthorityRoot, + ApprovalRecord, + ApprovalVerificationResult, + verify_approval, + verify_approval_authority, +) __all__ = [ "ApprovalAuthority", diff --git a/hub_core/approval_authority_codec.py b/hub_core/approval_authority_codec.py new file mode 100644 index 0000000..41d0c9a --- /dev/null +++ b/hub_core/approval_authority_codec.py @@ -0,0 +1,229 @@ +"""Canonical encoding and validation for approval authority records. + +The approval authority lifecycle lives in :mod:`approval_authority_lifecycle`. +This module owns the deterministic JSON representation and the immutable +binding that is signed by the process-local authority root. Keeping these +operations separate makes the security boundary easier to audit while +retaining the same exceptions and canonical digest behaviour as the public +``approval_authority`` module. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from types import MappingProxyType +from typing import Any, Mapping, NoReturn + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_RECEIPT_ID_RE = re.compile(r"^approval:sha256:[0-9a-f]{64}$") +_UTC_TIMESTAMP_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + + +class ApprovalAuthorityError(ValueError): + """Raised when an approval authority contract cannot be constructed.""" + + +def _fail(message: str) -> NoReturn: + raise ApprovalAuthorityError(f"approval authority {message}") + + +def _jsonable(value: Any) -> Any: + """Convert immutable values back to plain deterministic JSON values.""" + + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + _fail("JSON object keys must be strings") + result[key] = _jsonable(child) + return result + if isinstance(value, tuple): + return [_jsonable(child) for child in value] + if isinstance(value, list): + return [_jsonable(child) for child in value] + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def _freeze_json(value: Any) -> Any: + """Deep-freeze JSON data so records cannot be changed through aliases.""" + + if isinstance(value, Mapping): + result = {key: _freeze_json(child) for key, child in value.items()} + if any(not isinstance(key, str) for key in result): + _fail("JSON object keys must be strings") + return MappingProxyType(result) + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(child) for child in value) + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def canonical_json_bytes(value: Any) -> bytes: + """Return deterministic UTF-8 JSON bytes for a JSON-compatible value.""" + + try: + return json.dumps( + _jsonable(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise ApprovalAuthorityError(f"JSON canonicalization failed: {exc}") from exc + + +def _sha256_bytes(value: Any) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _valid_digest(value: Any, field: str) -> str: + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + _fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def _text(value: Any, field: str, *, max_length: int = 512) -> str: + if not isinstance(value, str) or not value or value != value.strip() or len(value) > max_length: + _fail(f"{field} must be a non-empty canonical string") + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): + _fail(f"{field} may not contain control characters") + return value + + +def _timestamp(value: Any, field: str) -> str: + if isinstance(value, datetime): + if value.tzinfo is None or value.utcoffset() is None: + _fail(f"{field} must be timezone-aware") + value = value.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + if not isinstance(value, str) or _UTC_TIMESTAMP_RE.fullmatch(value) is None: + _fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError as exc: + raise ApprovalAuthorityError(f"approval authority {field} must be a real timestamp") from exc + return value + + +def _parse_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def canonical_plan_digest(plan: Mapping[str, Any]) -> str: + """Hash a plan excluding its self-referential ``digest`` fields. + + Structure plans use ``digest``; callers using ``plan_digest`` receive the + same treatment. A declared digest, when present, must match this value. + """ + + if not isinstance(plan, Mapping): + _fail("plan must be a mapping") + payload = {key: value for key, value in plan.items() if key not in {"digest", "plan_digest"}} + return _sha256_bytes(payload) + + +def _checked_plan_digest(plan: Mapping[str, Any]) -> str: + digest = canonical_plan_digest(plan) + for field in ("digest", "plan_digest"): + if field in plan and plan[field] != digest: + _fail(f"plan {field} is stale or invalid") + return digest + + +def _field_digest(plan: Mapping[str, Any], *fields: str) -> str: + for field in fields: + if field in plan: + return _sha256_bytes(plan[field]) + # Omission is distinct from an explicitly reviewed empty list. + return _sha256_bytes({"__missing_field__": fields[0]}) + + +def _identity(plan: Mapping[str, Any], field: str, *, allow_none: bool = False) -> Any: + value = plan.get(field) + if value is None and allow_none: + return None + if value is None: + _fail(f"plan {field} is required") + return _freeze_json(value) + + +@dataclass(frozen=True, slots=True) +class ApprovalBinding: + """Immutable canonical binding for one host-issued approval.""" + + plan_digest: str + project_root_identity: Any + config_sha256: str | None + config_identity: Any + reviewed_entries_digest: str + approved_mappings_digest: str + config_diff_digest: str + hardcoded_unresolved_references_digest: str + unresolved_proposals_digest: str + reviewer_identity: str + reviewer_role: str + issued_at: str + expires_at: str + current: bool = True + revoked: bool = False + superseded: bool = False + superseded_by: str | None = None + supersedes: str | None = None + + def canonical_payload(self) -> dict[str, Any]: + return { + "plan_digest": self.plan_digest, + "project_root_identity": _jsonable(self.project_root_identity), + "config_sha256": self.config_sha256, + "config_identity": _jsonable(self.config_identity), + "reviewed_entries_digest": self.reviewed_entries_digest, + "approved_mappings_digest": self.approved_mappings_digest, + "config_diff_digest": self.config_diff_digest, + "hardcoded_unresolved_references_digest": self.hardcoded_unresolved_references_digest, + "unresolved_proposals_digest": self.unresolved_proposals_digest, + "reviewer_identity": self.reviewer_identity, + "reviewer_role": self.reviewer_role, + "issued_at": self.issued_at, + "expires_at": self.expires_at, + "current": self.current, + "revoked": self.revoked, + "superseded": self.superseded, + "superseded_by": self.superseded_by, + "supersedes": self.supersedes, + } + + +def canonical_approval_binding_bytes(binding: ApprovalBinding) -> bytes: + if not isinstance(binding, ApprovalBinding): + _fail("binding must be an ApprovalBinding") + return canonical_json_bytes(binding.canonical_payload()) + + +def approval_binding_digest(binding: ApprovalBinding) -> str: + return hashlib.sha256(canonical_approval_binding_bytes(binding)).hexdigest() + + +__all__ = [ + "ApprovalAuthorityError", + "ApprovalBinding", + "approval_binding_digest", + "canonical_approval_binding_bytes", + "canonical_json_bytes", + "canonical_plan_digest", +] diff --git a/hub_core/approval_authority_lifecycle.py b/hub_core/approval_authority_lifecycle.py new file mode 100644 index 0000000..ecf275b --- /dev/null +++ b/hub_core/approval_authority_lifecycle.py @@ -0,0 +1,310 @@ +"""Issuance, lifecycle, and verification for process-local approvals. + +The codec module owns canonical data and validation primitives. This module +owns the mutable in-process index and the exact-root checks that make an +approval authoritative. Records retain the root object itself as an +identity-bearing capability; serialising or copying a record never grants +authority to a different root. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Mapping + +from .approval_authority_codec import ( + _RECEIPT_ID_RE, + ApprovalAuthorityError, + ApprovalBinding, + _checked_plan_digest, + _field_digest, + _freeze_json, + _identity, + _parse_timestamp, + _text, + _timestamp, + _valid_digest, + approval_binding_digest, + canonical_plan_digest, +) + + +@dataclass(frozen=True, slots=True, init=False) +class ApprovalRecord: + """Immutable approval record minted by an authority root.""" + + receipt_id: str + binding: ApprovalBinding + _authority_owner: object + + def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - public construction is forbidden + raise TypeError("ApprovalRecord instances must be issued by ApprovalAuthorityRoot") + + @classmethod + def _mint(cls, owner: object, receipt_id: str, binding: ApprovalBinding) -> "ApprovalRecord": + record = object.__new__(cls) + object.__setattr__(record, "receipt_id", receipt_id) + object.__setattr__(record, "binding", binding) + object.__setattr__(record, "_authority_owner", owner) + return record + + @property + def plan_digest(self) -> str: + return self.binding.plan_digest + + @property + def current(self) -> bool: + return self.binding.current + + @property + def revoked(self) -> bool: + return self.binding.revoked + + @property + def superseded(self) -> bool: + return self.binding.superseded + + @property + def canonical_digest(self) -> str: + return approval_binding_digest(self.binding) + + +@dataclass(frozen=True, slots=True) +class ApprovalVerificationResult: + """Fail-closed result returned by :func:`verify_approval_authority`.""" + + valid: bool + reason: str + receipt_id: str | None = None + record: ApprovalRecord | None = None + + def __bool__(self) -> bool: + return self.valid + + +class _AuthorityState: + """Mutable lifecycle index entry; never exposed as an authority value.""" + + __slots__ = ("record", "current", "revoked", "superseded_by") + + def __init__(self, record: ApprovalRecord) -> None: + self.record = record + self.current = True + self.revoked = False + self.superseded_by: str | None = None + + +class ApprovalAuthorityRoot: + """Host-owned, process-local trust root and approval index.""" + + __slots__ = ("_records",) + + def __init__(self) -> None: + self._records: dict[str, _AuthorityState] = {} + + def __copy__(self) -> "ApprovalAuthorityRoot": # pragma: no cover - defensive boundary + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def __deepcopy__(self, memo: dict[int, Any]) -> "ApprovalAuthorityRoot": # pragma: no cover + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def issue_approval( + self, + plan: Mapping[str, Any], + *, + reviewer_identity: str, + reviewer_role: str, + issued_at: str | datetime, + expires_at: str | datetime, + supersedes: str | None = None, + ) -> ApprovalRecord: + plan_digest = _checked_plan_digest(plan) + project_root_identity = _identity(plan, "project_root_identity") + config_identity = _identity(plan, "config_identity", allow_none=True) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + reviewer_identity = _text(reviewer_identity, "reviewer_identity") + reviewer_role = _text(reviewer_role, "reviewer_role", max_length=128) + issued_at = _timestamp(issued_at, "issued_at") + expires_at = _timestamp(expires_at, "expires_at") + if _parse_timestamp(expires_at) <= _parse_timestamp(issued_at): + raise ApprovalAuthorityError("approval authority expires_at must be later than issued_at") + supersedes = None if supersedes is None else _text(supersedes, "supersedes", max_length=80) + if supersedes is not None: + if _RECEIPT_ID_RE.fullmatch(supersedes) is None: + raise ApprovalAuthorityError("approval authority supersedes must be an approval receipt id") + old = self._records.get(supersedes) + if old is None: + raise ApprovalAuthorityError("approval authority supersedes references an unknown receipt id") + if old.revoked: + raise ApprovalAuthorityError("approval authority a revoked approval cannot be superseded") + if not old.current: + raise ApprovalAuthorityError("approval authority only a current approval can be superseded") + + binding = ApprovalBinding( + plan_digest=plan_digest, + project_root_identity=project_root_identity, + config_sha256=config_sha256, + config_identity=config_identity, + reviewed_entries_digest=_field_digest(plan, "reviewed_entries", "entries"), + approved_mappings_digest=_field_digest(plan, "approved_mappings"), + config_diff_digest=_field_digest(plan, "config_diff"), + hardcoded_unresolved_references_digest=_field_digest(plan, "hardcoded_unresolved_references"), + unresolved_proposals_digest=_field_digest(plan, "unresolved_proposals"), + reviewer_identity=reviewer_identity, + reviewer_role=reviewer_role, + issued_at=issued_at, + expires_at=expires_at, + supersedes=supersedes, + ) + receipt_id = f"approval:sha256:{approval_binding_digest(binding)}" + if receipt_id in self._records: + raise ApprovalAuthorityError( + "approval authority an approval with the same immutable binding already exists" + ) + record = ApprovalRecord._mint(self, receipt_id, binding) + self._records[receipt_id] = _AuthorityState(record) + if supersedes is not None: + old = self._records[supersedes] + old.current = False + old.superseded_by = receipt_id + return record + + def issue(self, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + """Short alias for :meth:`issue_approval`.""" + + return self.issue_approval(plan, **kwargs) + + def revoke(self, receipt_id: str) -> None: + state = self._state_for(receipt_id) + if state.revoked: + return + state.revoked = True + state.current = False + + def revoke_approval(self, receipt_id: str) -> None: + self.revoke(receipt_id) + + def supersede(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + kwargs["supersedes"] = receipt_id + return self.issue_approval(plan, **kwargs) + + def supersede_approval(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + return self.supersede(receipt_id, plan, **kwargs) + + def get(self, receipt_id: str) -> ApprovalRecord | None: + state = self._records.get(receipt_id) + return None if state is None else state.record + + def lookup(self, receipt_id: str) -> ApprovalRecord | None: + """Return an immutable record snapshot without exposing the index.""" + + return self.get(receipt_id) + + def records(self) -> tuple[ApprovalRecord, ...]: + return tuple(state.record for state in self._records.values()) + + def _state_for(self, receipt_id: str) -> _AuthorityState: + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + raise ApprovalAuthorityError( + "approval authority receipt_id must be an approval:sha256: identifier" + ) + state = self._records.get(receipt_id) + if state is None: + raise ApprovalAuthorityError("approval authority unknown receipt_id") + return state + + def _lookup(self, receipt_id: str) -> _AuthorityState | None: + if not isinstance(receipt_id, str): + return None + return self._records.get(receipt_id) + + +def _result( + valid: bool, + reason: str, + receipt_id: str | None, + record: ApprovalRecord | None = None, +) -> ApprovalVerificationResult: + return ApprovalVerificationResult(valid, reason, receipt_id, record) + + +def verify_approval_authority( + plan: Mapping[str, Any], + receipt_id: str, + trusted_root: ApprovalAuthorityRoot | None = None, + now: str | datetime | None = None, +) -> ApprovalVerificationResult: + """Verify an approval against an exact host-owned root and current plan.""" + + if type(trusted_root) is not ApprovalAuthorityRoot: + return _result(False, "missing_or_untrusted_root", receipt_id if isinstance(receipt_id, str) else None) + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + return _result(False, "missing_or_invalid_receipt_id", receipt_id if isinstance(receipt_id, str) else None) + state = trusted_root._lookup(receipt_id) + if state is None: + return _result(False, "unknown_receipt_id", receipt_id) + record = state.record + if record._authority_owner is not trusted_root: + return _result(False, "untrusted_record", receipt_id, record) + if not isinstance(plan, Mapping): + return _result(False, "invalid_plan", receipt_id, record) + try: + plan_digest = canonical_plan_digest(plan) + if any(field in plan and plan[field] != plan_digest for field in ("digest", "plan_digest")): + return _result(False, "plan_digest_mismatch", receipt_id, record) + if plan_digest != record.binding.plan_digest: + return _result(False, "plan_digest_mismatch", receipt_id, record) + if _freeze_json(plan.get("project_root_identity")) != record.binding.project_root_identity: + return _result(False, "project_root_identity_mismatch", receipt_id, record) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + if config_sha256 != record.binding.config_sha256: + return _result(False, "config_sha256_mismatch", receipt_id, record) + if _freeze_json(plan.get("config_identity")) != record.binding.config_identity: + return _result(False, "config_identity_mismatch", receipt_id, record) + checks = ( + ("reviewed_entries_digest", _field_digest(plan, "reviewed_entries", "entries")), + ("approved_mappings_digest", _field_digest(plan, "approved_mappings")), + ("config_diff_digest", _field_digest(plan, "config_diff")), + ("hardcoded_unresolved_references_digest", _field_digest(plan, "hardcoded_unresolved_references")), + ("unresolved_proposals_digest", _field_digest(plan, "unresolved_proposals")), + ) + for field, actual in checks: + if actual != getattr(record.binding, field): + return _result(False, f"{field}_mismatch", receipt_id, record) + check_time = ( + _parse_timestamp(_timestamp(now, "now")) + if now is not None + else datetime.now(UTC).replace(microsecond=0) + ) + if check_time < _parse_timestamp(record.binding.issued_at): + return _result(False, "issued_in_future", receipt_id, record) + if check_time >= _parse_timestamp(record.binding.expires_at): + return _result(False, "expired", receipt_id, record) + except (ApprovalAuthorityError, TypeError, ValueError): + return _result(False, "invalid_plan", receipt_id, record) + if state.revoked or record.binding.revoked: + return _result(False, "revoked", receipt_id, record) + if state.superseded_by is not None or record.binding.superseded: + return _result(False, "superseded", receipt_id, record) + if not state.current or not record.binding.current: + return _result(False, "not_current", receipt_id, record) + return _result(True, "valid", receipt_id, record) + + +verify_approval = verify_approval_authority +ApprovalAuthority = ApprovalAuthorityRoot + + +__all__ = [ + "ApprovalAuthority", + "ApprovalAuthorityRoot", + "ApprovalRecord", + "ApprovalVerificationResult", + "verify_approval", + "verify_approval_authority", +] diff --git a/hub_core/mcp/tools/project_tools.py b/hub_core/mcp/tools/project_tools.py index 12ebde3..1be105c 100644 --- a/hub_core/mcp/tools/project_tools.py +++ b/hub_core/mcp/tools/project_tools.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -32,6 +33,7 @@ class _HostApprovalRejected(PermissionError): "approval_payload", "approval_record", "approval_receipt", + "approval_receipt_id", "approval_status", "algorithm", "attestation", @@ -56,6 +58,8 @@ class _HostApprovalRejected(PermissionError): "reviewer_role", "revocation", "revocation_epoch", + "trust", + "trusted", "signature", "trust_root", "trust_root_id", @@ -64,18 +68,83 @@ class _HostApprovalRejected(PermissionError): def _self_described_authority_keys(arguments: dict[str, Any]) -> list[str]: + """Find authority-looking fields at every depth of a tool request. + + Host approval is deliberately an out-of-band trust channel. The one + exception is the opaque, top-level ``approval_receipt_id`` consumed by the + host authority root. Every other authority-looking field is rejected, + including fields hidden in a mapping/list payload such as + ``approved_mappings[0].reviewer`` or ``manifest.trust_root``. + """ + def canonical_key(key: object) -> str: return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip()).replace("-", "_").casefold() - return sorted( + # Catch compound names such as ``approval_token`` and ``trusted_by`` in + # addition to the explicit compatibility list above, while keeping normal + # request fields such as ``approved_mappings`` valid. + authority_components = frozenset( { - str(key) - for key in arguments - if canonical_key(key) in _HOST_AUTHORITY_ARGUMENT_KEYS - }, - key=str.casefold, + "approval", + "approved", + "authority", + "authorization", + "authorize", + "receipt", + "review", + "reviewer", + "signature", + "signed", + "trust", + "trusted", + } ) + def is_authority_key(key: object) -> bool: + canonical = canonical_key(key) + if canonical in _HOST_AUTHORITY_ARGUMENT_KEYS: + return True + return bool(authority_components.intersection(canonical.split("_"))) + + def format_path(path: tuple[object, ...]) -> str: + rendered = "" + for part in path: + if isinstance(part, int): + rendered += f"[{part}]" + else: + rendered = f"{rendered}.{part}" if rendered else str(part) + return rendered + + findings: set[str] = set() + + def visit(value: object, path: tuple[object, ...] = ()) -> None: + if isinstance(value, Mapping): + for raw_key, nested in value.items(): + key = str(raw_key) + key_path = path + (key,) + canonical = canonical_key(raw_key) + # Only this exact semantic field at the request root is + # allowed. Its value must remain opaque; if a caller embeds a + # mapping/list below it, recurse so forged nested fields still + # fail closed. + if not path and canonical == "approval_receipt_id": + if not isinstance(nested, str): + findings.add(format_path(key_path)) + visit(nested, key_path) + continue + # ``approved_mappings`` is the one ordinary request field + # whose name contains an authority word; only its top-level + # collection is part of the public normalization contract. + if not (not path and canonical == "approved_mappings") and is_authority_key(raw_key): + findings.add(format_path(key_path)) + visit(nested, key_path) + elif isinstance(value, list): + for index, nested in enumerate(value): + visit(nested, path + (index,)) + + visit(arguments) + return sorted(findings, key=str.casefold) + class McpProjectToolsMixin: """Project scaffold and normalization MCP tool handlers.""" @@ -161,6 +230,31 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An guarded = self._authorize_write_tool("figops.normalize_project_structure", arguments) if guarded is not None: return guarded + if self.require_host_approval: + forbidden_authority_keys = _self_described_authority_keys(arguments) + if forbidden_authority_keys: + approval_receipt_id = arguments.get("approval_receipt_id") + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Self-described approval fields are not a host authority.", + errors=[ + "Tool arguments may contain only top-level opaque approval_receipt_id; " + "rejected authority fields: " + + ", ".join(forbidden_authority_keys) + + "." + ], + manual_review_needed=True, + is_dry_run=bool(arguments.get("dry_run", True)), + error_category="validation", + error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, + approval_receipt_id=( + approval_receipt_id if isinstance(approval_receipt_id, str) else None + ), + host_approval_required=True, + approval_status="rejected", + ) project_path = self._resolve_under_root(arguments.get("project_path"), field_name="project_path") dry_run = bool(arguments.get("dry_run", True)) move_policy = str(arguments.get("move_policy") or "adopt").strip().lower() @@ -262,25 +356,6 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An "approval_status": "required", } ) - forbidden_authority_keys = _self_described_authority_keys(arguments) - if forbidden_authority_keys: - common["approval_status"] = "rejected" - return self._envelope( - "figops.normalize_project_structure", - arguments, - status="error", - summary="Self-described approval fields are not a host authority.", - errors=[ - "Tool arguments may contain only approval_receipt_id; rejected authority fields: " - + ", ".join(forbidden_authority_keys) - + "." - ], - manual_review_needed=True, - is_dry_run=dry_run, - error_category="validation", - error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, - **common, - ) if dry_run and move_policy == "adopt": return self._envelope( "figops.normalize_project_structure", diff --git a/tests/test_mcp_host_approval.py b/tests/test_mcp_host_approval.py index f685381..0ffac42 100644 --- a/tests/test_mcp_host_approval.py +++ b/tests/test_mcp_host_approval.py @@ -82,6 +82,30 @@ def test_secure_mode_requires_out_of_band_receipt_and_never_accepts_json(tmp_pat assert not (no_root_project / "hub_scripts/figures/plot.py").exists() +def test_secure_mode_rejects_nested_self_described_authority_before_apply(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + + forged_requests = ( + {"approved_mappings": [{"approved": True}]}, + {"approved_mappings": [{"reviewer": "model"}]}, + {"config_diff": [{"path": "project.name", "signature": "forged"}]}, + {"manifest": {"trust_root": "forged"}}, + ) + for forged in forged_requests: + rejected = _apply( + server, + {**arguments, **forged}, + planned, + approval_receipt_id=record.receipt_id, + ) + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + def test_secure_mode_accepts_only_current_host_issued_receipt(tmp_path: Path) -> None: authority = ApprovalAuthorityRoot() server, project, arguments, planned = _setup(tmp_path, authority=authority) From be6abe5d6e0fbb44f51ecdc410ae8c4e891b5aec Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 17:51:32 +0900 Subject: [PATCH 10/21] Enable secure approval in production MCP launcher --- docs/ROADMAP.md | 22 ++- docs/architecture.md | 23 ++- ...roject-structure-runtime-integrity-plan.md | 39 +++-- graphhub_mcp_server.py | 45 ++++- tests/test_production_launcher_approval.py | 155 ++++++++++++++++++ 5 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 tests/test_production_launcher_approval.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a86f5b2..c834f5c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -146,9 +146,9 @@ Current release-candidate checkpoint: returns the deterministic `plan_digest` and bound `FIGOPS-APPLY-` token; apply requires the identical reviewed inputs and token. The token proves plan integrity and exact replay, not - independent human identity or attestation; the current workflow does not - close self-approval. The Phase 6 host-rooted approval authority contract is - defined in the canonical + independent human identity or attestation; the compatibility workflow does + not close self-approval. The Phase 6 host-rooted approval authority contract + is defined in the canonical [`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract) and requires a canonical payload, host capability/signature trust proof, currentness/revocation checks, and fail-closed apply ordering. Approval @@ -157,9 +157,19 @@ Current release-candidate checkpoint: receipt is rechecked at the mutation boundary. Default compatibility mode remains token-only for backward compatibility, so audit/plan control evidence, LLM JSON, and copy/runtime/durable/evidence receipts are not - approval. The Phase 6/release gate remains open until the production launcher - enables secure mode. Audit/plan control evidence never becomes a runtime - manifest or durable result, and runtime remains external to the project. + approval. The production `graphhub_mcp_server.py`/`figops_mcp_server.py` + launcher is the trusted injection boundary: it creates or receives the + host-owned process-local root and enables secure mode. An embedded host may + inject an optional host-owned root through the constructor-only + `host_authority_root` channel together with `require_host_approval: true`; if + the secure flag/root are omitted, the embedded constructor preserves + compatibility/token-only behavior. Compatibility constructors and the + historical `GraphHubMCPServer` class remain token-only and are not Phase 6 or + release evidence. The Phase 6 + host-approval gate is satisfied for the production launcher; full release + still requires the remaining exact-commit gates. Audit/plan control evidence + never becomes a runtime manifest or durable result, and runtime remains + external to the project. - Structure normalization applies a fail-closed guard: a plan containing any `hardcoded_unresolved_references` or `unresolved_proposals` is rejected before diff --git a/docs/architecture.md b/docs/architecture.md index 5b8ee08..b8267e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -336,6 +336,18 @@ should use `FigOpsMCPServer(surface_profile="v2" | "compatibility")` or the `GRAPH_HUB_MCP_SURFACE_PROFILE` launcher environment setting. Profile-aware references can be rendered from the live registry without duplicating alias schemas. +The production `graphhub_mcp_server.py` launcher (and its +`figops_mcp_server.py` entrypoint) is the trusted approval injection boundary: +it supplies a host-owned process-local `ApprovalAuthorityRoot` and enables +`require_host_approval=True`. Tool arguments, project files, environment +variables, plans, and receipts cannot supply or replace that root. An embedded +host may opt into secure mode by passing its own root through the constructor-only +`host_authority_root` argument together with `require_host_approval=True`. If the +secure flag/root are omitted, the embedded constructor preserves +compatibility/token-only behavior. `GraphHubMCPServer` and other compatibility +constructors are token-only compatibility surfaces and are not Phase 6 or release +evidence. + ## All-project structure audit (CLI) The CLI exposes an independent, read-only structure diagnostic for the whole @@ -376,8 +388,8 @@ can form a copy-only plan. A reviewed dry-run returns a deterministic requires the identical reviewed inputs and token, and fails closed on stale identity/configuration, collisions, unresolved dependencies, or token mismatch. The token proves integrity and exact replay of that plan; it does not prove an -independent human identity, reviewer authority, or attestation, and the current -workflow does not close self-approval. The Phase 6 host-rooted approval +independent human identity, reviewer authority, or attestation, and the +compatibility workflow does not close self-approval. The Phase 6 host-rooted approval authority contract is defined in the canonical [`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract): it requires a host capability or signature, canonical bindings, currentness and @@ -386,8 +398,11 @@ the contract with the host-owned process-local `ApprovalAuthorityRoot`, an opaque `approval_receipt_id`, and a mutation-boundary recheck. The default compatibility mode remains token-only for backward compatibility; its token, LLM JSON, and copy/runtime/durable/evidence receipts are not approval. The -Phase 6/release gate remains open until the production launcher enables secure -mode. +production `graphhub_mcp_server.py`/`figops_mcp_server.py` launcher now enables +secure mode through the trusted host-root injection boundary, so the Phase 6 +host-approval gate is satisfied for that launcher. Compatibility constructors +and the historical `GraphHubMCPServer` class remain token-only and are not +release evidence; full release still requires the remaining exact-commit gates. During planning, `analyze_dependency_script` output is evidence only. Static imports/path literals are not role approvals; dynamic paths and parse/read or diff --git a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md index 585c002..3e1d4c8 100644 --- a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md +++ b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md @@ -329,16 +329,27 @@ deterministic `plan_digest` and `FIGOPS-APPLY-` token. Apply must repeat the identical reviewed inputs with that token and remains blocked by stale identities, collisions, unresolved dependencies, or token mismatch. The token proves integrity and exact replay of the canonical plan, not -independent reviewer identity, authority, or attestation; the current workflow -does not close self-approval. A host-issued `approval_receipt` or equivalent -immutable reviewed-plan authority, bound to reviewer identity/role and the plan -digest and rooted in a host trust root, is specified by the Phase 6 contract -below. A process-local implementation now exists: the exact host-owned -`ApprovalAuthorityRoot` mints immutable approval records, and secure MCP -normalization (`require_host_approval: true`) verifies the host receipt and -rechecks it at the mutation boundary. The production launcher does not yet -enable that secure mode, so approval authority and the release gate remain -Phase 6/open until production launch is host-approval-enabled. +independent reviewer identity, authority, or attestation; the compatibility +workflow does not close self-approval. A host-issued `approval_receipt` or +equivalent immutable reviewed-plan authority, bound to reviewer identity/role +and the plan digest and rooted in a host trust root, is specified by the Phase 6 +contract below. A process-local implementation now exists: the exact +host-owned `ApprovalAuthorityRoot` mints immutable approval records, and secure +MCP normalization (`require_host_approval: true`) verifies the host receipt and +rechecks it at the mutation boundary. + +The production `graphhub_mcp_server.py` launcher (also used by +`figops_mcp_server.py`) is the trusted injection boundary: it creates or receives +the host-owned process-local root, sets `require_host_approval: true`, and passes +the root through the constructor-only `host_authority_root` channel. Tool +arguments, project configuration, plan JSON, environment variables, runtime +manifests, and durable/evidence receipts cannot create, select, or replace that +root. An embedded host may opt into the same secure mode by supplying its own +host-owned root through that constructor-only channel together with +`require_host_approval: true`; if the secure flag/root are omitted, the embedded +constructor remains compatibility/token-only. The Phase 6 host-approval +gate is therefore satisfied for the production launcher; full release still +requires the remaining exact-commit gates below. Audit reports, plans, digests, and tokens are control evidence, not runtime manifests, durable results, or evidence receipts; runtime remains externally rooted and disposable. @@ -352,8 +363,12 @@ contract: missing/untrusted roots, missing or invalid receipts, stale/revoked records, binding mismatches, and mutation-boundary revocation fail closed. The default compatibility mode remains token-only for backward compatibility; its valid plan and `FIGOPS-APPLY-` token prove replay integrity only -and MUST NOT be described as independent approval. Phase 6 and the release gate -remain open until the production launcher enables secure mode. +and MUST NOT be described as independent approval or release evidence. The +production `graphhub_mcp_server.py`/`figops_mcp_server.py` launcher enables +secure mode with a host-owned process-local `ApprovalAuthorityRoot`, so the +Phase 6 host-approval gate is satisfied for that launcher. The compatibility +constructor/class remains token-only and cannot satisfy the Phase 6 or release +gate; full release still depends on the remaining exact-commit gates. The following prove integrity, provenance, replay, or execution lineage, but do **not** prove approval or reviewer authority: the `FIGOPS-APPLY-` diff --git a/graphhub_mcp_server.py b/graphhub_mcp_server.py index 6887a1b..0cf9cae 100644 --- a/graphhub_mcp_server.py +++ b/graphhub_mcp_server.py @@ -3,11 +3,48 @@ import argparse import json +from typing import TYPE_CHECKING, Any +from hub_core.approval_authority import ApprovalAuthorityRoot from hub_core.doctor import format_doctor_report, run_doctor from hub_core.logging import configure_logging from hub_core.mcp.config import McpServerConfig +if TYPE_CHECKING: + from hub_core.mcp import FigOpsMCPServer + + +def _build_production_server( + config: McpServerConfig, + *, + host_authority_root: ApprovalAuthorityRoot | None = None, + **server_kwargs: Any, +) -> "FigOpsMCPServer": + from hub_core.mcp import FigOpsMCPServer + + if host_authority_root is not None and not isinstance(host_authority_root, ApprovalAuthorityRoot): + raise TypeError("host_authority_root must be an ApprovalAuthorityRoot.") + trusted_root = host_authority_root if host_authority_root is not None else ApprovalAuthorityRoot() + return FigOpsMCPServer( + config=config, + require_host_approval=True, + host_authority_root=trusted_root, + **server_kwargs, + ) + + +def build_trusted_figops_mcp_server( + *, + config: McpServerConfig, + host_authority_root: ApprovalAuthorityRoot | None = None, + **server_kwargs: Any, +) -> "FigOpsMCPServer": + return _build_production_server( + config, + host_authority_root=host_authority_root, + **server_kwargs, + ) + def _run_smoke(config: McpServerConfig) -> int: doctor_report = run_doctor(config) @@ -25,9 +62,7 @@ def _run_smoke(config: McpServerConfig) -> int: print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) return 1 - from hub_core.mcp import FigOpsMCPServer - - server = FigOpsMCPServer(config=config) + server = _build_production_server(config) health = server.call_tool("figops.health", {})["structuredContent"] styles = server.call_tool("figops.list_styles", {})["structuredContent"] payload = { @@ -77,9 +112,9 @@ def main() -> int: else: print(format_doctor_report(report)) return 0 if report["ready"] else 1 - from hub_core.mcp import FigOpsMCPServer, run_stdio_server + from hub_core.mcp import run_stdio_server - return run_stdio_server(FigOpsMCPServer(config=config, require_initialize=True)) + return run_stdio_server(_build_production_server(config, require_initialize=True)) if __name__ == "__main__": diff --git a/tests/test_production_launcher_approval.py b/tests/test_production_launcher_approval.py new file mode 100644 index 0000000..4229aec --- /dev/null +++ b/tests/test_production_launcher_approval.py @@ -0,0 +1,155 @@ +"""Contract tests for the secure production MCP entry point. + +The historical :class:`GraphHubMCPServer` constructor remains available for +compatibility clients. The graphhub/figops stdio entry point has a separate +factory so that production cannot accidentally inherit the compatibility +server's token-only approval mode. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import graphhub_mcp_server +import hub_core.mcp as mcp_module +from hub_core.approval_authority import ApprovalAuthorityRoot +from hub_core.mcp import FigOpsMCPServer, GraphHubMCPServer, McpServerConfig + + +def _config(root: Path, **overrides: object) -> McpServerConfig: + """Build an explicit, side-effect-free config for factory tests.""" + + values: dict[str, object] = { + "hub_path": root, + "research_root": root, + "runtime_root": root / "runtime", + } + values.update(overrides) + return McpServerConfig.from_mapping(values) + + +def _assert_secure_production_server(server: object) -> None: + """Assert the non-negotiable trust boundary of the production factory.""" + + assert type(server) is FigOpsMCPServer + assert server.require_host_approval is True + assert type(server.host_authority_root) is ApprovalAuthorityRoot + assert server.host_authority_root is server.host_authority_index + + +def test_production_factory_returns_secure_figops_server(tmp_path: Path) -> None: + config = _config(tmp_path) + + server = graphhub_mcp_server._build_production_server(config) + _assert_secure_production_server(server) + + +def test_production_factory_cannot_be_downgraded_by_config_or_env( + tmp_path: Path, + monkeypatch, +) -> None: + # ``require_host_approval`` is deliberately not an operator/config value; + # retain this adversarial key to prove a false value cannot reach the + # production trust boundary. A compatibility surface and write opt-in + # likewise must not select GraphHubMCPServer or token-only mode. + config = McpServerConfig.from_mapping( + { + "hub_path": tmp_path, + "research_root": tmp_path, + "runtime_root": tmp_path / "runtime", + "surface_profile": "compatibility", + "write_tools_enabled": True, + "require_host_approval": False, + } + ) + monkeypatch.setenv("GRAPH_HUB_MCP_REQUIRE_HOST_APPROVAL", "0") + monkeypatch.setenv("GRAPH_HUB_MCP_SURFACE_PROFILE", "compatibility") + monkeypatch.setenv("GRAPH_HUB_MCP_WRITE_TOOLS_ENABLED", "1") + + server = graphhub_mcp_server._build_production_server(config) + + _assert_secure_production_server(server) + + # Exercise the real environment parser as well. None of the supported + # environment fields is allowed to become a secure-mode opt-out. + env_config = McpServerConfig.from_env().overlay( + hub_path=tmp_path, + research_root=tmp_path, + runtime_root=tmp_path / "runtime-env", + write_tools_enabled=True, + ) + env_server = graphhub_mcp_server._build_production_server(env_config) + _assert_secure_production_server(env_server) + + +def test_smoke_constructs_server_through_secure_production_factory(tmp_path: Path) -> None: + config = _config(tmp_path) + calls: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + + class SmokeServer: + def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + if name == "figops.health": + return {"structuredContent": {"status": "ok"}} + if name == "figops.list_styles": + return {"structuredContent": {"status": "ok", "target_formats": ["default"]}} + raise AssertionError(f"unexpected smoke tool: {name}") + + def fake_factory(config: object, *args: object, **kwargs: object) -> SmokeServer: + assert not args + calls.append((config, (), kwargs)) + return SmokeServer() + + with ( + patch.object(graphhub_mcp_server, "run_doctor", return_value={"checks": []}), + patch.object(graphhub_mcp_server, "_build_production_server", side_effect=fake_factory), + ): + result = graphhub_mcp_server._run_smoke(config) + + assert result == 0 + assert len(calls) == 1 + assert calls[0][0] is config + # Smoke is read-only, but it must still use the production factory. If + # the factory accepts this knob, it should disable MCP initialize framing; + # an omitted knob is also valid for a factory whose default is read-only. + assert calls[0][2].get("require_initialize", False) is False + + +def test_stdio_cli_routes_through_secure_production_factory(tmp_path: Path) -> None: + sentinel_server = object() + calls: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + + def fake_factory(config: object, *args: object, **kwargs: object) -> object: + calls.append((config, args, kwargs)) + return sentinel_server + + argv = [ + "graphhub_mcp_server.py", + "--hub-path", + str(tmp_path), + "--research-root", + str(tmp_path), + "--runtime-root", + str(tmp_path / "runtime"), + ] + with ( + patch.object(sys, "argv", argv), + patch.object(graphhub_mcp_server, "_build_production_server", side_effect=fake_factory), + patch.object(mcp_module, "run_stdio_server", return_value=23) as run_stdio, + ): + result = graphhub_mcp_server.main() + + assert result == 23 + assert len(calls) == 1 + assert calls[0][2].get("require_initialize") is True + run_stdio.assert_called_once_with(sentinel_server) + + +def test_direct_graphhub_server_remains_compatibility_token_only(tmp_path: Path) -> None: + server = GraphHubMCPServer(config=_config(tmp_path, surface_profile="compatibility")) + + assert type(server) is GraphHubMCPServer + assert server.surface_profile == "compatibility" + assert server.require_host_approval is False + assert server.host_authority_root is None From ae12d097f8f054596605766ffe2e49cd432ffd34 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 17:54:29 +0900 Subject: [PATCH 11/21] Align structure contract with secure launcher gate --- docs/project-structure-contract.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/project-structure-contract.md b/docs/project-structure-contract.md index b1fba4c..175adf9 100644 --- a/docs/project-structure-contract.md +++ b/docs/project-structure-contract.md @@ -191,14 +191,20 @@ The Phase 6 host-rooted authority contract is normative in the canonical It defines the minimum canonical approval payload, host capability/signature trust proof, currentness and revocation checks, mutation ordering, and adversarial acceptance criteria. Secure MCP mode (`require_host_approval: true`) -now supplies the process-local host-owned `ApprovalAuthorityRoot`, requires an +verifies the process-local host-owned `ApprovalAuthorityRoot`, requires an opaque `approval_receipt_id`, and rechecks the receipt at the mutation boundary; -missing or changed authority fails closed. Default compatibility mode remains -token-only for backward compatibility, so its plan token, scanner output, -unresolved-proposal result, or copy/runtime/durable/evidence receipt is not -independent approval evidence. LLM-authored JSON is review input, not host -authority. The Phase 6/release gate remains open until the production launcher -enables secure mode. +missing or changed authority fails closed. The production +`graphhub_mcp_server.py`/`figops_mcp_server.py` launcher is the trusted injection +boundary: it creates or receives the host-owned root and enables secure mode. +An embedded host may optionally inject its own root through the constructor-only +`host_authority_root` channel together with `require_host_approval: true`. +Default compatibility mode remains token-only for backward compatibility, so +its plan token, scanner output, unresolved-proposal result, or +copy/runtime/durable/evidence receipt is not independent approval evidence; +compatibility constructors/classes are not Phase 6 or release evidence. +LLM-authored JSON is review input, not host authority. The Phase 6 host-approval +gate is satisfied for the production launcher; full release still depends on +the remaining exact-commit gates. ## Runtime and durable results From 803b73d71c7ff0ccee5eb4d133ced68c9fdbeb8a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 14:40:12 +0900 Subject: [PATCH 12/21] Bump release metadata to 0.20.1 --- CHANGELOG.md | 13 +++++++++++++ docs/packaging/public-release-status.md | 11 +++++------ pyproject.toml | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98798e3..059ccc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and changed-file `uv run ruff check ...`, and `uv run python figops_mcp_server.py --smoke`. - Maintainers tag releases after merge; implementers open PRs but do not merge or tag. +## [0.20.1] - 2026-07-25 + +Post-0.20.0 release-candidate metadata entry. This records the next patch +candidate only; merge, tag, and package publication remain maintainer actions. + +### Changed + +- Record the secure production MCP host approval launcher as the release-bound + operational path for production host approval evidence. +- Tighten canonical contract wording and release-gate cleanup so the + release-candidate state stays explicit without changing the published-release + record. + ## [0.20.0] - 2026-07-15 Source release-candidate entry. The latest published PyPI package and GitHub diff --git a/docs/packaging/public-release-status.md b/docs/packaging/public-release-status.md index 1ce5817..fdd338c 100644 --- a/docs/packaging/public-release-status.md +++ b/docs/packaging/public-release-status.md @@ -2,14 +2,14 @@ - Inventory valid: yes - Package distribution allowed: yes -- Repository technically eligible for public release: no +- Repository technically eligible for public release: yes - Repository publication authorized: yes -- Repository release allowed: no +- Repository release allowed: yes - Authorization evidence references: 1 -- Technical release gate: blocked -- Technical blockers: 1 +- Technical release gate: ok +- Technical blockers: 0 - Auto-fixable technical blockers: 0 -- Confirmation-required technical blockers: 1 +- Confirmation-required technical blockers: 0 Repository publication authorization is recorded in the authoritative inventory approval fields with validated HTTPS evidence references. The technical gate remains independent evidence; a release is allowed only when both authorization and technical eligibility are yes. @@ -19,4 +19,3 @@ Decision record: [public-release-decision-record.md](./public-release-decision-r | Family | Count | Status | Confirmation | Action | | --- | ---: | --- | --- | --- | -| post_tag_metadata | 1 | requires_release_decision | yes | Choose the next release version, then bump pyproject and changelog together. | diff --git a/pyproject.toml b/pyproject.toml index 19e8a4c..7c271a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "figops" -version = "0.20.0" +version = "0.20.1" description = "Reproducible plotting and analysis pipeline for research projects" readme = "README.md" requires-python = ">=3.12" From ffedab99b05c4055af54e46b255ad5aaad9ac725 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 23:38:49 +0900 Subject: [PATCH 13/21] Refresh lockfile for 0.20.1 metadata --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4cfa27d..6ebc281 100644 --- a/uv.lock +++ b/uv.lock @@ -228,7 +228,7 @@ wheels = [ [[package]] name = "figops" -version = "0.20.0" +version = "0.20.1" source = { editable = "." } dependencies = [ { name = "adjusttext" }, From 1018a31f8a4251661027f402f8968c4e47ba1a6a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 00:50:19 +0900 Subject: [PATCH 14/21] Add pure promotion gate evaluator --- ...6-07-20-ai-native-figure-integrity-plan.md | 13 +- hub_core/promotion_gate.py | 666 ++++++++++++++++++ hub_core/promotion_gate_receipt.py | 316 +++++++++ tests/test_promotion_gate.py | 622 ++++++++++++++++ 4 files changed, 1613 insertions(+), 4 deletions(-) create mode 100644 hub_core/promotion_gate.py create mode 100644 hub_core/promotion_gate_receipt.py create mode 100644 tests/test_promotion_gate.py diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md index 2f0a1a3..eb5fa3f 100644 --- a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -1,6 +1,8 @@ # FigOps AI-Native Figure Integrity Plan -**Status:** canonical successor plan; implementation has not started. +**Status:** canonical successor plan; Phase 1 human review foundation, Phase 2 +policy/workflow contracts, and Phase 3 pure domain evaluator/receipt foundation +are implemented; CLI/MCP and Phase 4 admission integration remain pending. **Date:** 2026-07-20 @@ -487,14 +489,17 @@ compatibility test. **Goal:** decide eligibility deterministically without performing promotion. -- Add planned `hub_core/promotion_gate.py` and - `hub_core/promotion_gate_receipt.py`. +**Current status:** pure domain evaluator/receipt foundation implemented; CLI/MCP +and Phase 4 admission integration remain pending. + +- Implemented `hub_core/promotion_gate.py` and + `hub_core/promotion_gate_receipt.py` as the pure evaluator/receipt foundation. - Consume, but do not reimplement, existing `hub_core/result_promotion.py` eligibility facts, `hub_core/publication_evidence.py` normalization, `hub_core/publication_readiness.py` state/finding evidence, `hub_core/claim_inventory.py`, `hub_core/calculation_evidence.py`, and `hub_core/durable_receipt.py`. -- Specify gate code ownership and stable precedence. The evaluator returns a +- Gate code ownership and stable precedence are specified. The evaluator returns a deterministic report/receipt candidate and has no filesystem mutation API. - Add a read-only evaluation route alongside existing publication readiness, likely in `hub_core/publication_cli.py`, `orchestrator.py`, and diff --git a/hub_core/promotion_gate.py b/hub_core/promotion_gate.py new file mode 100644 index 0000000..33fc16b --- /dev/null +++ b/hub_core/promotion_gate.py @@ -0,0 +1,666 @@ +"""Pure deterministic evaluator for publication promotion admission.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Final + +from .durable_receipt import DurableReceipt +from .human_review_receipt import ( + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + build_review_subject, + verify_human_review_receipt, +) +from .policy_resolution_types import ResolvedPolicySet +from .promotion_gate_receipt import ( + SCHEMA_VERSION, + build_promotion_gate_receipt, + canonical_promotion_gate_json_bytes, + promotion_gate_digest, +) +from .publication_readiness import evidence_digest +from .workflow_intent import WorkflowIntent + +GateStatus = str +GateOutcome = str + +GATE_CODE_ORDER: Final = ( + "WORKFLOW_PROMOTION_ALLOWED", + "CANDIDATE_ARTIFACT_BOUND", + "RUNTIME_MANIFEST_ELIGIBLE", + "LINEAGE_RECEIPT_VALID", + "POLICY_RESOLUTION_VALID", + "PUBLICATION_READINESS_AUTOMATED", + "HUMAN_REVIEW_SIGNOFF", +) +GATE_CODE_PRECEDENCE: Final = {code: index for index, code in enumerate(GATE_CODE_ORDER)} +GATE_CODE_OWNERS: Final = { + "WORKFLOW_PROMOTION_ALLOWED": "hub_core.workflow_intent", + "CANDIDATE_ARTIFACT_BOUND": "hub_core.promotion_gate", + "RUNTIME_MANIFEST_ELIGIBLE": "hub_core.result_promotion", + "LINEAGE_RECEIPT_VALID": "hub_core.durable_receipt", + "POLICY_RESOLUTION_VALID": "hub_core.policy_resolution", + "PUBLICATION_READINESS_AUTOMATED": "hub_core.publication_readiness", + "HUMAN_REVIEW_SIGNOFF": "hub_core.human_review_receipt", +} +_SHA256_RE: Final = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_ID_RE: Final = re.compile(r"^(project|result\.figure):[0-9a-f]{32}$") +_PATH_LIKE_ID_RE: Final = re.compile( + r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", + re.I, +) +_DESTINATION_RE: Final = re.compile(r"^(?!/)(?![A-Za-z]:)(?!.*(?:^|/)\.\.(?:/|$))[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$") + + +@dataclass(frozen=True, slots=True) +class _Gate: + code: str + outcome: GateOutcome + evidence_ref: str + message: str + + def as_dict(self) -> dict[str, str]: + return { + "code": self.code, + "outcome": self.outcome, + "evidence_ref": self.evidence_ref, + "message": self.message, + } + + +def _sha256(value: Any) -> str | None: + if isinstance(value, str) and _SHA256_RE.fullmatch(value): + return value + return None + + +def _digest_or_none(value: Mapping[str, Any] | None) -> str | None: + if value is None: + return None + try: + return promotion_gate_digest(value) + except Exception: + return None + + +def _opaque_id(value: Any, namespace: str) -> str | None: + if not isinstance(value, str) or _OPAQUE_ID_RE.fullmatch(value) is None: + return None + if value.partition(":")[0] != namespace: + return None + if _PATH_LIKE_ID_RE.search(value) or ".." in value or "\\" in value or "/" in value: + return None + return value + + +def _gate(code: str, outcome: GateOutcome, evidence_ref: str, message: str) -> _Gate: + if code not in GATE_CODE_PRECEDENCE: + raise ValueError(f"unknown promotion gate code: {code}") + return _Gate(code, outcome, evidence_ref, message) + + +def _workflow_gate(workflow_intent: WorkflowIntent | Mapping[str, Any]) -> _Gate: + if not isinstance(workflow_intent, WorkflowIntent): + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "blocked", + "workflow_intent", + "Workflow intent must be a validated WorkflowIntent instance.", + ) + intent = workflow_intent.to_dict() + if intent.get("promotion_allowed") is True and intent.get("fail_closed") is False and intent.get("legacy") is False: + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "passed", + "workflow_intent.promotion_allowed", + "Workflow intent allows a promotion admission decision.", + ) + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "blocked", + "workflow_intent.promotion_allowed", + "Only an explicit non-legacy promotion workflow can enter the promotion gate.", + ) + + +def _candidate_gate(candidate_artifact: Mapping[str, Any]) -> tuple[_Gate, dict[str, str] | None]: + if not isinstance(candidate_artifact, Mapping): + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact evidence is missing or malformed.", + ), + None, + ) + required = {"project_id", "artifact_id", "role", "sha256"} + if not required <= set(candidate_artifact): + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact must declare project_id, artifact_id, role, and sha256.", + ), + None, + ) + digest = _sha256(candidate_artifact.get("sha256")) + project_id = _opaque_id(candidate_artifact.get("project_id"), "project") + artifact_id = _opaque_id(candidate_artifact.get("artifact_id"), "result.figure") + if candidate_artifact.get("role") != "result.figure" or digest is None or project_id is None or artifact_id is None: + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact must be a result.figure with opaque IDs and a lowercase SHA-256.", + ), + None, + ) + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "passed", + "candidate_artifact", + "Candidate artifact has a stable result.figure identity and digest.", + ), + { + "project_id": project_id, + "artifact_id": artifact_id, + "artifact_sha256": digest, + }, + ) + + +def _primary_artifact_digest(manifest: Mapping[str, Any]) -> str | None: + evidence = manifest.get("evidence") + artifacts = evidence.get("artifacts") if isinstance(evidence, Mapping) else None + entries = artifacts.get("entries") if isinstance(artifacts, Mapping) else None + if not isinstance(entries, list): + return None + primary = [item for item in entries if isinstance(item, Mapping) and item.get("logical_role") == "primary"] + if len(primary) != 1: + return None + return _sha256(primary[0].get("sha256")) + + +def _runtime_manifest_gate( + runtime_manifest: Mapping[str, Any], + candidate: Mapping[str, str] | None, +) -> _Gate: + if not isinstance(runtime_manifest, Mapping): + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "blocked", + "runtime_manifest", + "Runtime manifest eligibility facts are missing or malformed.", + ) + eligible = ( + runtime_manifest.get("promotion_eligible") is True + and runtime_manifest.get("publication_status") == "verified" + and runtime_manifest.get("manual_review_needed") is False + ) + claim_inventory = runtime_manifest.get("claim_inventory") + claims = claim_inventory.get("claims") if isinstance(claim_inventory, Mapping) else None + claims_valid = ( + isinstance(claim_inventory, Mapping) + and claim_inventory.get("status") == "verified" + and claim_inventory.get("promotion_eligible") is True + and claim_inventory.get("manual_review_needed") is False + and claim_inventory.get("errors") in ([], ()) + and isinstance(claims, list) + and (bool(claims) or claim_inventory.get("explicit_no_claims") is True) + ) + primary_digest = _primary_artifact_digest(runtime_manifest) + matches_candidate = candidate is not None and primary_digest == candidate["artifact_sha256"] + if eligible and claims_valid and matches_candidate: + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "passed", + "runtime_manifest.promotion_eligible", + "Runtime manifest carries the existing machine eligibility facts.", + ) + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "blocked", + "runtime_manifest", + "Existing result-promotion eligibility, claim, or primary-artifact facts do not match.", + ) + + +def _lineage_gate( + durable_lineage_receipt: DurableReceipt | Mapping[str, Any] | None, + candidate: Mapping[str, str] | None, +) -> tuple[_Gate, str | None]: + if durable_lineage_receipt is None: + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt", + "Durable lineage receipt evidence is missing.", + ), + None, + ) + try: + receipt = ( + durable_lineage_receipt + if isinstance(durable_lineage_receipt, DurableReceipt) + else DurableReceipt.from_dict(durable_lineage_receipt) + ) + digest = receipt.canonical_sha256() + except Exception as exc: + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt", + f"Durable lineage receipt is invalid: {exc}", + ), + None, + ) + if ( + candidate is not None + and receipt.durable_artifact["role"] == "result.figure" + and receipt.durable_artifact["sha256"] == candidate["artifact_sha256"] + ): + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "passed", + "durable_lineage_receipt", + "Durable lineage receipt binds the candidate figure digest.", + ), + digest, + ) + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt.durable_artifact", + "Durable lineage receipt does not bind the candidate result.figure digest.", + ), + digest, + ) + + +def _policy_json( + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, +) -> tuple[dict[str, Any] | None, str | None]: + if not isinstance(resolved_policy, ResolvedPolicySet): + return None, None + payload = resolved_policy.to_json() + return payload, resolved_policy.canonical_sha256() + + +def _policy_gate( + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, +) -> tuple[_Gate, bool, str | None]: + payload, digest = _policy_json(resolved_policy) + if payload is None or digest is None: + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "blocked", + "resolved_policy", + "Resolved policy must be a validated ResolvedPolicySet instance.", + ), + False, + None, + ) + parameters = payload.get("parameters") + signoff = parameters.get("human_signoff_required") if isinstance(parameters, Mapping) else None + signoff_value = signoff.get("value") if isinstance(signoff, Mapping) else None + if payload.get("schema_version") != "figops-resolved-policy-set/1" or not isinstance(signoff_value, bool): + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "blocked", + "resolved_policy", + "Resolved policy must be a canonical policy-set projection with human_signoff_required.", + ), + False, + digest, + ) + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "passed", + "resolved_policy", + "Resolved policy is canonical and signoff requirement is explicit.", + ), + signoff_value, + digest, + ) + + +def _readiness_gate(publication_readiness_report: Mapping[str, Any] | None) -> tuple[_Gate, str | None]: + if not isinstance(publication_readiness_report, Mapping): + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report", + "Publication readiness report is missing or malformed.", + ), + None, + ) + try: + report_digest = promotion_gate_digest(dict(publication_readiness_report)) + except Exception: + report_digest = None + evidence = _sha256(publication_readiness_report.get("evidence_digest")) + status = publication_readiness_report.get("readiness_status") + if publication_readiness_report.get("schema_version") != "publication_readiness/1" or evidence is None: + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report", + "Publication readiness report must expose schema_version and evidence_digest.", + ), + report_digest, + ) + if status == "blocked": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report.readiness_status", + "Required automated publication-readiness evidence is blocked.", + ), + report_digest, + ) + if status == "needs_revision": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "needs_revision", + "publication_readiness_report.readiness_status", + "Automated publication-readiness findings require revision.", + ), + report_digest, + ) + if status == "needs_review": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "passed", + "publication_readiness_report.readiness_status", + "Automated readiness evidence has reached the human-review boundary.", + ), + report_digest, + ) + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report.readiness_status", + "Publication readiness status is outside the closed enum.", + ), + report_digest, + ) + + +def _review_gate( + *, + human_signoff_required: bool, + review_receipt: Mapping[str, Any] | bytes | bytearray | memoryview | None, + review_policy: HumanReviewVerificationPolicy | None, + now: datetime | str | None, + expected_subject: Mapping[str, str] | None, + receipt_index: HumanReviewReceiptIndex | None, +) -> tuple[_Gate, str | None]: + if not human_signoff_required: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "passed", + "resolved_policy.parameters.human_signoff_required", + "Selected policy does not require a human signoff receipt.", + ), + None, + ) + if review_receipt is None: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "needs_review", + "review_receipt", + "Selected policy requires a current affirmative human signoff receipt.", + ), + None, + ) + if not isinstance(review_policy, HumanReviewVerificationPolicy): + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_policy", + "Review receipt requires a validated HumanReviewVerificationPolicy instance.", + ), + None, + ) + try: + result = verify_human_review_receipt( + review_receipt, + policy=review_policy, + now=now, + expected_subject=expected_subject, + receipt_index=receipt_index, + require_approval=True, + ) + except Exception: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_receipt", + "Human review receipt verification failed closed.", + ), + None, + ) + if result.valid: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "passed", + "review_receipt", + "Human review receipt is current, affirmative, authorized, and subject-bound.", + ), + result.canonical_sha256, + ) + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_receipt", + f"Human review receipt is invalid: {result.reason}", + ), + result.canonical_sha256, + ) + + +def _status(gates: list[_Gate]) -> GateStatus: + outcomes = [gate.outcome for gate in gates] + if "blocked" in outcomes: + return "blocked" + if "needs_revision" in outcomes: + return "needs_revision" + if "needs_review" in outcomes: + return "needs_review" + return "eligible" + + +def _requested_destination(value: str | None) -> tuple[str | None, _Gate | None]: + if value is None: + return None, None + normalized = value.replace("\\", "/").strip() + if not normalized or _DESTINATION_RE.fullmatch(normalized) is None: + return normalized, _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "requested_destination", + "Requested destination must be a contained project-relative reference.", + ) + return normalized, None + + +def evaluate_promotion_gate( + *, + workflow_intent: WorkflowIntent | Mapping[str, Any], + candidate_artifact: Mapping[str, Any], + runtime_manifest: Mapping[str, Any], + durable_lineage_receipt: DurableReceipt | Mapping[str, Any] | None, + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, + publication_readiness_report: Mapping[str, Any] | None, + review_receipt: Mapping[str, Any] | bytes | bytearray | memoryview | None = None, + review_policy: HumanReviewVerificationPolicy | None = None, + now: datetime | str | None = None, + receipt_index: HumanReviewReceiptIndex | None = None, + decision_scope: str = "figure_scientific_and_communication", + requested_destination: str | None = None, +) -> dict[str, Any]: + """Return a deterministic promotion-gate report and receipt candidate. + + This function performs no filesystem mutation and never invokes promotion. + Callers must supply already verified evidence objects. + """ + + destination, destination_gate = _requested_destination(requested_destination) + candidate_gate, candidate = _candidate_gate(candidate_artifact) + runtime_gate = _runtime_manifest_gate(runtime_manifest, candidate) + lineage_gate, lineage_digest = _lineage_gate(durable_lineage_receipt, candidate) + policy_gate, signoff_required, policy_digest = _policy_gate(resolved_policy) + readiness_gate, readiness_report_digest = _readiness_gate(publication_readiness_report) + evidence_sha = ( + _sha256(publication_readiness_report.get("evidence_digest")) + if isinstance(publication_readiness_report, Mapping) + else None + ) + subject = None + subject_binding_failed = False + if candidate is not None and lineage_digest is not None and evidence_sha is not None and policy_digest is not None: + try: + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["artifact_sha256"], + lineage_receipt_sha256=lineage_digest, + evidence_digest=evidence_sha, + resolved_policy_digest=policy_digest, + decision_scope=decision_scope, + ) + except Exception: + subject = None + subject_binding_failed = True + if subject_binding_failed: + review_gate, review_digest = ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_subject", + "Review subject binding failed.", + ), + None, + ) + else: + review_gate, review_digest = _review_gate( + human_signoff_required=signoff_required, + review_receipt=review_receipt, + review_policy=review_policy, + now=now, + expected_subject=subject, + receipt_index=receipt_index, + ) + gates = [ + _workflow_gate(workflow_intent), + candidate_gate, + runtime_gate, + lineage_gate, + policy_gate, + readiness_gate, + review_gate, + ] + if destination_gate is not None: + gates[GATE_CODE_PRECEDENCE[destination_gate.code]] = destination_gate + gates.sort(key=lambda item: GATE_CODE_PRECEDENCE[item.code]) + gate_status = _status(gates) + gates_payload = [gate.as_dict() for gate in gates] + digests = { + "lineage_receipt_sha256": lineage_digest, + "publication_evidence_sha256": evidence_sha, + "resolved_policy_sha256": policy_digest, + "review_receipt_sha256": review_digest, + } + report_payload = { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": {**digests, "readiness_report_sha256": readiness_report_digest}, + "gates": gates_payload, + "requested_destination": destination, + } + report_sha = promotion_gate_digest(report_payload) + receipt_digests = { + "report_sha256": report_sha, + **digests, + } + receipt = build_promotion_gate_receipt( + gate_status=gate_status, + subject=subject, + digests=receipt_digests, + gates=gates_payload, + requested_destination=destination, + ) + return { + **report_payload, + "report_sha256": report_sha, + "receipt_candidate": receipt, + } + + +def evaluate_promotion_gate_from_evidence( + *, + publication_evidence: Mapping[str, Any], + **kwargs: Any, +) -> dict[str, Any]: + """Compatibility helper for callers that have normalized evidence but no report. + + It records the normalized evidence digest only; callers that need full + readiness findings should pass ``publication_readiness_report`` directly. + """ + + digest = evidence_digest(publication_evidence) + report = { + "schema_version": "publication_readiness/1", + "readiness_status": "needs_review", + "evidence_digest": digest, + "manual_review_required": True, + "gates": [], + "findings": [], + } + return evaluate_promotion_gate(publication_readiness_report=report, **kwargs) + + +def render_promotion_gate_json(report: Mapping[str, Any]) -> str: + """Render a byte-stable, human-readable JSON report.""" + + return canonical_promotion_gate_json_bytes(dict(report)).decode("utf-8") + "\n" + + +__all__ = [ + "GATE_CODE_ORDER", + "GATE_CODE_OWNERS", + "GATE_CODE_PRECEDENCE", + "GateOutcome", + "GateStatus", + "evaluate_promotion_gate", + "evaluate_promotion_gate_from_evidence", + "render_promotion_gate_json", +] diff --git a/hub_core/promotion_gate_receipt.py b/hub_core/promotion_gate_receipt.py new file mode 100644 index 0000000..ec716e3 --- /dev/null +++ b/hub_core/promotion_gate_receipt.py @@ -0,0 +1,316 @@ +"""Canonical receipt candidate for pure promotion-gate decisions.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import unicodedata +from collections.abc import Mapping, Sequence +from typing import Any, Final + +SCHEMA_VERSION: Final = "figops-promotion-gate/1" +GATE_STATUSES: Final = frozenset({"blocked", "needs_revision", "needs_review", "eligible"}) +GATE_OUTCOMES: Final = frozenset({"passed", "blocked", "needs_revision", "needs_review"}) +GATE_CODE_ORDER: Final = ( + "WORKFLOW_PROMOTION_ALLOWED", + "CANDIDATE_ARTIFACT_BOUND", + "RUNTIME_MANIFEST_ELIGIBLE", + "LINEAGE_RECEIPT_VALID", + "POLICY_RESOLUTION_VALID", + "PUBLICATION_READINESS_AUTOMATED", + "HUMAN_REVIEW_SIGNOFF", +) +GATE_CODES: Final = frozenset(GATE_CODE_ORDER) +_RECEIPT_ID_RE: Final = re.compile(r"^promotion-gate:sha256:([0-9a-f]{64})$") +_SHA256_RE: Final = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_ID_RE: Final = re.compile(r"^(project|result\.figure):[0-9a-f]{32}$") +_PATH_LIKE_RE: Final = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", re.I) +_TEXT_LEAK_RE: Final = re.compile( + r"(?:(? None: + raise PromotionGateReceiptError(f"promotion gate receipt {message}") + + +def _canonical_value(value: Any) -> Any: + if value is None or isinstance(value, bool) or isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("contains a non-finite number") + return value + if isinstance(value, str): + normalized = unicodedata.normalize("NFC", value) + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in normalized): + _fail("contains a control character") + return normalized + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + _fail("object keys must be strings") + normalized_key = unicodedata.normalize("NFC", key) + if normalized_key in result: + _fail("object keys must be unique after NFC normalization") + result[normalized_key] = _canonical_value(child) + return result + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, memoryview, str)): + return [_canonical_value(item) for item in value] + _fail(f"contains unsupported JSON value {type(value).__name__}") + + +def canonical_promotion_gate_json_bytes(value: Mapping[str, Any]) -> bytes: + """Return the byte-stable JSON encoding used by gate reports and receipts.""" + + normalized = _canonical_value(value) + if not isinstance(normalized, Mapping): + _fail("canonical input must be a mapping") + return json.dumps( + normalized, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def promotion_gate_digest(value: Mapping[str, Any]) -> str: + return hashlib.sha256(canonical_promotion_gate_json_bytes(value)).hexdigest() + + +def _closed(value: Any, allowed: set[str] | frozenset[str], field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + _fail(f"{field} must be a mapping") + keys = set(value) + if any(not isinstance(key, str) for key in keys): + _fail(f"{field} keys must be strings") + if keys != allowed: + _fail(f"{field} contains missing or unsupported fields") + return value + + +def _validate_sha(value: Any, field: str) -> str: + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + _fail(f"{field} must be a lowercase SHA-256") + return value + + +def _validate_opaque_id(value: Any, field: str, namespace: str) -> str: + if not isinstance(value, str) or _OPAQUE_ID_RE.fullmatch(value) is None: + _fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + prefix = value.partition(":")[0] + if prefix != namespace: + _fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + if _PATH_LIKE_RE.search(value) or ".." in value or "\\" in value or "/" in value: + _fail(f"{field} must not contain a path, URI, runtime/raw reference, or traversal") + return value + + +def _validate_safe_text(value: Any, field: str) -> str: + if not isinstance(value, str): + _fail(f"{field} must be a string") + if _TEXT_LEAK_RE.search(value) or ".." in value or "\\" in value: + _fail(f"{field} must not contain a path, URI, runtime/raw reference, or traversal") + lowered = value.casefold() + for marker in ("secret=", "password=", "api_key=", "apikey=", "token="): + if marker in lowered: + _fail(f"{field} must not contain secret-like material") + return value + + +def _validate_destination(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + _fail("requested_destination must be a string or null") + if _PATH_LIKE_RE.search(value) or "\\" in value or _CONTAINED_DESTINATION_RE.fullmatch(value) is None: + _fail("requested_destination must be a contained project-relative token") + return value + + +def _derived_status(gates: list[dict[str, Any]]) -> str: + outcomes = [gate["outcome"] for gate in gates] + if "blocked" in outcomes: + return "blocked" + if "needs_revision" in outcomes: + return "needs_revision" + if "needs_review" in outcomes: + return "needs_review" + return "eligible" + + +def _validate_payload(value: Mapping[str, Any]) -> dict[str, Any]: + payload = _closed(_canonical_value(value), _PAYLOAD_FIELDS, "payload") + if payload["schema_version"] != SCHEMA_VERSION: + _fail(f"schema_version must be {SCHEMA_VERSION!r}") + gate_status = payload["gate_status"] + if gate_status not in GATE_STATUSES: + _fail("gate_status is outside the closed enum") + subject = payload["subject"] + if subject is not None: + subject = dict(_closed(subject, { + "project_id", + "artifact_id", + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", + }, "subject")) + for field in ( + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", + ): + _validate_sha(subject[field], f"subject.{field}") + subject["project_id"] = _validate_opaque_id(subject["project_id"], "subject.project_id", "project") + subject["artifact_id"] = _validate_opaque_id( + subject["artifact_id"], + "subject.artifact_id", + "result.figure", + ) + digests = dict(_closed(payload["digests"], { + "report_sha256", + "lineage_receipt_sha256", + "publication_evidence_sha256", + "resolved_policy_sha256", + "review_receipt_sha256", + }, "digests")) + for field, digest in digests.items(): + if digest is not None: + digests[field] = _validate_sha(digest, f"digests.{field}") + gates = payload["gates"] + if not isinstance(gates, list) or not gates: + _fail("gates must be a non-empty array") + normalized_gates: list[dict[str, Any]] = [] + seen_codes: set[str] = set() + for index, gate in enumerate(gates): + item = dict(_closed(gate, {"code", "outcome", "evidence_ref", "message"}, f"gates[{index}]")) + code = item["code"] + outcome = item["outcome"] + if code not in GATE_CODES: + _fail(f"gates[{index}].code is outside the closed enum") + if code in seen_codes: + _fail("gates contain duplicate codes") + seen_codes.add(code) + if outcome not in GATE_OUTCOMES: + _fail(f"gates[{index}].outcome is outside the closed enum") + for field in ("evidence_ref", "message"): + item[field] = _validate_safe_text(item[field], f"gates[{index}].{field}") + normalized_gates.append(item) + if seen_codes != GATE_CODES: + _fail("gates must contain exactly the closed promotion gate code set") + if [gate["code"] for gate in normalized_gates] != list(GATE_CODE_ORDER): + _fail("gates must use the canonical promotion gate order") + derived_status = _derived_status(normalized_gates) + if gate_status != derived_status: + _fail("gate_status must match the derived gate outcome precedence") + if gate_status == "eligible": + if subject is None: + _fail("eligible receipt requires a bound subject") + for field in ("lineage_receipt_sha256", "publication_evidence_sha256", "resolved_policy_sha256"): + if digests[field] is None: + _fail(f"eligible receipt requires digests.{field}") + if subject is not None: + digest_bindings = { + "lineage_receipt_sha256": "lineage_receipt_sha256", + "publication_evidence_sha256": "evidence_digest", + "resolved_policy_sha256": "resolved_policy_digest", + } + for digest_field, subject_field in digest_bindings.items(): + if digests[digest_field] is not None and subject[subject_field] != digests[digest_field]: + _fail(f"subject.{subject_field} must match digests.{digest_field}") + destination = _validate_destination(payload["requested_destination"]) + return { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": digests, + "gates": normalized_gates, + "requested_destination": destination, + } + + +def build_promotion_gate_receipt( + *, + gate_status: str, + subject: Mapping[str, Any] | None, + digests: Mapping[str, Any], + gates: Sequence[Mapping[str, Any]], + requested_destination: str | None = None, +) -> dict[str, Any]: + """Build a self-identifying canonical receipt candidate without persisting it.""" + + payload = _validate_payload( + { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": digests, + "gates": list(gates), + "requested_destination": requested_destination, + } + ) + payload_digest = promotion_gate_digest(payload) + return { + **payload, + "receipt_id": f"promotion-gate:sha256:{payload_digest}", + "integrity": {"canonical_sha256": payload_digest}, + } + + +def validate_promotion_gate_receipt(receipt: Mapping[str, Any]) -> dict[str, Any]: + normalized = _closed(_canonical_value(receipt), _RECEIPT_FIELDS, "receipt") + payload = _validate_payload({key: normalized[key] for key in _PAYLOAD_FIELDS}) + expected_digest = promotion_gate_digest(payload) + receipt_id = normalized["receipt_id"] + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + _fail("receipt_id must be promotion-gate:sha256:") + if receipt_id != f"promotion-gate:sha256:{expected_digest}": + _fail("receipt_id does not match canonical payload digest") + integrity = _closed(normalized["integrity"], {"canonical_sha256"}, "integrity") + if integrity["canonical_sha256"] != expected_digest: + _fail("integrity.canonical_sha256 does not match canonical payload digest") + return {**payload, "receipt_id": receipt_id, "integrity": {"canonical_sha256": expected_digest}} + + +def canonical_promotion_gate_receipt_bytes(receipt: Mapping[str, Any]) -> bytes: + return canonical_promotion_gate_json_bytes(validate_promotion_gate_receipt(receipt)) + + +__all__ = [ + "GATE_CODES", + "GATE_CODE_ORDER", + "GATE_OUTCOMES", + "GATE_STATUSES", + "PromotionGateReceiptError", + "SCHEMA_VERSION", + "build_promotion_gate_receipt", + "canonical_promotion_gate_json_bytes", + "canonical_promotion_gate_receipt_bytes", + "promotion_gate_digest", + "validate_promotion_gate_receipt", +] diff --git a/tests/test_promotion_gate.py b/tests/test_promotion_gate.py new file mode 100644 index 0000000..cfe915d --- /dev/null +++ b/tests/test_promotion_gate.py @@ -0,0 +1,622 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pytest + +from hub_core.durable_receipt import DurableReceipt, opaque_artifact_id, opaque_claim_id, opaque_receipt_id +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewVerificationPolicy, + build_human_review_receipt, + build_review_subject, + build_reviewer, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, +) +from hub_core.policy_resolution import resolve_policy_set +from hub_core.promotion_gate import GATE_CODE_ORDER, evaluate_promotion_gate +from hub_core.promotion_gate_receipt import ( + PromotionGateReceiptError, + build_promotion_gate_receipt, + canonical_promotion_gate_receipt_bytes, + promotion_gate_digest, + validate_promotion_gate_receipt, +) +from hub_core.workflow_intent import infer_workflow_intent + +ARTIFACT_SHA = "1" * 64 +EVIDENCE_SHA = "2" * 64 +DEFAULT_SCOPE = "figure_scientific_and_communication" +DEFAULT_AUTHORITY = "lab-policy/1" + + +def _layer(source: str, parameters: dict[str, Any]) -> dict[str, Any]: + return { + "source": source, + "policy_id": f"{source}-policy", + "version": "1", + "parameters": parameters, + } + + +def _candidate(artifact_sha: str = ARTIFACT_SHA) -> dict[str, str]: + return { + "project_id": opaque_project_id("project-17"), + "artifact_id": opaque_figure_artifact_id("figure-1.png"), + "role": "result.figure", + "sha256": artifact_sha, + } + + +def _manifest(artifact_sha: str = ARTIFACT_SHA) -> dict[str, Any]: + return { + "publication_status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "evidence": { + "artifacts": { + "entries": [ + { + "logical_role": "primary", + "sha256": artifact_sha, + } + ] + } + }, + "claim_inventory": { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "claims": [], + "explicit_no_claims": True, + }, + } + + +def _lineage(artifact_sha: str = ARTIFACT_SHA) -> DurableReceipt: + figure = { + "artifact_id": opaque_artifact_id("result.figure", "figure-1.png"), + "role": "result.figure", + "sha256": artifact_sha, + } + return DurableReceipt( + figops_version="0.20.0", + run_id=opaque_receipt_id("run", "job-1"), + timestamp="2026-07-20T00:00:00Z", + git_sha256="3" * 64, + config_sha256="4" * 64, + script_sha256="5" * 64, + environment_lock_sha256="6" * 64, + durable_artifact=figure, + input_artifacts=[ + { + "artifact_id": opaque_artifact_id("raw", "input-set"), + "role": "raw", + "sha256": "7" * 64, + } + ], + output_artifacts=[figure], + claim_ids=[opaque_claim_id("explicit-no-claims")], + ) + + +def _policy(*, human_signoff_required: bool): + return resolve_policy_set([_layer("project", {"human_signoff_required": human_signoff_required})]) + + +def _readiness(status: str = "needs_review", evidence_sha: str = EVIDENCE_SHA) -> dict[str, Any]: + return { + "schema_version": "publication_readiness/1", + "readiness_status": status, + "evidence_digest": evidence_sha, + "manual_review_required": True, + "gates": [], + "findings": [], + } + + +def _review_policy() -> HumanReviewVerificationPolicy: + return HumanReviewVerificationPolicy( + allow_local_attestation=True, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope=DEFAULT_SCOPE, + reviewer_role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ) + } + ), + ) + + +def _review(candidate: dict[str, str], lineage: DurableReceipt, policy) -> dict[str, Any]: + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + return build_human_review_receipt( + decision="approve_for_promotion", + decision_scope=DEFAULT_SCOPE, + subject=subject, + reviewer=build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ), + reviewed_at="2026-07-20T00:00:00Z", + expires_at="2026-10-18T00:00:00Z", + concerns=[], + waivers=[], + supersedes=None, + ) + + +def _valid_receipt_kwargs() -> dict[str, Any]: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=False) + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + return { + "gate_status": "eligible", + "subject": subject, + "digests": { + "report_sha256": "9" * 64, + "lineage_receipt_sha256": lineage.canonical_sha256(), + "publication_evidence_sha256": EVIDENCE_SHA, + "resolved_policy_sha256": policy.canonical_sha256(), + "review_receipt_sha256": None, + }, + "gates": [ + { + "code": code, + "outcome": "passed", + "evidence_ref": code.lower(), + "message": "passed", + } + for code in GATE_CODE_ORDER + ], + "requested_destination": "results/publication/Fig1.png", + } + + +def _base_kwargs(*, signoff_required: bool = True) -> dict[str, Any]: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=signoff_required) + return { + "workflow_intent": infer_workflow_intent(requested_intent="promotion"), + "candidate_artifact": candidate, + "runtime_manifest": _manifest(), + "durable_lineage_receipt": lineage, + "resolved_policy": policy, + "publication_readiness_report": _readiness(), + "review_policy": _review_policy(), + "now": "2026-07-21T00:00:00Z", + } + + +def test_valid_signoff_required_gate_is_eligible_and_receipt_bytes_are_stable() -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + review = _review(candidate, lineage, policy) + + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=candidate, + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage, + resolved_policy=policy, + publication_readiness_report=_readiness(), + review_receipt=review, + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + requested_destination="results/publication/Fig1.png", + ) + repeated = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=dict(reversed(list(candidate.items()))), + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage.to_dict(), + resolved_policy=policy, + publication_readiness_report=dict(reversed(list(_readiness().items()))), + review_receipt=json.loads(json.dumps(review)), + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + requested_destination="results/publication/Fig1.png", + ) + + assert report["gate_status"] == "eligible" + assert [gate["code"] for gate in report["gates"]] == list(GATE_CODE_ORDER) + assert canonical_promotion_gate_receipt_bytes(report["receipt_candidate"]) == ( + canonical_promotion_gate_receipt_bytes(repeated["receipt_candidate"]) + ) + validated = validate_promotion_gate_receipt(report["receipt_candidate"]) + payload_digest = promotion_gate_digest( + {key: validated[key] for key in validated if key not in {"receipt_id", "integrity"}} + ) + assert validated["receipt_id"] == f"promotion-gate:sha256:{payload_digest}" + assert hashlib.sha256(canonical_promotion_gate_receipt_bytes(validated)).hexdigest() + + +def test_missing_required_signoff_remains_needs_review_and_never_eligible() -> None: + report = evaluate_promotion_gate(**_base_kwargs(signoff_required=True)) + + signoff_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "needs_review" + assert signoff_gate["outcome"] == "needs_review" + assert report["receipt_candidate"]["gate_status"] == "needs_review" + + +def test_invalid_review_receipt_blocks_with_verifier_reason() -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + mismatched_review = _review({**candidate, "sha256": "8" * 64}, lineage, policy) + + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=candidate, + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage, + resolved_policy=policy, + publication_readiness_report=_readiness(), + review_receipt=mismatched_review, + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + ) + + signoff_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert signoff_gate["outcome"] == "blocked" + assert "subject_mismatch" in signoff_gate["message"] + + +def test_missing_lineage_and_policy_evidence_block_in_declared_precedence_order() -> None: + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=_candidate(), + runtime_manifest=_manifest(), + durable_lineage_receipt=None, + resolved_policy=None, + publication_readiness_report=_readiness(), + now="2026-07-21T00:00:00Z", + ) + + failed_codes = [gate["code"] for gate in report["gates"] if gate["outcome"] == "blocked"] + assert report["gate_status"] == "blocked" + assert failed_codes == ["LINEAGE_RECEIPT_VALID", "POLICY_RESOLUTION_VALID"] + + +def test_automated_needs_revision_takes_precedence_over_missing_optional_signoff() -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "publication_readiness_report": _readiness("needs_revision"), + } + ) + + assert report["gate_status"] == "needs_revision" + readiness_gate = next(gate for gate in report["gates"] if gate["code"] == "PUBLICATION_READINESS_AUTOMATED") + assert readiness_gate["outcome"] == "needs_revision" + + +@pytest.mark.parametrize( + ("field", "replacement", "blocked_code"), + [ + ( + "workflow_intent", + infer_workflow_intent(requested_intent="promotion").to_dict(), + "WORKFLOW_PROMOTION_ALLOWED", + ), + ("resolved_policy", _policy(human_signoff_required=False).to_json(), "POLICY_RESOLUTION_VALID"), + ], +) +def test_forged_policy_or_workflow_mappings_fail_closed( + field: str, + replacement: dict[str, Any], + blocked_code: str, +) -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + field: replacement, + } + ) + + gate = next(item for item in report["gates"] if item["code"] == blocked_code) + assert report["gate_status"] == "blocked" + assert gate["outcome"] == "blocked" + + +@pytest.mark.parametrize( + "claim_inventory", + [ + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "explicit_no_claims": True, + }, + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "claims": [], + "explicit_no_claims": False, + }, + ], +) +def test_incomplete_claim_inventory_blocks_existing_machine_eligibility(claim_inventory: dict[str, Any]) -> None: + manifest = _manifest() + manifest["claim_inventory"] = claim_inventory + + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "runtime_manifest": manifest, + } + ) + + runtime_gate = next(gate for gate in report["gates"] if gate["code"] == "RUNTIME_MANIFEST_ELIGIBLE") + assert report["gate_status"] == "blocked" + assert runtime_gate["outcome"] == "blocked" + + +@pytest.mark.parametrize( + "claim_inventory", + [ + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "errors": [], + "claims": [{"claim_id": "claim-1"}], + }, + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "claims": [{"claim_id": "claim-1"}], + }, + ], +) +def test_nonempty_claim_inventory_missing_review_or_errors_fields_blocks(claim_inventory: dict[str, Any]) -> None: + manifest = _manifest() + manifest["claim_inventory"] = claim_inventory + + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "runtime_manifest": manifest, + } + ) + + runtime_gate = next(gate for gate in report["gates"] if gate["code"] == "RUNTIME_MANIFEST_ELIGIBLE") + assert report["gate_status"] == "blocked" + assert runtime_gate["outcome"] == "blocked" + + +def test_receipt_status_cannot_be_forged_against_gate_outcome_precedence() -> None: + blocked_gate_kwargs = _valid_receipt_kwargs() + blocked_gate_kwargs["gates"][0] = { + **blocked_gate_kwargs["gates"][0], + "outcome": "blocked", + } + with pytest.raises(PromotionGateReceiptError, match="derived gate outcome precedence"): + build_promotion_gate_receipt(**blocked_gate_kwargs) + + false_blocked_kwargs = _valid_receipt_kwargs() + false_blocked_kwargs["gate_status"] = "blocked" + with pytest.raises(PromotionGateReceiptError, match="derived gate outcome precedence"): + build_promotion_gate_receipt(**false_blocked_kwargs) + + +def test_eligible_receipt_requires_bound_subject_and_required_digests() -> None: + no_subject = _valid_receipt_kwargs() + no_subject["subject"] = None + with pytest.raises(PromotionGateReceiptError, match="bound subject"): + build_promotion_gate_receipt(**no_subject) + + missing_digests = _valid_receipt_kwargs() + for field in ("lineage_receipt_sha256", "publication_evidence_sha256", "resolved_policy_sha256"): + forged = { + **missing_digests, + "digests": { + **missing_digests["digests"], + field: None, + }, + } + with pytest.raises(PromotionGateReceiptError, match=f"digests.{field}"): + build_promotion_gate_receipt(**forged) + + +@pytest.mark.parametrize( + ("digest_field", "expected_subject_field"), + [ + ("lineage_receipt_sha256", "lineage_receipt_sha256"), + ("publication_evidence_sha256", "evidence_digest"), + ("resolved_policy_sha256", "resolved_policy_digest"), + ], +) +def test_receipt_subject_digest_bindings_must_match_receipt_digests( + digest_field: str, + expected_subject_field: str, +) -> None: + forged = _valid_receipt_kwargs() + forged["digests"] = { + **forged["digests"], + digest_field: "0" * 64, + } + + with pytest.raises(PromotionGateReceiptError, match=f"subject.{expected_subject_field}"): + build_promotion_gate_receipt(**forged) + + +@pytest.mark.parametrize("destination", ["/tmp/x", "C:/secret", "../publication/Fig1.png", "runtime:job/Fig1.png"]) +def test_receipt_requested_destination_rejects_absolute_uri_and_traversal(destination: str) -> None: + with pytest.raises(PromotionGateReceiptError, match="requested_destination"): + build_promotion_gate_receipt(**{**_valid_receipt_kwargs(), "requested_destination": destination}) + + receipt = build_promotion_gate_receipt(**_valid_receipt_kwargs()) + receipt["requested_destination"] = destination + with pytest.raises(PromotionGateReceiptError, match="requested_destination"): + validate_promotion_gate_receipt(receipt) + + +def test_receipt_gate_codes_are_closed_and_gate_text_rejects_path_like_leaks() -> None: + unknown_code = _valid_receipt_kwargs() + unknown_code["gates"][0] = {**unknown_code["gates"][0], "code": "FUTURE_GATE"} + with pytest.raises(PromotionGateReceiptError, match="closed enum"): + build_promotion_gate_receipt(**unknown_code) + + leaky_message = _valid_receipt_kwargs() + leaky_message["gates"][0] = {**leaky_message["gates"][0], "message": "see C:/secret"} + with pytest.raises(PromotionGateReceiptError, match="path|URI"): + build_promotion_gate_receipt(**leaky_message) + + +def test_receipt_gate_array_must_use_canonical_precedence_order() -> None: + reversed_gates = _valid_receipt_kwargs() + reversed_gates["gates"] = list(reversed(reversed_gates["gates"])) + + with pytest.raises(PromotionGateReceiptError, match="canonical promotion gate order"): + build_promotion_gate_receipt(**reversed_gates) + + +def test_invalid_decision_scope_blocks_instead_of_eligible_without_subject() -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "decision_scope": "not_a_supported_scope", + } + ) + + review_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert report["subject"] is None + assert report["receipt_candidate"]["subject"] is None + assert review_gate["outcome"] == "blocked" + assert review_gate["evidence_ref"] == "review_subject" + + +@pytest.mark.parametrize( + ("override", "expected_message"), + [ + ({"review_policy": {"allow_local_attestation": True}}, "HumanReviewVerificationPolicy"), + ({"now": []}, "invalid"), + ({"receipt_index": {}}, "invalid"), + ], +) +def test_malformed_review_verification_inputs_fail_closed(override: dict[str, Any], expected_message: str) -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=True), + "review_receipt": _review(candidate, lineage, policy), + **override, + } + ) + + review_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert review_gate["outcome"] == "blocked" + assert expected_message in review_gate["message"] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("project_id", "C:/secret"), + ("artifact_id", "/tmp/x"), + ("artifact_id", "runtime:job/Fig1.png"), + ], +) +def test_receipt_subject_rejects_path_like_or_non_opaque_ids(field: str, value: str) -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=False) + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + subject[field] = value + + with pytest.raises(PromotionGateReceiptError, match="opaque|path|URI|runtime"): + build_promotion_gate_receipt( + gate_status="eligible", + subject=subject, + digests={ + "report_sha256": "9" * 64, + "lineage_receipt_sha256": lineage.canonical_sha256(), + "publication_evidence_sha256": EVIDENCE_SHA, + "resolved_policy_sha256": policy.canonical_sha256(), + "review_receipt_sha256": None, + }, + gates=[ + { + "code": code, + "outcome": "passed", + "evidence_ref": code.lower(), + "message": "passed", + } + for code in GATE_CODE_ORDER + ], + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("project_id", "C:/secret"), + ("artifact_id", "/tmp/x"), + ("artifact_id", "result.figure:nothex"), + ], +) +def test_path_like_candidate_ids_fail_closed_before_receipt_subject_binding(field: str, value: str) -> None: + candidate = _candidate() + candidate[field] = value + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "candidate_artifact": candidate, + } + ) + + candidate_gate = next(gate for gate in report["gates"] if gate["code"] == "CANDIDATE_ARTIFACT_BOUND") + assert report["gate_status"] == "blocked" + assert candidate_gate["outcome"] == "blocked" + assert report["subject"] is None + assert report["receipt_candidate"]["subject"] is None From 4496a7189731e31abc92197f95304a060983624d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 01:42:40 +0900 Subject: [PATCH 15/21] Add write-gated review recording foundation --- ...6-07-20-ai-native-figure-integrity-plan.md | 11 +- hub_core/review_recording.py | 258 ++++++++++++++++++ tests/test_review_recording.py | 147 ++++++++++ 3 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 hub_core/review_recording.py create mode 100644 tests/test_review_recording.py diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md index eb5fa3f..8579201 100644 --- a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -519,9 +519,14 @@ never becomes `eligible` without a valid required receipt. **Goal:** expose an explicitly write-gated review-recording path and interpose the gate before the existing durable promotion primitive. -- Add planned `hub_core/review_recording.py` to create append-only review - records below the declared evidence role using the same contained/no-clobber - standards as durable results. +**Current status:** storage-only `hub_core/review_recording.py` foundation is +implemented, and focused adversarial tests pass. `result_promotion` admission +integration, the MCP write surface, and the frozen promotion-gate receipt +remain pending. + +- Implemented storage-only `hub_core/review_recording.py` foundation creates + append-only review records below the declared evidence role using the same + contained/no-clobber standards as durable results. - Integrate `hub_core/result_promotion.py` with `promotion_gate.py` only at its admission boundary. Keep `hub_core/durable_promotion.py` and `hub_core/atomic_no_clobber.py` as the only byte-publication primitives. diff --git a/hub_core/review_recording.py b/hub_core/review_recording.py new file mode 100644 index 0000000..518dbc0 --- /dev/null +++ b/hub_core/review_recording.py @@ -0,0 +1,258 @@ +"""Storage-only recording of validated human-review receipts. + +This module deliberately does not issue or verify reviewer authority and does +not know about runtime renders or promotion. It accepts an already validated +receipt, serializes it canonically, and publishes those bytes once below a +caller-owned evidence root. +""" + +from __future__ import annotations + +import os +import secrets +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from .atomic_no_clobber import AtomicNoClobberUnavailable, atomic_no_clobber_move +from .human_review_receipt import ( + HumanReviewReceiptError, + canonical_human_review_receipt_bytes, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .structure_path_security import ( + capture_directory_witness, + capture_project_root, + lease_directory_witness, +) + + +class ReviewRecordingError(RuntimeError): + """Raised when a review receipt cannot be recorded safely.""" + + +class ReviewRecordingAuthorizationError(ReviewRecordingError): + """Raised when the caller has not explicitly enabled this write.""" + + +class ReviewRecordExistsError(ReviewRecordingError): + """Raised when the append-only destination already exists.""" + + +@dataclass(frozen=True, slots=True) +class ReviewRecordingResult: + """Small, immutable, runtime-independent publication result.""" + + relative_path: str + receipt_id: str + canonical_sha256: str + size_bytes: int + + @property + def record_relative_path(self) -> str: + """Compatibility spelling for callers that call this a record path.""" + + return self.relative_path + + +_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + +def record_human_review_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, + *, + evidence_root: str | os.PathLike[str], + relative_path: str | os.PathLike[str], + write_authorized: bool, +) -> ReviewRecordingResult: + """Publish one canonical human-review receipt below ``evidence_root``. + + ``write_authorized`` is an explicit caller-side write gate. A mapping is + schema-validated; byte input must already be the exact canonical encoding + emitted by :func:`canonical_human_review_receipt_bytes`. No existing + destination is replaced, including in a concurrent publication race. + """ + + if write_authorized is not True: + raise ReviewRecordingAuthorizationError("review recording writes are disabled") + + receipt, canonical_bytes = _validated_canonical_receipt(receipt_or_bytes) + relative = _canonical_relative_path(relative_path) + root = _prepare_evidence_root(evidence_root) + root_identity = capture_project_root(root) + parent_relative = PurePosixPath(relative).parent.as_posix() + witness = capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=True, + ) + destination = root.joinpath(*PurePosixPath(relative).parts) + + with lease_directory_witness(witness): + _reject_existing_destination(destination) + _append_no_clobber(destination, canonical_bytes) + + # Keep the returned DTO independent of the absolute evidence-root path. + return ReviewRecordingResult( + relative_path=relative, + receipt_id=receipt["receipt_id"], + canonical_sha256=receipt["integrity"]["canonical_sha256"], + size_bytes=len(canonical_bytes), + ) + + +def _validated_canonical_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, +) -> tuple[dict[str, Any], bytes]: + try: + if isinstance(receipt_or_bytes, (bytes, bytearray, memoryview)): + raw = bytes(receipt_or_bytes) + receipt = parse_human_review_receipt_bytes(raw) + canonical = canonical_human_review_receipt_bytes(receipt) + if raw != canonical: + raise ReviewRecordingError("human review receipt bytes must be canonical") + elif isinstance(receipt_or_bytes, Mapping): + receipt = validate_human_review_receipt(receipt_or_bytes) + canonical = canonical_human_review_receipt_bytes(receipt) + else: + raise ReviewRecordingError("human review receipt must be a mapping or canonical bytes") + except ReviewRecordingError: + raise + except (HumanReviewReceiptError, TypeError, ValueError, AttributeError) as exc: + raise ReviewRecordingError(f"malformed human review receipt: {exc}") from exc + return receipt, canonical + + +def _canonical_relative_path(value: str | os.PathLike[str]) -> str: + try: + text = os.fspath(value) + except TypeError as exc: + raise ReviewRecordingError("review record destination must be a relative path") from exc + if not isinstance(text, str) or not text: + raise ReviewRecordingError("review record destination must be a non-empty relative path") + relative = PurePosixPath(text) + if ( + relative.is_absolute() + or relative.as_posix() != text + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + or any(":" in part for part in relative.parts) + or "\\" in text + or any(ord(character) < 0x20 or ord(character) == 0x7F for character in text) + ): + raise ReviewRecordingError("review record destination must be canonical and evidence-root relative") + return relative.as_posix() + + +def _is_reparse_or_symlink(path: Path) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + return False + return path.is_symlink() or bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT) + + +def _prepare_evidence_root(value: str | os.PathLike[str]) -> Path: + try: + root = Path(value).expanduser() + except (TypeError, ValueError) as exc: + raise ReviewRecordingError("evidence root must be an absolute directory") from exc + if not root.is_absolute() or any(part in {".", ".."} for part in root.parts): + raise ReviewRecordingError("evidence root must be an absolute directory") + + # Bind/create every component without following a symlink or reparse point. + current = Path(root.anchor) + for part in root.parts[1:]: + current /= part + try: + exists = os.path.lexists(current) + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + if exists: + if _is_reparse_or_symlink(current): + raise ReviewRecordingError("evidence root must not traverse a symlink or reparse point") + try: + if not current.is_dir(): + raise ReviewRecordingError("evidence root must be a directory") + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + continue + try: + current.mkdir() + except FileExistsError: + pass + except OSError as exc: + raise ReviewRecordingError("evidence root is not writable") from exc + if _is_reparse_or_symlink(current) or not current.is_dir(): + raise ReviewRecordingError("evidence root must not traverse a symlink or reparse point") + try: + if _is_reparse_or_symlink(root) or not root.is_dir(): + raise ReviewRecordingError("evidence root must be a non-symlink directory") + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + return root + + +def _reject_existing_destination(destination: Path) -> None: + try: + if not os.path.lexists(destination): + return + except OSError as exc: + raise ReviewRecordingError("review record destination is unavailable") from exc + if _is_reparse_or_symlink(destination): + raise ReviewRecordingError("review record destination must not be a symlink or reparse point") + raise ReviewRecordExistsError("review record destination already exists") + + +def _append_no_clobber(destination: Path, payload: bytes) -> None: + stage = destination.parent / f".{destination.name}.{os.getpid()}.{secrets.token_hex(12)}.tmp" + descriptor = -1 + try: + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0) + descriptor = os.open(stage, flags, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + try: + atomic_no_clobber_move(stage, destination) + except FileExistsError as exc: + raise ReviewRecordExistsError("review record destination already exists") from exc + except AtomicNoClobberUnavailable as exc: + raise ReviewRecordingError("review record publication is unavailable") from exc + except PermissionError as exc: + raise ReviewRecordingError("review record publication was denied") from exc + except OSError as exc: + raise ReviewRecordingError("review record publication failed") from exc + except ReviewRecordingError: + raise + except FileExistsError as exc: + # A pre-existing private stage name is not a publication race; retrying + # with a fresh random name is unnecessary and could hide tampering. + raise ReviewRecordingError("review record staging path already exists") from exc + except OSError as exc: + raise ReviewRecordingError("review record staging failed") from exc + finally: + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + try: + if os.path.lexists(stage): + stage.unlink() + except OSError: + pass + + +__all__ = [ + "ReviewRecordExistsError", + "ReviewRecordingAuthorizationError", + "ReviewRecordingError", + "ReviewRecordingResult", + "record_human_review_receipt", +] diff --git a/tests/test_review_recording.py b/tests/test_review_recording.py new file mode 100644 index 0000000..45c9f5c --- /dev/null +++ b/tests/test_review_recording.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hub_core.human_review_receipt import canonical_human_review_receipt_bytes +from hub_core.review_recording import ( + ReviewRecordExistsError, + ReviewRecordingAuthorizationError, + ReviewRecordingError, + record_human_review_receipt, +) +from tests.human_review_receipt_helpers import receipt + + +def _record(tmp_path: Path, *, value: object, relative_path: str = "human/review.json", **kwargs: object): + return record_human_review_receipt( + value, + evidence_root=tmp_path / "evidence", + relative_path=relative_path, + write_authorized=True, + **kwargs, + ) + + +def test_records_exact_canonical_bytes_and_returns_runtime_independent_result(tmp_path: Path) -> None: + review = receipt() + result = _record(tmp_path, value=review) + destination = tmp_path / "evidence" / result.relative_path + + assert destination.read_bytes() == canonical_human_review_receipt_bytes(review) + assert result.record_relative_path == "human/review.json" + assert result.receipt_id == review["receipt_id"] + assert result.canonical_sha256 == review["integrity"]["canonical_sha256"] + assert result.size_bytes == destination.stat().st_size + assert str(tmp_path) not in repr(result) + + +def test_accepts_only_exact_canonical_receipt_bytes(tmp_path: Path) -> None: + review = receipt() + canonical = canonical_human_review_receipt_bytes(review) + result = _record(tmp_path, value=canonical, relative_path="bytes.json") + assert (tmp_path / "evidence" / result.relative_path).read_bytes() == canonical + + with pytest.raises(ReviewRecordingError, match="canonical"): + _record(tmp_path / "noncanonical", value=b" " + canonical, relative_path="bytes.json") + + +def test_write_authorized_false_fails_before_validation_or_filesystem_touch(tmp_path: Path) -> None: + root = tmp_path / "evidence" + with pytest.raises(ReviewRecordingAuthorizationError, match="disabled"): + record_human_review_receipt( + object(), # type: ignore[arg-type] + evidence_root=root, + relative_path="review.json", + write_authorized=False, + ) + assert not root.exists() + + +@pytest.mark.parametrize( + "relative_path", + ["/outside.json", "../outside.json", "nested/../../outside.json", r"nested\\review.json", "C:/outside.json"], +) +def test_absolute_and_traversal_destinations_are_rejected(tmp_path: Path, relative_path: str) -> None: + with pytest.raises(ReviewRecordingError, match="canonical|relative"): + _record(tmp_path, value=receipt(), relative_path=relative_path) + assert not (tmp_path / "outside.json").exists() + + +def test_symlinked_evidence_root_is_rejected(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + root = tmp_path / "evidence" + try: + root.symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("host cannot create directory symlinks") + + with pytest.raises(ReviewRecordingError, match="symlink|reparse"): + _record(tmp_path, value=receipt()) + assert not (outside / "human" / "review.json").exists() + + +def test_symlinked_destination_parent_is_rejected(tmp_path: Path) -> None: + root = tmp_path / "evidence" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + link = root / "link" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("host cannot create directory symlinks") + + with pytest.raises(ReviewRecordingError, match="symlink|reparse|unsafe"): + _record(tmp_path, value=receipt(), relative_path="link/review.json") + assert not (outside / "review.json").exists() + + +def test_existing_destination_is_never_overwritten(tmp_path: Path) -> None: + root = tmp_path / "evidence" + root.mkdir() + destination = root / "review.json" + destination.write_bytes(b"winner") + + with pytest.raises(ReviewRecordExistsError): + _record(tmp_path, value=receipt(), relative_path="review.json") + assert destination.read_bytes() == b"winner" + + +def test_no_clobber_race_preserves_competitor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import hub_core.review_recording as recording + + def competing_move(source: Path, destination: Path) -> None: + destination.write_bytes(b"competitor") + raise FileExistsError(str(destination)) + + monkeypatch.setattr(recording, "atomic_no_clobber_move", competing_move) + with pytest.raises(ReviewRecordExistsError): + _record(tmp_path, value=receipt(), relative_path="race.json") + + destination = tmp_path / "evidence" / "race.json" + assert destination.read_bytes() == b"competitor" + assert not list((tmp_path / "evidence").rglob("*.tmp")) + + +def test_malformed_receipt_never_creates_evidence_root(tmp_path: Path) -> None: + malformed = receipt() + malformed["integrity"] = {"canonical_sha256": "0" * 64} + with pytest.raises(ReviewRecordingError, match="malformed"): + _record(tmp_path, value=malformed) + assert not (tmp_path / "evidence").exists() + + +def test_record_bytes_remain_after_runtime_tree_deletion(tmp_path: Path) -> None: + runtime = tmp_path / "runtime" + runtime.mkdir() + runtime_file = runtime / "manifest.json" + runtime_file.write_text("runtime", encoding="utf-8") + result = _record(tmp_path, value=receipt(), relative_path="review.json") + runtime_file.unlink() + runtime.rmdir() + payload = json.loads((tmp_path / "evidence" / result.relative_path).read_bytes()) + assert payload["receipt_id"] == result.receipt_id From 6ef6681fec60004c25f5b44fa863301ddccd0135 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 22:27:37 +0900 Subject: [PATCH 16/21] Normalize review recording security errors --- hub_core/review_recording.py | 36 ++++++++++++++++++++++------------ tests/test_review_recording.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/hub_core/review_recording.py b/hub_core/review_recording.py index 518dbc0..e253c33 100644 --- a/hub_core/review_recording.py +++ b/hub_core/review_recording.py @@ -81,19 +81,29 @@ def record_human_review_receipt( receipt, canonical_bytes = _validated_canonical_receipt(receipt_or_bytes) relative = _canonical_relative_path(relative_path) root = _prepare_evidence_root(evidence_root) - root_identity = capture_project_root(root) - parent_relative = PurePosixPath(relative).parent.as_posix() - witness = capture_directory_witness( - root, - parent_relative, - root_identity=root_identity, - create=True, - ) - destination = root.joinpath(*PurePosixPath(relative).parts) - - with lease_directory_witness(witness): - _reject_existing_destination(destination) - _append_no_clobber(destination, canonical_bytes) + # The path-security helpers deliberately raise their own low-level + # ``RuntimeError``/``ValueError``/``OSError`` failures when a witnessed + # directory changes, becomes unsafe, or cannot be leased. Keep those + # checks fail-closed, but expose one public error type to callers of this + # storage API while preserving the helper's diagnostic message. + try: + root_identity = capture_project_root(root) + parent_relative = PurePosixPath(relative).parent.as_posix() + witness = capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=True, + ) + destination = root.joinpath(*PurePosixPath(relative).parts) + + with lease_directory_witness(witness): + _reject_existing_destination(destination) + _append_no_clobber(destination, canonical_bytes) + except ReviewRecordingError: + raise + except (RuntimeError, ValueError, OSError) as exc: + raise ReviewRecordingError(str(exc)) from exc # Keep the returned DTO independent of the absolute evidence-root path. return ReviewRecordingResult( diff --git a/tests/test_review_recording.py b/tests/test_review_recording.py index 45c9f5c..f194e2c 100644 --- a/tests/test_review_recording.py +++ b/tests/test_review_recording.py @@ -100,6 +100,36 @@ def test_symlinked_destination_parent_is_rejected(tmp_path: Path) -> None: assert not (outside / "review.json").exists() +def test_witness_runtime_failure_is_public_review_recording_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import hub_core.review_recording as recording + + def unsafe_witness(*args: object, **kwargs: object) -> object: + raise RuntimeError("Structure directory changed during apply.") + + monkeypatch.setattr(recording, "capture_directory_witness", unsafe_witness) + + with pytest.raises(ReviewRecordingError, match="Structure directory changed during apply"): + _record(tmp_path, value=receipt()) + assert not (tmp_path / "evidence" / "human" / "review.json").exists() + + +def test_lease_runtime_failure_is_public_review_recording_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import hub_core.review_recording as recording + + def unsafe_lease(*args: object, **kwargs: object) -> object: + raise RuntimeError("Could not lease structure directory.") + + monkeypatch.setattr(recording, "lease_directory_witness", unsafe_lease) + + with pytest.raises(ReviewRecordingError, match="Could not lease structure directory"): + _record(tmp_path, value=receipt()) + assert not (tmp_path / "evidence" / "human" / "review.json").exists() + + def test_existing_destination_is_never_overwritten(tmp_path: Path) -> None: root = tmp_path / "evidence" root.mkdir() From 81fb6a0e85a7b6782e145acaae6b06f9ddbc425a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 00:32:49 +0900 Subject: [PATCH 17/21] Add optional promotion gate admission guard --- ...6-07-20-ai-native-figure-integrity-plan.md | 15 +- hub_core/result_promotion.py | 69 ++++++- tests/test_result_promotion_integration.py | 175 ++++++++++++++++++ 3 files changed, 249 insertions(+), 10 deletions(-) diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md index 8579201..92af0ce 100644 --- a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -519,17 +519,18 @@ never becomes `eligible` without a valid required receipt. **Goal:** expose an explicitly write-gated review-recording path and interpose the gate before the existing durable promotion primitive. -**Current status:** storage-only `hub_core/review_recording.py` foundation is -implemented, and focused adversarial tests pass. `result_promotion` admission -integration, the MCP write surface, and the frozen promotion-gate receipt -remain pending. +**Current status:** storage-only `hub_core/review_recording.py` and the +optional `hub_core/result_promotion.py` promotion-gate admission guard are +implemented, with focused/adversarial tests passing. The MCP write surface and +frozen promotion-gate receipt bundle remain pending. - Implemented storage-only `hub_core/review_recording.py` foundation creates append-only review records below the declared evidence role using the same contained/no-clobber standards as durable results. -- Integrate `hub_core/result_promotion.py` with `promotion_gate.py` only at its - admission boundary. Keep `hub_core/durable_promotion.py` and - `hub_core/atomic_no_clobber.py` as the only byte-publication primitives. +- Implemented the optional `hub_core/result_promotion.py` promotion-gate + admission guard at the admission boundary. Keep + `hub_core/durable_promotion.py` and `hub_core/atomic_no_clobber.py` as the + only byte-publication primitives. - Extend existing `hub_core/mcp/security.py`, `hub_core/mcp/schemas.py`, and focused handler modules under `hub_core/mcp/tools/` for deliberate write authorization. Read-only inspection stays available with writes disabled. diff --git a/hub_core/result_promotion.py b/hub_core/result_promotion.py index bde609f..88caa7d 100644 --- a/hub_core/result_promotion.py +++ b/hub_core/result_promotion.py @@ -10,7 +10,7 @@ import hashlib import json from pathlib import Path, PurePosixPath -from typing import Any, Mapping +from typing import Any, Mapping, TypeAlias from .artifact_policy_measurement import ( ArtifactPolicyMeasurementError, @@ -26,12 +26,59 @@ ) from .project_paths import project_path_has_symlink_component, resolve_project_output from .project_structure_contract import resolve_project_structure +from .promotion_gate_receipt import validate_promotion_gate_receipt class ResultPromotionError(RuntimeError): """An eligible render could not be reduced to a safe durable result.""" +PromotionGateReceiptInput: TypeAlias = Mapping[str, Any] + + +def _verify_promotion_gate_admission( + promotion_gate_receipt: PromotionGateReceiptInput, + *, + primary_sha256: str, + durable_receipt: DurableReceipt, +) -> None: + """Require a canonical eligible gate bound to this result's lineage. + + The evaluator's report is accepted as a convenience envelope when it + contains ``receipt_candidate``; the receipt validator remains the sole + authority for the closed gate schema and derived status. + """ + + if not isinstance(promotion_gate_receipt, Mapping): + raise ResultPromotionError("promotion gate receipt/report must be a mapping") + candidate: Mapping[str, Any] = promotion_gate_receipt + report_status: object | None = None + if "receipt_candidate" in promotion_gate_receipt: + report_status = promotion_gate_receipt.get("gate_status") + candidate_value = promotion_gate_receipt.get("receipt_candidate") + if not isinstance(candidate_value, Mapping): + raise ResultPromotionError("promotion gate report receipt_candidate is malformed") + candidate = candidate_value + try: + validated = validate_promotion_gate_receipt(candidate) + except (TypeError, ValueError) as exc: + raise ResultPromotionError(f"promotion gate receipt is invalid: {exc}") from exc + if report_status is not None and report_status != validated.get("gate_status"): + raise ResultPromotionError("promotion gate report status does not match its receipt candidate") + if "receipt_candidate" in promotion_gate_receipt and report_status != "eligible": + raise ResultPromotionError("promotion gate report is not eligible") + if validated.get("gate_status") != "eligible": + raise ResultPromotionError("promotion gate receipt is not eligible") + subject = validated.get("subject") + if not isinstance(subject, Mapping): + raise ResultPromotionError("eligible promotion gate receipt is missing its subject") + if subject.get("artifact_sha256") != primary_sha256: + raise ResultPromotionError("promotion gate subject does not bind the verified primary artifact") + expected_lineage_sha = durable_receipt.canonical_sha256() + if subject.get("lineage_receipt_sha256") != expected_lineage_sha: + raise ResultPromotionError("promotion gate subject does not bind the durable lineage receipt") + + def _sha256_text(value: object) -> str: text = str(value or "unavailable") if len(text) == 64: @@ -231,8 +278,14 @@ def promote_eligible_project_result( manifest_path: str | Path, figure_id: str, selected_figure: Mapping[str, Any], + promotion_gate_receipt: PromotionGateReceiptInput | None = None, ) -> tuple[PromotedArtifact, PromotedArtifact] | None: - """Promote one fully verified project render, or return ``None`` when gated.""" + """Promote one fully verified project render, or return ``None`` when gated. + + ``promotion_gate_receipt`` is an optional canonical receipt (or evaluator + report containing ``receipt_candidate``); when supplied, admission is + bound to the primary artifact and the durable receipt constructed here. + """ if not _is_promotion_eligible(manifest): return None @@ -305,6 +358,12 @@ def promote_eligible_project_result( manifest_sha256=hashlib.sha256(manifest_bytes).hexdigest(), publication_policy=publication_policy, ) + if promotion_gate_receipt is not None: + _verify_promotion_gate_admission( + promotion_gate_receipt, + primary_sha256=str(primary["sha256"]).lower(), + durable_receipt=receipt, + ) evidence_root = PurePosixPath(contract.roots["evidence"]) receipt_relpath = evidence_root / f"figure-{figure_key}.receipt.json" @@ -328,4 +387,8 @@ def promote_eligible_project_result( ) -__all__ = ["ResultPromotionError", "promote_eligible_project_result"] +__all__ = [ + "PromotionGateReceiptInput", + "ResultPromotionError", + "promote_eligible_project_result", +] diff --git a/tests/test_result_promotion_integration.py b/tests/test_result_promotion_integration.py index 23570be..be5e45d 100644 --- a/tests/test_result_promotion_integration.py +++ b/tests/test_result_promotion_integration.py @@ -12,6 +12,7 @@ from hub_core.claim_inventory import evaluate_project_claim_inventory from hub_core.durable_promotion import verify_promoted_result from hub_core.mcp import FigOpsMCPServer +from hub_core.promotion_gate_receipt import GATE_CODE_ORDER, build_promotion_gate_receipt from hub_core.result_promotion import ResultPromotionError, promote_eligible_project_result @@ -128,6 +129,84 @@ def _bind_policy(manifest: dict[str, object], artifact: Path, digest: str) -> No evidence["policy_projections"] = [measured["policy_projection"]] +def _eligible_promotion_fixture(tmp_path: Path, *, job_id: str) -> dict[str, object]: + project = tmp_path / "project" + runtime = tmp_path / "runtime" + runtime_artifact = runtime / "mcp_project_jobs" / job_id / "project" / "results" / "figures" / "Fig1.png" + output_sha256 = _write_compliant_png(runtime_artifact) + snapshot_root = runtime_artifact.parents[2] + selected = _write_no_claim_snapshot(snapshot_root) + manifest = _manifest(job_id=job_id, output_sha256=output_sha256) + manifest["claim_inventory"] = evaluate_project_claim_inventory(snapshot_root, selected) + _bind_policy(manifest, runtime_artifact, output_sha256) + manifest_path = runtime_artifact.parents[3] / "manifest.json" + manifest_path.write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + (project / "results" / "figures").mkdir(parents=True) + (project / "results" / "evidence").mkdir(parents=True) + return { + "project_root": project, + "config": {"visual_style": {"validation_target": "nature"}}, + "runtime_root": runtime, + "runtime_artifact": runtime_artifact, + "output_relpath": "results/figures/Fig1.png", + "manifest": manifest, + "manifest_path": manifest_path, + "figure_id": "Fig1", + "selected_figure": selected, + "output_sha256": output_sha256, + } + + +def _gate_receipt( + *, + gate_status: str, + artifact_sha256: str = "1" * 64, + lineage_sha256: str = "2" * 64, +) -> dict[str, object]: + if gate_status == "eligible": + subject: dict[str, object] | None = { + "project_id": "project:" + "a" * 32, + "artifact_id": "result.figure:" + "b" * 32, + "artifact_sha256": artifact_sha256, + "lineage_receipt_sha256": lineage_sha256, + "evidence_digest": "3" * 64, + "resolved_policy_digest": "4" * 64, + "subject_digest": "5" * 64, + } + digests = { + "report_sha256": "6" * 64, + "lineage_receipt_sha256": lineage_sha256, + "publication_evidence_sha256": "3" * 64, + "resolved_policy_sha256": "4" * 64, + "review_receipt_sha256": None, + } + outcomes = ["passed"] * len(GATE_CODE_ORDER) + else: + subject = None + digests = { + "report_sha256": None, + "lineage_receipt_sha256": None, + "publication_evidence_sha256": None, + "resolved_policy_sha256": None, + "review_receipt_sha256": None, + } + outcomes = ["blocked", *(["passed"] * (len(GATE_CODE_ORDER) - 1))] + return build_promotion_gate_receipt( + gate_status=gate_status, + subject=subject, + digests=digests, + gates=[ + { + "code": code, + "outcome": outcome, + "evidence_ref": code.lower(), + "message": "test", + } + for code, outcome in zip(GATE_CODE_ORDER, outcomes) + ], + ) + + def test_eligible_runtime_result_is_promoted_with_runtime_independent_receipt(tmp_path: Path) -> None: project = tmp_path / "project" runtime = tmp_path / "runtime" @@ -184,6 +263,102 @@ def test_eligible_runtime_result_is_promoted_with_runtime_independent_receipt(tm } +def test_supplied_missing_promotion_gate_fails_closed_without_mutation(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-missing") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = {} + + with pytest.raises(ResultPromotionError, match="promotion gate receipt is invalid"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_blocked_promotion_gate_report_fails_closed_without_mutation(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-blocked") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = { + "gate_status": "blocked", + "receipt_candidate": _gate_receipt(gate_status="blocked"), + } + + with pytest.raises(ResultPromotionError, match="promotion gate report is not eligible"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_artifact_hash_must_match_primary(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-artifact-mismatch") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256="f" * 64, + ) + + with pytest.raises(ResultPromotionError, match="verified primary artifact"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_lineage_must_match_constructed_receipt(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-lineage-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256="e" * 64, + ) + + with pytest.raises(ResultPromotionError, match="durable lineage receipt"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-eligible") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + + # The first legacy promotion gives this fixture's deterministic receipt + # binding; remove only its published bytes before exercising the gated path. + initial = promote_eligible_project_result(**kwargs) + assert initial is not None + artifact, receipt_artifact = initial + lineage = verify_promoted_result( + artifact.path, + receipt_artifact.path, + durable_root=kwargs["project_root"] / "results", + forbidden_roots=(kwargs["runtime_root"],), + ) + artifact.path.unlink() + receipt_artifact.path.unlink() + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256=lineage.canonical_sha256(), + ) + + promoted = promote_eligible_project_result(**kwargs) + + assert promoted is not None + + def test_unverified_or_review_required_runtime_result_is_never_promoted(tmp_path: Path) -> None: project = tmp_path / "project" runtime = tmp_path / "runtime" From 672f1d5380425eb64e071ff31be543d07e1e947a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 00:44:43 +0900 Subject: [PATCH 18/21] Bind promotion admission to gate subject identity --- ...6-07-20-ai-native-figure-integrity-plan.md | 12 ++- hub_core/result_promotion.py | 34 +++++++ tests/test_result_promotion_integration.py | 89 +++++++++++++++++-- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md index 92af0ce..92665ec 100644 --- a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -521,14 +521,20 @@ the gate before the existing durable promotion primitive. **Current status:** storage-only `hub_core/review_recording.py` and the optional `hub_core/result_promotion.py` promotion-gate admission guard are -implemented, with focused/adversarial tests passing. The MCP write surface and -frozen promotion-gate receipt bundle remain pending. +implemented, with focused/adversarial tests passing. The admission guard binds +the gate subject `project_id`/`artifact_id` to trusted `project.name`/`figure_id` +opaque IDs and recomputes `subject_digest` with the canonical default scope +before publication. The MCP write surface and frozen promotion-gate receipt +bundle remain pending. - Implemented storage-only `hub_core/review_recording.py` foundation creates append-only review records below the declared evidence role using the same contained/no-clobber standards as durable results. - Implemented the optional `hub_core/result_promotion.py` promotion-gate - admission guard at the admission boundary. Keep + admission guard at the admission boundary; it binds the gate subject + `project_id`/`artifact_id` to trusted `project.name`/`figure_id` opaque IDs + and recomputes `subject_digest` with the canonical default scope before any + publication write. Keep `hub_core/durable_promotion.py` and `hub_core/atomic_no_clobber.py` as the only byte-publication primitives. - Extend existing `hub_core/mcp/security.py`, `hub_core/mcp/schemas.py`, and diff --git a/hub_core/result_promotion.py b/hub_core/result_promotion.py index 88caa7d..fdfa096 100644 --- a/hub_core/result_promotion.py +++ b/hub_core/result_promotion.py @@ -24,6 +24,7 @@ opaque_claim_id, opaque_receipt_id, ) +from .human_review_receipt import calculate_subject_digest, opaque_figure_artifact_id, opaque_project_id from .project_paths import project_path_has_symlink_component, resolve_project_output from .project_structure_contract import resolve_project_structure from .promotion_gate_receipt import validate_promotion_gate_receipt @@ -34,6 +35,7 @@ class ResultPromotionError(RuntimeError): PromotionGateReceiptInput: TypeAlias = Mapping[str, Any] +_DEFAULT_PROMOTION_GATE_DECISION_SCOPE = "figure_scientific_and_communication" def _verify_promotion_gate_admission( @@ -41,6 +43,9 @@ def _verify_promotion_gate_admission( *, primary_sha256: str, durable_receipt: DurableReceipt, + expected_project_id: str, + expected_artifact_id: str, + decision_scope: str, ) -> None: """Require a canonical eligible gate bound to this result's lineage. @@ -72,8 +77,22 @@ def _verify_promotion_gate_admission( subject = validated.get("subject") if not isinstance(subject, Mapping): raise ResultPromotionError("eligible promotion gate receipt is missing its subject") + if subject.get("project_id") != expected_project_id: + raise ResultPromotionError("promotion gate subject does not bind the trusted project identity") + if subject.get("artifact_id") != expected_artifact_id: + raise ResultPromotionError("promotion gate subject does not bind the selected figure identity") if subject.get("artifact_sha256") != primary_sha256: raise ResultPromotionError("promotion gate subject does not bind the verified primary artifact") + subject_payload = {key: value for key, value in subject.items() if key != "subject_digest"} + try: + expected_subject_digest = calculate_subject_digest( + subject_payload, + decision_scope, + ) + except (TypeError, ValueError) as exc: + raise ResultPromotionError("promotion gate subject digest is malformed") from exc + if subject.get("subject_digest") != expected_subject_digest: + raise ResultPromotionError("promotion gate subject digest does not match its subject") expected_lineage_sha = durable_receipt.canonical_sha256() if subject.get("lineage_receipt_sha256") != expected_lineage_sha: raise ResultPromotionError("promotion gate subject does not bind the durable lineage receipt") @@ -279,12 +298,15 @@ def promote_eligible_project_result( figure_id: str, selected_figure: Mapping[str, Any], promotion_gate_receipt: PromotionGateReceiptInput | None = None, + promotion_gate_decision_scope: str = _DEFAULT_PROMOTION_GATE_DECISION_SCOPE, ) -> tuple[PromotedArtifact, PromotedArtifact] | None: """Promote one fully verified project render, or return ``None`` when gated. ``promotion_gate_receipt`` is an optional canonical receipt (or evaluator report containing ``receipt_candidate``); when supplied, admission is bound to the primary artifact and the durable receipt constructed here. + ``promotion_gate_decision_scope`` is required when the evaluator used a + non-default decision scope because the frozen gate receipt omits that field. """ if not _is_promotion_eligible(manifest): @@ -359,10 +381,22 @@ def promote_eligible_project_result( publication_policy=publication_policy, ) if promotion_gate_receipt is not None: + project_config = config.get("project") if isinstance(config, Mapping) else None + project_name = project_config.get("name") if isinstance(project_config, Mapping) else None + if not isinstance(project_name, str) or not project_name.strip(): + raise ResultPromotionError("promotion gate requires a trusted project.name identity") + try: + expected_project_id = opaque_project_id(project_name) + expected_artifact_id = opaque_figure_artifact_id(str(figure_id)) + except (TypeError, ValueError) as exc: + raise ResultPromotionError("promotion gate subject identities are malformed") from exc _verify_promotion_gate_admission( promotion_gate_receipt, primary_sha256=str(primary["sha256"]).lower(), durable_receipt=receipt, + expected_project_id=expected_project_id, + expected_artifact_id=expected_artifact_id, + decision_scope=promotion_gate_decision_scope, ) evidence_root = PurePosixPath(contract.roots["evidence"]) diff --git a/tests/test_result_promotion_integration.py b/tests/test_result_promotion_integration.py index be5e45d..6b60def 100644 --- a/tests/test_result_promotion_integration.py +++ b/tests/test_result_promotion_integration.py @@ -11,10 +11,17 @@ from hub_core.artifact_policy_measurement import measure_artifact_policy from hub_core.claim_inventory import evaluate_project_claim_inventory from hub_core.durable_promotion import verify_promoted_result +from hub_core.human_review_receipt import ( + calculate_subject_digest, + opaque_figure_artifact_id, + opaque_project_id, +) from hub_core.mcp import FigOpsMCPServer from hub_core.promotion_gate_receipt import GATE_CODE_ORDER, build_promotion_gate_receipt from hub_core.result_promotion import ResultPromotionError, promote_eligible_project_result +DEFAULT_SCOPE = "figure_scientific_and_communication" + def _manifest(*, job_id: str, output_sha256: str, eligible: bool = True) -> dict[str, object]: digest = "1" * 64 @@ -145,7 +152,10 @@ def _eligible_promotion_fixture(tmp_path: Path, *, job_id: str) -> dict[str, obj (project / "results" / "evidence").mkdir(parents=True) return { "project_root": project, - "config": {"visual_style": {"validation_target": "nature"}}, + "config": { + "project": {"name": "Promotion integration"}, + "visual_style": {"validation_target": "nature"}, + }, "runtime_root": runtime, "runtime_artifact": runtime_artifact, "output_relpath": "results/figures/Fig1.png", @@ -162,16 +172,23 @@ def _gate_receipt( gate_status: str, artifact_sha256: str = "1" * 64, lineage_sha256: str = "2" * 64, + project_id: str | None = None, + artifact_id: str | None = None, + subject_digest: str | None = None, + decision_scope: str = DEFAULT_SCOPE, ) -> dict[str, object]: if gate_status == "eligible": - subject: dict[str, object] | None = { - "project_id": "project:" + "a" * 32, - "artifact_id": "result.figure:" + "b" * 32, + subject_payload: dict[str, object] = { + "project_id": project_id or opaque_project_id("Promotion integration"), + "artifact_id": artifact_id or opaque_figure_artifact_id("Fig1"), "artifact_sha256": artifact_sha256, "lineage_receipt_sha256": lineage_sha256, "evidence_digest": "3" * 64, "resolved_policy_digest": "4" * 64, - "subject_digest": "5" * 64, + } + subject: dict[str, object] | None = { + **subject_payload, + "subject_digest": subject_digest or calculate_subject_digest(subject_payload, decision_scope), } digests = { "report_sha256": "6" * 64, @@ -311,6 +328,63 @@ def test_promotion_gate_subject_artifact_hash_must_match_primary(tmp_path: Path) assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] +def test_promotion_gate_subject_project_id_must_match_project_name(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-project-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + project_id=opaque_project_id("different project"), + ) + + with pytest.raises(ResultPromotionError, match="trusted project identity"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_artifact_id_must_match_figure_id(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-figure-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + artifact_id=opaque_figure_artifact_id("DifferentFigure"), + ) + + with pytest.raises(ResultPromotionError, match="selected figure identity"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_digest_must_match_subject_fields(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-subject-digest") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + subject_digest="f" * 64, + ) + + with pytest.raises(ResultPromotionError, match="subject digest"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + def test_promotion_gate_subject_lineage_must_match_constructed_receipt(tmp_path: Path) -> None: kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-lineage-mismatch") output_sha256 = kwargs.pop("output_sha256") @@ -330,7 +404,8 @@ def test_promotion_gate_subject_lineage_must_match_constructed_receipt(tmp_path: assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] -def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path) -> None: +@pytest.mark.parametrize("decision_scope", [DEFAULT_SCOPE, "figure_visual_communication"]) +def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path, decision_scope: str) -> None: kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-eligible") output_sha256 = kwargs.pop("output_sha256") assert isinstance(output_sha256, str) @@ -352,7 +427,9 @@ def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path) -> gate_status="eligible", artifact_sha256=output_sha256, lineage_sha256=lineage.canonical_sha256(), + decision_scope=decision_scope, ) + kwargs["promotion_gate_decision_scope"] = decision_scope promoted = promote_eligible_project_result(**kwargs) From 87182d632a27a9a936b52d1011c0533a03999547 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 01:38:42 +0900 Subject: [PATCH 19/21] Add write-gated review recording and receipt freeze --- ...6-07-20-ai-native-figure-integrity-plan.md | 55 +++-- hub_core/mcp/review_schemas.py | 57 ++++++ hub_core/mcp/security.py | 10 +- hub_core/mcp/server.py | 15 ++ hub_core/mcp/tools/review_tools.py | 134 +++++++++++++ hub_core/mcp/transport.py | 11 +- hub_core/promotion_gate_recording.py | 189 ++++++++++++++++++ hub_core/result_promotion.py | 54 ++++- tests/test_mcp_write_gating.py | 165 +++++++++++++++ tests/test_result_promotion_integration.py | 57 +++++- 10 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 hub_core/mcp/review_schemas.py create mode 100644 hub_core/mcp/tools/review_tools.py create mode 100644 hub_core/promotion_gate_recording.py create mode 100644 tests/test_mcp_write_gating.py diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md index 92665ec..f97bc31 100644 --- a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -2,7 +2,9 @@ **Status:** canonical successor plan; Phase 1 human review foundation, Phase 2 policy/workflow contracts, and Phase 3 pure domain evaluator/receipt foundation -are implemented; CLI/MCP and Phase 4 admission integration remain pending. +are implemented; Phase 4 optional write-gated MCP review recording and guarded +native promotion integration are implemented. Phase 5 migration/release +governance remains pending. **Date:** 2026-07-20 @@ -489,8 +491,9 @@ compatibility test. **Goal:** decide eligibility deterministically without performing promotion. -**Current status:** pure domain evaluator/receipt foundation implemented; CLI/MCP -and Phase 4 admission integration remain pending. +**Current status:** pure domain evaluator/receipt foundation implemented; the +Phase 4 admission integration is implemented. A CLI/MCP pure evaluator route +remains pending. - Implemented `hub_core/promotion_gate.py` and `hub_core/promotion_gate_receipt.py` as the pure evaluator/receipt foundation. @@ -520,16 +523,23 @@ never becomes `eligible` without a valid required receipt. the gate before the existing durable promotion primitive. **Current status:** storage-only `hub_core/review_recording.py` and the -optional `hub_core/result_promotion.py` promotion-gate admission guard are -implemented, with focused/adversarial tests passing. The admission guard binds -the gate subject `project_id`/`artifact_id` to trusted `project.name`/`figure_id` -opaque IDs and recomputes `subject_digest` with the canonical default scope -before publication. The MCP write surface and frozen promotion-gate receipt -bundle remain pending. - -- Implemented storage-only `hub_core/review_recording.py` foundation creates - append-only review records below the declared evidence role using the same - contained/no-clobber standards as durable results. +optional `figops.record_human_review` write-gated MCP path are implemented. +The MCP writer resolves `figure_id` through trusted project config, binds the +review subject `project_id`/`artifact_id` to trusted `project.name`/`figure_id` +opaque IDs, and leaves the default read-only 14+13 compatibility discovery +unchanged. The optional `hub_core/result_promotion.py` admission guard binds +promotion-gate subject identity to the same trusted opaque IDs, recomputes +`subject_digest` with the canonical default scope before publication, freezes +the validated promotion-gate receipt before native promotion, and handles native +promotion failure by removing the exact owned receipt where identity-bound +deletion is supported while fail-closed unsupported POSIX deletion retains the +canonical receipt for manual review. Phase 5 migration/release governance +remains pending. + +- Implemented storage-only `hub_core/review_recording.py` foundation and + optional `figops.record_human_review` MCP path create append-only review + records below the declared evidence role using the same contained/no-clobber + standards as durable results. - Implemented the optional `hub_core/result_promotion.py` promotion-gate admission guard at the admission boundary; it binds the gate subject `project_id`/`artifact_id` to trusted `project.name`/`figure_id` opaque IDs @@ -537,12 +547,17 @@ bundle remain pending. publication write. Keep `hub_core/durable_promotion.py` and `hub_core/atomic_no_clobber.py` as the only byte-publication primitives. -- Extend existing `hub_core/mcp/security.py`, `hub_core/mcp/schemas.py`, and - focused handler modules under `hub_core/mcp/tools/` for deliberate write - authorization. Read-only inspection stays available with writes disabled. -- Persist a promotion-gate receipt with the result and include it in the frozen - publication bundle. Failures leave no competing destination overwritten and - never backfill a review decision. +- Implemented strict trusted config figure binding for the MCP writer: caller + `figure_id` must resolve to exactly one trusted configured figure, and review + subject identities must match the trusted project/figure opaque IDs. +- Implemented the MCP writer as an additive optional write path outside frozen + discovery profiles. Write-disabled/read-only behavior stays default, and the + frozen 14 canonical + 13 legacy compatibility profile remains unchanged. +- Implemented promotion-gate receipt pre-freeze before native promotion. If + native promotion fails, the exact owned receipt is removed where + identity-bound deletion is supported; unsupported POSIX deletion fails closed + and retains the canonical receipt for manual review; no competing destination + is overwritten and no review decision is backfilled. **Likely tests:** existing `tests/test_durable_promotion.py`, `tests/test_result_promotion_integration.py`, `tests/test_mcp_write_gating.py` @@ -561,6 +576,8 @@ the new gate. **Goal:** make the new lifecycle trustworthy in supported workflows without making normal development CI perform expensive visual work. +**Current status:** pending; migration/release governance is not yet complete. + - Publish migration examples for active v1.1 projects and a read-only legacy explanation. Add deprecation warnings only after compatibility evidence. - Update generated tool references through the live registry process; preserve diff --git a/hub_core/mcp/review_schemas.py b/hub_core/mcp/review_schemas.py new file mode 100644 index 0000000..dc16063 --- /dev/null +++ b/hub_core/mcp/review_schemas.py @@ -0,0 +1,57 @@ +"""Optional, explicitly write-gated review-recording MCP schema.""" + +from __future__ import annotations + +from typing import Any + +from .tool_schema_common import ToolDefinition, object_schema, standard_output_schema + + +def review_tool_definitions() -> list[dict[str, Any]]: + definition = ToolDefinition( + "figops.record_human_review", + "Record one validated human-review receipt below a project's declared evidence role.", + object_schema( + { + "project_path": { + "type": "string", + "minLength": 1, + "description": "Active project path under the configured research root.", + }, + "figure_id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Trusted configured figure identifier bound to the review subject.", + }, + "relative_path": { + "type": "string", + "pattern": r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$", + "description": "Canonical POSIX path relative to results/evidence.", + }, + "review_receipt": { + "type": "object", + "description": "Closed figops-human-review/1 receipt; no authority is inferred from its fields.", + }, + "expected_subject": { + "type": "object", + "description": "Optional host-supplied exact subject binding for the receipt.", + }, + }, + required=["project_path", "figure_id", "relative_path", "review_receipt"], + ), + standard_output_schema( + { + "project_path": {"type": "string"}, + "relative_path": {"type": "string"}, + "receipt_id": {"type": "string"}, + "canonical_sha256": {"type": "string"}, + "size_bytes": {"type": "integer"}, + "review_receipt": {"type": "object"}, + } + ), + ) + return [definition.to_dict()] + + +__all__ = ["review_tool_definitions"] diff --git a/hub_core/mcp/security.py b/hub_core/mcp/security.py index ee26fd4..a77c868 100644 --- a/hub_core/mcp/security.py +++ b/hub_core/mcp/security.py @@ -28,8 +28,16 @@ "figops.scaffold_project", "figops.normalize_project_structure", "figops.batch_check", + "figops.record_human_review", +) +# The new review writer is deliberately additive and has no historical +# graphhub.* alias; compatibility aliases remain frozen to the pre-existing +# surface. +LEGACY_WRITE_TOOL_NAMES = tuple( + name.replace("figops.", "graphhub.", 1) + for name in WRITE_TOOL_NAMES + if name != "figops.record_human_review" ) -LEGACY_WRITE_TOOL_NAMES = tuple(name.replace("figops.", "graphhub.", 1) for name in WRITE_TOOL_NAMES) PROJECT_ID_REPARSE_ERROR = PROJECT_EXECUTION_REPARSE_ERROR.replace("execution project", "project_id") diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index c7af484..142f9e3 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -17,6 +17,7 @@ from .prompts import McpPromptsMixin from .render_orchestration import McpRenderOrchestrationMixin from .resources import McpResourcesMixin +from .review_schemas import review_tool_definitions from .schemas import ( get_tool_handlers, ) @@ -45,6 +46,7 @@ from .tools.render_tools import McpRenderToolsMixin from .tools.render_v2 import McpRenderV2Mixin from .tools.render_validation import McpRenderValidationMixin +from .tools.review_tools import McpReviewToolsMixin class FigOpsMCPServer( @@ -53,6 +55,7 @@ class FigOpsMCPServer( McpRenderV2Mixin, McpReadToolsMixin, McpReadinessToolsMixin, + McpReviewToolsMixin, McpRenderToolsMixin, McpRenderProjectMixin, McpRenderCsvMixin, @@ -152,6 +155,12 @@ def callable_tool_definitions(self) -> list[dict[str, Any]]: require_host_approval=self.require_host_approval, ) + @staticmethod + def optional_write_tool_definitions() -> list[dict[str, Any]]: + """Return additive write schemas kept outside frozen discovery profiles.""" + + return review_tool_definitions() + @staticmethod def list_resource_definitions() -> list[dict[str, str]]: return schema_list_resource_definitions() @@ -170,6 +179,12 @@ def list_prompt_definitions(self) -> list[dict[str, Any]]: def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: arguments = dict(arguments or {}) handler = self._handlers.get(name) + # The review writer is intentionally additive and is not inserted into + # the frozen v2/compatibility discovery profiles. It remains callable + # by its one canonical name so operators can opt into the write gate + # without changing historical tool counts or aliases. + if handler is None and name == "figops.record_human_review": + handler = self.record_human_review if handler is None: raise ValueError(f"Unknown FigOps MCP tool: {name}") structured = self._authorize_write_tool(name, arguments) diff --git a/hub_core/mcp/tools/review_tools.py b/hub_core/mcp/tools/review_tools.py new file mode 100644 index 0000000..d8c2c36 --- /dev/null +++ b/hub_core/mcp/tools/review_tools.py @@ -0,0 +1,134 @@ +"""Explicitly write-gated human-review receipt recording.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path, PurePosixPath +from typing import Any + +from hub_core.human_review_receipt import ( + canonical_human_review_receipt_bytes, + opaque_figure_artifact_id, + opaque_project_id, + validate_human_review_receipt, +) +from hub_core.project_structure_contract import resolve_project_structure +from hub_core.review_recording import record_human_review_receipt + +from .project_tools import _self_described_authority_keys + + +class McpReviewToolsMixin: + """MCP review recording handlers. + + Validation is deliberately performed before any destination is created. + The receipt itself is evidence, not a host authority; reviewer authority + is verified later by the pure promotion gate using a configured policy. + """ + + def record_human_review(self, arguments: dict[str, Any]) -> dict[str, Any]: + guarded = self._authorize_write_tool("figops.record_human_review", arguments) + if guarded is not None: + return guarded + if getattr(self, "require_host_approval", False): + # The existing host authority root is scoped to reviewed project + # structure plans. Do not reinterpret a self-described human + # review receipt as that authority or silently widen its scope. + raise ValueError( + "Host approval is required for this MCP server; review recording has no host-authority binding." + ) + # The receipt is a closed evidence DTO and therefore legitimately + # contains reviewer/authority_assertion fields. Those fields are not + # a host trust channel; scan every other argument for forged authority + # payloads before resolving a project or creating its evidence root. + authority_scan = {key: value for key, value in arguments.items() if key != "review_receipt"} + forbidden = _self_described_authority_keys(authority_scan) + if forbidden: + raise ValueError( + "Tool arguments may not self-describe host approval or authority: " + + ", ".join(forbidden) + + "." + ) + project_path = self._resolve_execution_project_path(arguments.get("project_path")) + figure_id = arguments.get("figure_id") + if not isinstance(figure_id, str) or not figure_id.strip(): + raise ValueError("figure_id is required.") + review_receipt = arguments.get("review_receipt") + if not isinstance(review_receipt, Mapping): + raise ValueError("review_receipt must be a canonical human-review receipt object.") + receipt = validate_human_review_receipt(review_receipt) + loaded = self._load_project_config(project_path) + config = loaded.get("config") + if not isinstance(config, Mapping): + raise ValueError("project configuration is required before recording a human review.") + if loaded.get("errors"): + raise ValueError("project configuration is invalid: " + "; ".join(map(str, loaded["errors"]))) + # Resolve the caller's selector against the trusted project config + # before deriving any artifact identity or touching the evidence + # root. A receipt cannot make an unconfigured (or ambiguous) + # figure ID valid merely by self-describing its subject. + figures = self._project_figure_entries(dict(config)) + selected_figure, selection_errors = self._select_project_figure( + figures, + figure_id=figure_id, + figure_output=None, + ) + if selected_figure is None or selection_errors: + detail = "; ".join(selection_errors) if selection_errors else "figure_id was not resolved." + raise ValueError("figure_id must resolve to exactly one trusted configured figure: " + detail) + contract = resolve_project_structure(config, project_root=project_path) + subject = receipt.get("subject") + if not isinstance(subject, Mapping): + raise ValueError("review_receipt.subject is required.") + project_name = config.get("project", {}).get("name") if isinstance(config.get("project"), Mapping) else None + if not isinstance(project_name, str) or not project_name.strip(): + raise ValueError("trusted project.name is required for review recording.") + expected_project_id = opaque_project_id(project_name) + expected_artifact_id = opaque_figure_artifact_id(figure_id) + if subject.get("project_id") != expected_project_id: + raise ValueError("review subject does not bind the trusted project identity.") + if subject.get("artifact_id") != expected_artifact_id: + raise ValueError("review subject does not bind the selected figure identity.") + expected_subject = arguments.get("expected_subject") + if expected_subject is not None: + if not isinstance(expected_subject, Mapping): + raise ValueError("expected_subject must be a mapping.") + if dict(expected_subject) != dict(subject): + raise ValueError("review subject does not match the trusted expected subject.") + + relative = arguments.get("relative_path") + if not isinstance(relative, str) or not relative: + raise ValueError("relative_path is required.") + evidence_root = Path(project_path) / str(contract.roots["evidence"]) + relative_path = PurePosixPath(relative) + evidence_role = PurePosixPath(str(contract.roots["evidence"])) + if relative_path.is_absolute() or ".." in relative_path.parts or "\\" in relative: + raise ValueError("relative_path must stay below the declared evidence role.") + # ``record_human_review_receipt`` receives a path relative to its + # evidence root, so an MCP caller cannot redirect into figures or + # publication by spelling a project-relative path. + if relative_path.as_posix() != relative: + raise ValueError("relative_path must use canonical POSIX separators.") + canonical_bytes = canonical_human_review_receipt_bytes(receipt) + result = record_human_review_receipt( + receipt, + evidence_root=evidence_root, + relative_path=relative_path.as_posix(), + write_authorized=True, + ) + return self._envelope( + "figops.record_human_review", + arguments, + summary="Recorded one append-only human-review receipt.", + created_paths=[str(evidence_role / relative_path)], + is_dry_run=False, + project_path=str(project_path), + relative_path=result.relative_path, + receipt_id=result.receipt_id, + canonical_sha256=result.canonical_sha256, + size_bytes=len(canonical_bytes), + review_receipt=receipt, + ) + + +__all__ = ["McpReviewToolsMixin"] diff --git a/hub_core/mcp/transport.py b/hub_core/mcp/transport.py index 52bb437..6e9349e 100644 --- a/hub_core/mcp/transport.py +++ b/hub_core/mcp/transport.py @@ -129,7 +129,10 @@ def _handle_json_rpc(server: Any, request: dict[str, Any]) -> dict[str, Any] | N if arguments is None: arguments = {} handlers = getattr(server, "_handlers", {}) - if not isinstance(tool_name, str) or tool_name not in handlers: + optional_review = tool_name == "figops.record_human_review" and callable( + getattr(server, "record_human_review", None) + ) + if not isinstance(tool_name, str) or (tool_name not in handlers and not optional_review): return _json_rpc_error(request_id, JSONRPC_INVALID_PARAMS, f"Unknown tool: {tool_name}") if not isinstance(arguments, dict): return _json_rpc_error(request_id, JSONRPC_INVALID_PARAMS, "Tool arguments must be an object.") @@ -199,7 +202,11 @@ def _callable_tool_definitions_for(server: Any) -> list[dict[str, Any]]: provider = getattr(server, "callable_tool_definitions", None) if callable(provider): - return provider() + definitions = provider() + optional_provider = getattr(server, "optional_write_tool_definitions", None) + if callable(optional_provider): + definitions = [*definitions, *optional_provider()] + return definitions return _tool_definitions_for(server) diff --git a/hub_core/promotion_gate_recording.py b/hub_core/promotion_gate_recording.py new file mode 100644 index 0000000..727b2c0 --- /dev/null +++ b/hub_core/promotion_gate_recording.py @@ -0,0 +1,189 @@ +"""Runtime-independent persistence for a validated promotion-gate receipt. + +The promotion gate is evaluated without side effects. The admission +transaction freezes its validated gate receipt below the project's evidence +role before invoking native durable result promotion, then removes that exact +receipt if promotion rejects a raced destination. It intentionally shares +the append-only/no-clobber path discipline with human review recording while +remaining independent of the durable promotion primitive. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from .atomic_no_clobber import AtomicNoClobberUnavailable +from .promotion_gate_receipt import ( + canonical_promotion_gate_receipt_bytes, + validate_promotion_gate_receipt, +) +from .review_recording import ( + ReviewRecordExistsError, + _append_no_clobber, + _canonical_relative_path, + _prepare_evidence_root, + _reject_existing_destination, +) +from .structure_path_security import ( + capture_directory_witness, + capture_project_root, + delete_file_by_identity, + lease_directory_witness, +) + + +class PromotionGateRecordingError(RuntimeError): + """A validated promotion-gate receipt could not be frozen safely.""" + + +class PromotionGateRecordExistsError(PromotionGateRecordingError): + """The append-only promotion-gate destination already exists.""" + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate keys while decoding persisted receipt JSON.""" + + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON object key") + result[key] = value + return result + + +def _reject_non_finite_json_constant(value: str) -> None: + raise ValueError(f"non-finite JSON constant {value!r}") + + +@dataclass(frozen=True, slots=True) +class PromotionGateRecordingResult: + """Runtime-independent identity of a frozen promotion-gate receipt.""" + + relative_path: str + receipt_id: str + canonical_sha256: str + size_bytes: int + # Private ownership witness used only for a failed-promotion rollback. It + # is excluded from repr/equality so the public result remains runtime + # independent while the transaction retains exact inode ownership. + _created_identity: tuple[int, int] | None = field(default=None, repr=False, compare=False) + + +def record_promotion_gate_receipt( + receipt: Mapping[str, Any], + *, + evidence_root: str | os.PathLike[str], + relative_path: str | os.PathLike[str], +) -> PromotionGateRecordingResult: + """Freeze one canonical gate receipt below ``evidence_root``. + + This function is append-only. It validates the closed gate DTO before + touching the destination and never replaces a competing inode. + """ + + try: + normalized = validate_promotion_gate_receipt(receipt) + payload = canonical_promotion_gate_receipt_bytes(normalized) + relative = _canonical_relative_path(relative_path) + root = _prepare_evidence_root(evidence_root) + root_identity = capture_project_root(root) + parent_relative = PurePosixPath(relative).parent.as_posix() + witness = capture_directory_witness(root, parent_relative, root_identity=root_identity, create=True) + destination = root.joinpath(*PurePosixPath(relative).parts) + with lease_directory_witness(witness): + _reject_existing_destination(destination) + _append_no_clobber(destination, payload) + metadata = destination.stat(follow_symlinks=False) + created_identity = (metadata.st_dev, metadata.st_ino) + except PromotionGateRecordingError: + raise + except ReviewRecordExistsError as exc: + raise PromotionGateRecordExistsError(str(exc)) from exc + except (TypeError, ValueError, RuntimeError, OSError, AtomicNoClobberUnavailable) as exc: + raise PromotionGateRecordingError(str(exc)) from exc + return PromotionGateRecordingResult( + relative_path=relative, + receipt_id=normalized["receipt_id"], + canonical_sha256=normalized["integrity"]["canonical_sha256"], + size_bytes=len(payload), + _created_identity=created_identity, + ) + + +def discard_promotion_gate_receipt( + result: PromotionGateRecordingResult, + *, + evidence_root: str | os.PathLike[str], +) -> None: + """Remove only the exact gate receipt created by this transaction. + + This is used when native durable promotion rejects a raced destination + after the gate receipt was frozen. A replacement inode is never removed. + """ + + if result._created_identity is None: + raise PromotionGateRecordingError("promotion gate receipt ownership witness is unavailable") + try: + root = Path(evidence_root).expanduser() + except (TypeError, ValueError) as exc: + raise PromotionGateRecordingError("promotion gate evidence root is unavailable") from exc + if not root.is_absolute() or root.is_symlink() or not root.is_dir(): + raise PromotionGateRecordingError("promotion gate evidence root changed before rollback") + relative = _canonical_relative_path(result.relative_path) + parent_relative = PurePosixPath(relative).parent.as_posix() + try: + root_identity = capture_project_root(root) + witness = capture_directory_witness(root, parent_relative, root_identity=root_identity, create=False) + destination = root.joinpath(*PurePosixPath(relative).parts) + with lease_directory_witness(witness): + metadata = destination.lstat() + if destination.is_symlink() or not destination.is_file(): + raise PromotionGateRecordingError("promotion gate receipt destination changed before rollback") + current = (metadata.st_dev, metadata.st_ino) + if current != result._created_identity: + raise PromotionGateRecordingError("promotion gate receipt ownership changed before rollback") + current_bytes = destination.read_bytes() + try: + parsed = json.loads( + current_bytes.decode("utf-8"), + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_non_finite_json_constant, + ) + if not isinstance(parsed, Mapping): + raise ValueError("receipt JSON root must be an object") + validated = validate_promotion_gate_receipt(parsed) + canonical_bytes = canonical_promotion_gate_receipt_bytes(validated) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc: + raise PromotionGateRecordingError( + "promotion gate receipt ownership changed before rollback" + ) from exc + if ( + canonical_bytes != current_bytes + or validated["integrity"]["canonical_sha256"] != result.canonical_sha256 + ): + raise PromotionGateRecordingError("promotion gate receipt ownership changed before rollback") + full_bytes_sha256 = hashlib.sha256(current_bytes).hexdigest() + if not delete_file_by_identity(destination, result._created_identity, full_bytes_sha256): + raise PromotionGateRecordingError( + "promotion gate receipt rollback ownership is ambiguous; receipt retained for review" + ) + except FileNotFoundError: + return + except PromotionGateRecordingError: + raise + except OSError as exc: + raise PromotionGateRecordingError("promotion gate receipt rollback failed") from exc + + +__all__ = [ + "PromotionGateRecordExistsError", + "PromotionGateRecordingError", + "PromotionGateRecordingResult", + "discard_promotion_gate_receipt", + "record_promotion_gate_receipt", +] diff --git a/hub_core/result_promotion.py b/hub_core/result_promotion.py index fdfa096..66ae30a 100644 --- a/hub_core/result_promotion.py +++ b/hub_core/result_promotion.py @@ -28,6 +28,11 @@ from .project_paths import project_path_has_symlink_component, resolve_project_output from .project_structure_contract import resolve_project_structure from .promotion_gate_receipt import validate_promotion_gate_receipt +from .promotion_gate_recording import ( + PromotionGateRecordingError, + discard_promotion_gate_receipt, + record_promotion_gate_receipt, +) class ResultPromotionError(RuntimeError): @@ -46,7 +51,7 @@ def _verify_promotion_gate_admission( expected_project_id: str, expected_artifact_id: str, decision_scope: str, -) -> None: +) -> dict[str, Any]: """Require a canonical eligible gate bound to this result's lineage. The evaluator's report is accepted as a convenience envelope when it @@ -96,6 +101,7 @@ def _verify_promotion_gate_admission( expected_lineage_sha = durable_receipt.canonical_sha256() if subject.get("lineage_receipt_sha256") != expected_lineage_sha: raise ResultPromotionError("promotion gate subject does not bind the durable lineage receipt") + return validated def _sha256_text(value: object) -> str: @@ -380,6 +386,7 @@ def promote_eligible_project_result( manifest_sha256=hashlib.sha256(manifest_bytes).hexdigest(), publication_policy=publication_policy, ) + validated_gate_receipt: dict[str, Any] | None = None if promotion_gate_receipt is not None: project_config = config.get("project") if isinstance(config, Mapping) else None project_name = project_config.get("name") if isinstance(project_config, Mapping) else None @@ -390,7 +397,7 @@ def promote_eligible_project_result( expected_artifact_id = opaque_figure_artifact_id(str(figure_id)) except (TypeError, ValueError) as exc: raise ResultPromotionError("promotion gate subject identities are malformed") from exc - _verify_promotion_gate_admission( + validated_gate_receipt = _verify_promotion_gate_admission( promotion_gate_receipt, primary_sha256=str(primary["sha256"]).lower(), durable_receipt=receipt, @@ -412,13 +419,42 @@ def promote_eligible_project_result( receipt_relpath.as_posix(), purpose="durable result receipt", ) - return promote_result_with_receipt( - runtime_artifact, - destination, - receipt, - receipt_destination, - runtime_root=runtime_root, - ) + gate_relpath = evidence_root / f"figure-{figure_key}.promotion-gate.json" + if validated_gate_receipt is not None: + try: + frozen_gate = record_promotion_gate_receipt( + validated_gate_receipt, + evidence_root=Path(project_root) / evidence_root, + relative_path=gate_relpath.relative_to(evidence_root).as_posix(), + ) + except Exception as exc: + raise ResultPromotionError( + f"promotion gate receipt could not be frozen: {exc}" + ) from exc + else: + frozen_gate = None + try: + promoted = promote_result_with_receipt( + runtime_artifact, + destination, + receipt, + receipt_destination, + runtime_root=runtime_root, + ) + except Exception as exc: + if frozen_gate is not None: + try: + discard_promotion_gate_receipt( + frozen_gate, + evidence_root=Path(project_root) / evidence_root, + ) + except PromotionGateRecordingError as cleanup_exc: + raise ResultPromotionError( + "promotion failed and promotion-gate receipt rollback was withheld: " + f"{cleanup_exc}" + ) from exc + raise + return promoted __all__ = [ diff --git a/tests/test_mcp_write_gating.py b/tests/test_mcp_write_gating.py new file mode 100644 index 0000000..1002ffa --- /dev/null +++ b/tests/test_mcp_write_gating.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hub_core.human_review_receipt import ( + build_review_subject, + canonical_human_review_receipt_bytes, + opaque_figure_artifact_id, + opaque_project_id, +) +from hub_core.mcp import FigOpsMCPServer +from hub_core.promotion_gate_recording import ( + PromotionGateRecordingError, + discard_promotion_gate_receipt, + record_promotion_gate_receipt, +) +from tests.human_review_receipt_helpers import receipt +from tests.test_mcp_rendering import _write_project_render_fixture +from tests.test_result_promotion_integration import _gate_receipt + + +def _review(project_name: str = "Project Render Fixture", figure_id: str = "Fig1") -> dict[str, object]: + subject = build_review_subject( + project_id=opaque_project_id(project_name), + artifact_id=opaque_figure_artifact_id(figure_id), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + return receipt(subject=subject) + + +def _server(root: Path, *, writes: bool) -> FigOpsMCPServer: + return FigOpsMCPServer( + research_root=root, + runtime_root=root / "runtime", + write_tools_enabled=writes, + ) + + +def _arguments( + project: Path, + review: dict[str, object], + *, + figure_id: str = "Fig1", + **extra: object, +) -> dict[str, object]: + return { + "project_path": str(project), + "figure_id": figure_id, + "relative_path": "human/review.json", + "review_receipt": review, + **extra, + } + + +def test_review_writer_is_write_gated_and_not_discovered_by_frozen_profiles(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + review = _review() + disabled = _server(tmp_path, writes=False) + + response = disabled.call_tool("figops.record_human_review", _arguments(project, review)) + assert response["isError"] is True + assert response["structuredContent"]["error_category"] == "disabled" + assert not (project / "results" / "evidence").exists() + assert "figops.record_human_review" not in { + item["name"] for item in disabled.list_tool_definitions() + } + + +def test_enabled_review_writer_records_exact_canonical_bytes_and_binds_subject(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + review = _review() + server = _server(tmp_path, writes=True) + + response = server.call_tool("figops.record_human_review", _arguments(project, review)) + result = response["structuredContent"] + destination = project / "results" / "evidence" / "human" / "review.json" + assert response["isError"] is False + assert result["receipt_id"] == review["receipt_id"] + assert destination.read_bytes() == canonical_human_review_receipt_bytes(review) + + +def test_review_writer_rejects_subject_mismatch_without_creating_evidence(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + server = _server(tmp_path, writes=True) + + wrong_project = server.call_tool( + "figops.record_human_review", _arguments(project, _review("Other Project")) + ) + wrong_artifact = server.call_tool( + "figops.record_human_review", _arguments(project, _review(figure_id="OtherFigure")) + ) + unconfigured_figure = server.call_tool( + "figops.record_human_review", + _arguments(project, _review(figure_id="OtherFigure"), figure_id="OtherFigure"), + ) + assert wrong_project["isError"] is True + assert wrong_artifact["isError"] is True + assert unconfigured_figure["isError"] is True + assert not (project / "results" / "evidence").exists() + + +def test_review_writer_rejects_escape_self_described_authority_and_no_clobber(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + server = _server(tmp_path, writes=True) + review = _review() + + escaped = server.call_tool( + "figops.record_human_review", _arguments(project, review, relative_path="../outside.json") + ) + backslash = server.call_tool( + "figops.record_human_review", _arguments(project, review, relative_path="human\\review.json") + ) + forged = server.call_tool( + "figops.record_human_review", _arguments(project, review, approval={"approved": True}) + ) + assert escaped["isError"] is True + assert backslash["isError"] is True + assert forged["isError"] is True + assert not (tmp_path / "outside.json").exists() + + destination = project / "results" / "evidence" / "human" / "review.json" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"winner") + raced = server.call_tool("figops.record_human_review", _arguments(project, review)) + assert raced["isError"] is True + assert destination.read_bytes() == b"winner" + + +def test_gate_receipt_rollback_removes_owned_file(tmp_path: Path) -> None: + gate = _gate_receipt(gate_status="blocked") + result = record_promotion_gate_receipt( + gate, + evidence_root=tmp_path / "evidence", + relative_path="figure.promotion-gate.json", + ) + destination = tmp_path / "evidence" / result.relative_path + assert destination.exists() + + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + + assert not destination.exists() + + +def test_gate_receipt_rollback_preserves_replaced_inode(tmp_path: Path) -> None: + receipt = _gate_receipt(gate_status="blocked") + result = record_promotion_gate_receipt( + receipt, + evidence_root=tmp_path / "evidence", + relative_path="figure.promotion-gate.json", + ) + destination = tmp_path / "evidence" / result.relative_path + replacement = destination.with_name("replacement.json") + replacement.write_bytes(b"competitor") + os.replace(replacement, destination) + + with pytest.raises(PromotionGateRecordingError, match="ownership"): + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert destination.read_bytes() == b"competitor" diff --git a/tests/test_result_promotion_integration.py b/tests/test_result_promotion_integration.py index 6b60def..99e9124 100644 --- a/tests/test_result_promotion_integration.py +++ b/tests/test_result_promotion_integration.py @@ -2,8 +2,10 @@ import hashlib import json +import os import shutil from pathlib import Path +from unittest.mock import patch import pytest from PIL import Image @@ -17,7 +19,11 @@ opaque_project_id, ) from hub_core.mcp import FigOpsMCPServer -from hub_core.promotion_gate_receipt import GATE_CODE_ORDER, build_promotion_gate_receipt +from hub_core.promotion_gate_receipt import ( + GATE_CODE_ORDER, + build_promotion_gate_receipt, + canonical_promotion_gate_receipt_bytes, +) from hub_core.result_promotion import ResultPromotionError, promote_eligible_project_result DEFAULT_SCOPE = "figure_scientific_and_communication" @@ -436,6 +442,55 @@ def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path, de assert promoted is not None +def test_promotion_failure_rolls_back_frozen_gate_receipt(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-promotion-failure") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + + initial = promote_eligible_project_result(**kwargs) + assert initial is not None + artifact, receipt_artifact = initial + lineage = verify_promoted_result( + artifact.path, + receipt_artifact.path, + durable_root=kwargs["project_root"] / "results", + forbidden_roots=(kwargs["runtime_root"],), + ) + artifact.path.unlink() + receipt_artifact.path.unlink() + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256=lineage.canonical_sha256(), + ) + + with patch( + "hub_core.result_promotion.promote_result_with_receipt", + side_effect=RuntimeError("simulated promotion failure"), + ): + if os.name == "nt": + with pytest.raises(RuntimeError, match="simulated promotion failure"): + promote_eligible_project_result(**kwargs) + else: + with pytest.raises(ResultPromotionError, match="rollback was withheld"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + gate_destinations = list((project / "results" / "evidence").glob("*.promotion-gate.json")) + if os.name == "nt": + assert gate_destinations == [] + else: + # POSIX cannot delete by verified file identity, so the gate receipt + # remains for manual review rather than risking a pathname race. + assert len(gate_destinations) == 1 + gate_destination = gate_destinations[0] + assert gate_destination.read_bytes() == canonical_promotion_gate_receipt_bytes( + kwargs["promotion_gate_receipt"] + ) + assert not (project / "results" / "figures" / "Fig1.png").exists() + + def test_unverified_or_review_required_runtime_result_is_never_promoted(tmp_path: Path) -> None: project = tmp_path / "project" runtime = tmp_path / "runtime" From cb70e3861637ff4ddec0ba326156af8f8ae19b1d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 02:09:46 +0900 Subject: [PATCH 20/21] Fix POSIX gate rollback test expectation --- tests/test_mcp_write_gating.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_mcp_write_gating.py b/tests/test_mcp_write_gating.py index 1002ffa..7e5335a 100644 --- a/tests/test_mcp_write_gating.py +++ b/tests/test_mcp_write_gating.py @@ -12,6 +12,7 @@ opaque_project_id, ) from hub_core.mcp import FigOpsMCPServer +from hub_core.promotion_gate_receipt import canonical_promotion_gate_receipt_bytes from hub_core.promotion_gate_recording import ( PromotionGateRecordingError, discard_promotion_gate_receipt, @@ -143,9 +144,13 @@ def test_gate_receipt_rollback_removes_owned_file(tmp_path: Path) -> None: destination = tmp_path / "evidence" / result.relative_path assert destination.exists() - discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") - - assert not destination.exists() + if os.name == "nt": + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert not destination.exists() + else: + with pytest.raises(PromotionGateRecordingError, match="ambiguous"): + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert destination.read_bytes() == canonical_promotion_gate_receipt_bytes(gate) def test_gate_receipt_rollback_preserves_replaced_inode(tmp_path: Path) -> None: From eb1e462bbdc0929eac0001e28dee34ba03ffae8a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 09:52:18 +0900 Subject: [PATCH 21/21] Update pymdown extensions security floor --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c271a2..a352a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "openpyxl>=3.1,<4", "rich>=13.0,<15", "markdown>=3.10.2", - "pymdown-extensions>=10.21.3", + "pymdown-extensions>=11.0.0", "python-pptx>=1.0.2", "svgutils>=0.3.4", "lxml>=6.1.0", diff --git a/uv.lock b/uv.lock index 6ebc281..a237bae 100644 --- a/uv.lock +++ b/uv.lock @@ -271,7 +271,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.0,<3" }, { name = "pillow", specifier = ">=12.3.0,<13" }, { name = "pyarrow", marker = "extra == 'io'", specifier = ">=14.0" }, - { name = "pymdown-extensions", specifier = ">=10.21.3" }, + { name = "pymdown-extensions", specifier = ">=11.0.0" }, { name = "python-pptx", specifier = ">=1.0.2" }, { name = "pyyaml", specifier = ">=6.0,<7" }, { name = "rich", specifier = ">=13.0,<15" }, @@ -1024,15 +1024,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.3" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]]