diff --git a/CHANGELOG.md b/CHANGELOG.md index cab014b..57da68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,83 @@ versions per [PEP 440](https://peps.python.org/pep-0440/) / _Post-1.0.0 work lands here; legis versions independently from the Weft 1.0 launch on._ -## [1.3.0] — 2026-06-26 - -Suite-standard dot-dir hygiene, plus the legis-resident halves of six cross-repo -Weft seam conformance oracles. +## [1.3.0] — 2026-06-28 + +Suite-standard dot-dir hygiene and the legis-resident halves of six cross-repo +Weft seam conformance oracles; three security/federation fixes (two +governance-honesty hardenings on the posture and protected-cell audit paths, and +a rebuild of the Warpline advisory-preflight seam onto Warpline's real MCP wire); +and two new federation read surfaces — `governance_read.v1` (the per-SEI +governance read legis now **publishes** for Warpline) and a Plainweave +advisory-preflight **consumer**. + +### Security + +- **Posture `read_floor()` fails closed on a chain-integrity break + (legis-476ab6f125).** The hot routing read now gates on the keyless + `verify_integrity()` chain re-hash before returning the floor, so a + raw-DB-written/forged ledger tail can no longer silently set the routing floor: + an unverifiable chain resolves to `None` → the fail-closed `structured` + default, never the tampered floor. A recomputed-keyless-chain forgery remains + the conceded raw-file-write residual (README "Known security limitations"), + pinned by a characterization test rather than implied-closed. +- **Protected-cell batches advance the HeadAnchor (legis-0c310712a7).** + `ProtectedGate` gains a `transaction()` context manager — a parity mirror of + `SignoffGate.transaction()` — that advances the out-of-band HeadAnchor after a + batched protected append commits, so a later tail-truncation back to a stale + anchor is detectable. Preventive/parity hardening: production wires no anchor + and no caller batches protected appends today. + +### Changed — Warpline advisory preflight rebuilt onto the real MCP wire + +- **`warpline_preflight_get` now speaks Warpline's actual surface + (legis-a53d92507d).** The prior client issued `GET /api/impact-radius` against + an HTTP server Warpline never served. It is replaced by a stdlib stdio JSON-RPC + client (`WarplineMcpClient` + `StdioMcpInvoke`, no new dependency) that consumes + Warpline's **extant** `warpline_impact_radius_get` / `warpline_reverify_worklist_get` + MCP tools with a `rev_range`, parses the real `warpline.impact_radius.v1` / + `warpline.reverify_worklist.v1` envelope, and verifies the + `meta.local_only` / `peer_side_effects` invariant (GV-LG-3), failing closed on + every fault. Config moves from `WARPLINE_API_URL` to `WARPLINE_MCP_CMD`. The + advisory boundary is unchanged: governance verdicts stay byte-identical with or + without Warpline, and any failure degrades to a discriminated `unavailable`. + This **reverses** the earlier "Warpline must ship a flat HTTP producer" framing + — legis conforms to Warpline's frozen envelope (hub SEAM 4 §4A / GV-LG-3); + Warpline builds nothing. + +### Added — federation read surfaces: `governance_read.v1` (producer) + Plainweave (consumer) + +- **`governance_read.v1` — legis publishes a per-SEI governance read for Warpline + (PDR-0007).** A new surface on CLI (`legis governance-read `), MCP + (`governance_read` tool) and HTTP (`GET /governance/sei/{sei:path}/governance-read`), + returning the frozen `governance_read.v1` envelope (contract committed at + `contracts/governance_read.v1.schema.json`). It is a pure projection of the + forge-proof per-SEI attestation read (`read_sei_attestations`) into a + **cleared-only** posture-record shape (`operator_override` → `protected_override`, + `signoff_cleared` → `operator_signoff`), so Warpline's + `reverify_worklist(include_federation=True)` can enrich its worklist with legis + governance facts. Fail-closed throughout: an unverifiable trail resolves to a + discriminated `status: "unavailable"` (never a silent `records: []`); a tampered + trail fails loud on all three transports (HTTP 500 / MCP `AUDIT_INTEGRITY_FAILURE` + / CLI nonzero); `records: []` under `checked` is honest absence of clearance, + never "ungoverned" or "unknown SEI" (legis is an SEI consumer, never the + authority). Advisory-only — Warpline echoes it as `enrichment.governance` and + never gates on it (GV-LG-1). The discriminated-union schema and the CLI + chain-integrity guard are both **mutation-proven** against false-greens. This + ships the producer half of the obligation the 1.3.0 conformance note flagged as + unwired; Warpline's consumer (`LegisClient.governance_for_sei`) is the remaining + integration. + +- **Plainweave preflight — advisory consumer (PDR-0008).** legis gains a read-only + `plainweave_preflight_get` consumer of Plainweave's + `weft.plainweave.preflight_facts.v1` producer (its first sibling consumer), + mirroring the Warpline advisory-preflight read exactly: injectable + `PlainweaveMcpClient` + `StdioMcpInvoke`, every fault fails closed → + `unavailable`, GV-LG-3 validated against Plainweave's real `authority_boundary` + shape, configured via `PLAINWEAVE_MCP_CMD` (default unconfigured → + `unavailable`). Enrich-only; governance verdicts stay byte-identical with or + without Plainweave. The conformance oracle drives a constructed golden (live + end-to-end capture is a flagged follow-up). ### Added @@ -42,11 +115,22 @@ Weft seam conformance oracles. the default suite) plus a skip-clean Layer-2 source recheck: SEI (loomweave→legis), git-renames (legis→loomweave), signoff-binding (legis→filigree), loomweave-HMAC-wire (legis→loomweave, live-gated), the - warpline preflight read (legis consumer), and the per-SEI `attestation_get` - read (legis producer). Two outstanding peer obligations are recorded rather - than papered over — warpline ships no flat HTTP producer for the preflight - shape, and warpline's `LegisClient.governance_for_sei` is unwired — with the - Layer-2 rechecks armed to fire when each peer lands its half. + warpline preflight read (legis consumer — now driving Warpline's real + `warpline.impact_radius.v1` / `warpline.reverify_worklist.v1` MCP envelope; see + *Changed* above), and the per-SEI `attestation_get` read (legis producer). The + peer obligation this note previously flagged as unwired — the per-SEI governance + read Warpline needs — is now **shipped on legis's side** as `governance_read.v1` + (see *Added* above, with its own frozen-golden oracle); Warpline's consumer + (`LegisClient.governance_for_sei`) is the remaining integration half, tracked for + the live handshake. + +### Fixed + +- **`legis doctor` no longer double-prints the `[operator]` audience tag.** Five + operator-key check messages baked a literal `[operator]` into the message text + while the renderer also appends the audience tag, so the lines read + `… [operator] [operator]`. The literals are stripped; the renderer remains the + single owner of the tag. ## [1.2.0] — 2026-06-25 diff --git a/contracts/governance_read.v1.schema.json b/contracts/governance_read.v1.schema.json new file mode 100644 index 0000000..64f254a --- /dev/null +++ b/contracts/governance_read.v1.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://weft.dev/contracts/governance_read.v1.schema.json", + "title": "legis governance_read.v1", + "description": "Per-SEI VERIFIED GOVERNANCE CLEARANCE facts produced by legis (the governance authority) and consumed advisorily by siblings (e.g. warpline reverify enrichment). v1 reports verified clearances ONLY (operator overrides + cleared structured/protected sign-offs). 'records: []' under status 'checked' = no verified clearance for this SEI on legis's verified trail -- NOT 'ungoverned', and NOT 'unknown SEI' (legis is an SEI consumer, never the SEI authority; it cannot distinguish unknown-from-ungoverned). status 'unavailable' = legis could not produce a signature-verifiable answer (no protected gate / verifier / key); consumers MUST treat it as 'unavailable', NEVER as 'absent'. A TAMPERED trail does not reach this envelope at all -- it fails loud as a transport-level integrity error (MCP AUDIT_INTEGRITY_FAILURE / HTTP 5xx / CLI nonzero exit).", + "type": "object", + "required": ["status", "sei", "records"], + "additionalProperties": false, + "properties": { + "status": { + "description": "Envelope state. 'checked' = the verified trail was read (records may be empty = honest absence of clearance). 'unavailable' = could not produce a signature-verifiable answer.", + "enum": ["checked", "unavailable"] + }, + "sei": { + "description": "The SEI queried, echoed verbatim. Opaque; never parsed.", + "type": "string", + "minLength": 1 + }, + "records": { + "description": "Verified governance clearance records keyed on this SEI. Empty under 'checked' = honest absence. Always [] under 'unavailable'.", + "type": "array", + "items": { "$ref": "#/$defs/clearance_record" } + }, + "unavailable": { + "description": "Present ONLY under status 'unavailable' (and required there): why legis could not produce a verifiable answer. Non-empty.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["reason"], + "additionalProperties": false, + "properties": { "reason": { "type": "string", "minLength": 1 } } + } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "unavailable" } }, "required": ["status"] }, + "then": { + "required": ["unavailable"], + "properties": { "records": { "maxItems": 0 } } + } + }, + { + "if": { "properties": { "status": { "const": "checked" } }, "required": ["status"] }, + "then": { "not": { "required": ["unavailable"] } } + } + ], + "$defs": { + "clearance_record": { + "type": "object", + "required": ["sei", "disposition", "posture", "authority", "as_of", "reasons", "content_hash"], + "additionalProperties": false, + "properties": { + "sei": { + "description": "The SEI this clearance is keyed on (== the SIGNED entity_key.value; identity-stable).", + "type": "string", + "minLength": 1 + }, + "disposition": { + "description": "Record-level governance disposition. NOT the envelope 'status', NOT a consumer's 'enrichment.governance'. v1 closed enum = {'cleared'}: every record is a verified human clearance. (Future dispositions arrive in governance_read.v2 -- v1 is never mutated.)", + "enum": ["cleared"] + }, + "posture": { + "description": "The PROVABLE clearance mechanism. legis does NOT claim the enforcement cell for a sign-off (it cannot prove structured-vs-protected for a cleared sign-off) -- it reports the mechanism it can prove. 'protected_override' = a protected operator-override verdict; 'operator_signoff' = an operator-cleared sign-off.", + "enum": ["protected_override", "operator_signoff"] + }, + "authority": { + "description": "Clearing-authority class. v1: both clearance kinds are operator-cleared.", + "enum": ["operator"] + }, + "as_of": { + "description": "Timestamp from the SIGNED recorded_at of the clearance record (RFC3339 UTC; '+00:00' and 'Z' both valid). A clearance whose recorded_at is absent or non-RFC3339 is OMITTED by the producer (asymmetric-error rule) -- it never ships as null.", + "type": "string", + "format": "date-time" + }, + "reasons": { + "description": "Closed-vocab codes naming the clearance kind (what happened). Never free prose. Distinct axis from 'posture' (the provable mechanism / how); v1 correlates them 1:1 by construction, but v2 may carry additional reason codes without changing posture.", + "type": "array", + "minItems": 1, + "items": { "enum": ["operator_override", "signoff_cleared"] } + }, + "content_hash": { + "description": "The SIGNED Loomweave content hash binding the clearance to the governed content (non-empty).", + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/docs/contracts/warpline-governance-read.v1-prompt.md b/docs/contracts/warpline-governance-read.v1-prompt.md new file mode 100644 index 0000000..5742a0f --- /dev/null +++ b/docs/contracts/warpline-governance-read.v1-prompt.md @@ -0,0 +1,169 @@ +# Prompt for WARPLINE — wire `LegisGovernanceClient` to legis's `governance_read.v1` + +> **Canonical contract:** `contracts/governance_read.v1.schema.json` in the legis repo. **Mirror that +> file** — it is the source of truth. The copy embedded below is an abridged convenience mirror: every +> *validating* keyword (type/enum/minLength/format/allOf/if-then) is identical to the canonical file +> and legis CI asserts that equality; only the human `description` annotations are dropped here (they +> live in the canonical file). + +> **⚠ Contract hardened since the first hand-off (backward-compatible).** The schema now enforces the +> discriminated union (status `unavailable` ⇒ a non-empty `unavailable` reason array + `records: []`; +> status `checked` ⇒ no `unavailable` key). This only *tightens* what legis emits — legis never +> produced the now-rejected shapes — so a consumer that already built against the looser draft is not +> broken. Re-mirror the schema below for your own validation. + +You asked legis to expose a per-SEI governance read so `reverify_worklist(include_federation=True)` +can enrich its worklist with legis governance facts. Legis owns this contract (`governance_read.v1`) +because legis is the governance authority; you consume it advisorily. Build your +`LegisGovernanceClient` against the shape below. + +**Before you finalize the consumer, confirm the v1 SCOPE (one section below) matches what your +`enrichment.governance` displays.** A scope mismatch is the one thing a parallel build hides until +both sides are done — settle it first. Everything else here is final. + +## Trust boundary (restated so the contract can't erode it) + +- Legis is the **governance authority**; warpline is an **advisory consumer**. You ECHO legis + governance as `enrichment.governance: present | absent | unavailable`. You **NEVER gate** the + reverify decision on it. `GV-LG-1` (no `governance_verdict` in warpline output) stays asserted. +- Legis reports **facts it cryptographically verified**, never a verdict for you. Honest absence is + a first-class answer; an unanswerable read fails **loud**, never silent-empty. + +## v1 SCOPE — confirm it, THEN finalize ⚠️ the coordination point + +`governance_read.v1` reports **verified governance CLEARANCES only**, keyed on SEI: + +- `operator_override` — a protected-cell operator-override verdict (`OVERRIDDEN_BY_OPERATOR`), signed. +- `signoff_cleared` — a structured/protected sign-off an operator **cleared**, signed with an + integrity-bound request join. + +It does **NOT** report in-flight / uncleared governance (open PENDING sign-offs, BLOCKED verdicts, +Filigree bindings) — a deliberate v1 non-goal. + +**The honesty contract for your enrichment:** + +- `records: []` (under `status: "checked"`) means **"legis holds no verified governance clearance + for this SEI."** NOT "ungoverned," NOT "unknown SEI." An entity under an *open* structured block + has no clearance yet → it returns `[]`. So treat `enrichment.governance: absent` as **"no verified + clearance,"** not "ungoverned." If your display would read `absent` as "ungoverned," relabel the + key (e.g. `governance_clearance: present|absent`) or tell legis you need in-flight context — that's + a **pre-build scope bump** (cheap now) or a `governance_read.v2`, never a mutation of v1. A v2 that + ADDS dispositions/kinds leaves every v1 cleared record valid, so v1=cleared-only is a safe subset. +- **Legis cannot distinguish "unknown SEI" from "known SEI, no clearance"** (it is an SEI consumer, + never the authority). `[]` deliberately conflates them. "Does this SEI exist" is a Loomweave query. + +→ **Confirm: does cleared-only enrichment match your `enrichment.governance` semantics? Reply before +finalizing the consumer if you need more than verified clearances.** + +## The contract — `governance_read.v1.schema.json` (structural mirror; validating keywords identical, descriptions in the canonical file) + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://weft.dev/contracts/governance_read.v1.schema.json", + "title": "legis governance_read.v1", + "type": "object", + "required": ["status", "sei", "records"], + "additionalProperties": false, + "properties": { + "status": { "enum": ["checked", "unavailable"] }, + "sei": { "type": "string", "minLength": 1 }, + "records": { "type": "array", "items": { "$ref": "#/$defs/clearance_record" } }, + "unavailable": { + "type": "array", "minItems": 1, + "items": { "type": "object", "required": ["reason"], "additionalProperties": false, + "properties": { "reason": { "type": "string", "minLength": 1 } } } + } + }, + "allOf": [ + { "if": { "properties": { "status": { "const": "unavailable" } }, "required": ["status"] }, + "then": { "required": ["unavailable"], "properties": { "records": { "maxItems": 0 } } } }, + { "if": { "properties": { "status": { "const": "checked" } }, "required": ["status"] }, + "then": { "not": { "required": ["unavailable"] } } } + ], + "$defs": { + "clearance_record": { + "type": "object", + "required": ["sei", "disposition", "posture", "authority", "as_of", "reasons", "content_hash"], + "additionalProperties": false, + "properties": { + "sei": { "type": "string", "minLength": 1 }, + "disposition": { "enum": ["cleared"] }, + "posture": { "enum": ["protected_override", "operator_signoff"] }, + "authority": { "enum": ["operator"] }, + "as_of": { "type": "string", "format": "date-time" }, + "reasons": { "type": "array", "minItems": 1, + "items": { "enum": ["operator_override", "signoff_cleared"] } }, + "content_hash": { "type": "string", "minLength": 1 } + } + } + } +} +``` + +Field meanings: `disposition` = record-level (NOT the envelope `status`, NOT your +`enrichment.governance`); v1 = `{cleared}`. `posture` = the **provable** clearance mechanism +(`protected_override` | `operator_signoff` — legis won't claim structured-vs-protected for a +sign-off). `authority` = `operator`. `as_of` = the signed `recorded_at` (RFC3339 UTC; a clearance +whose `recorded_at` is missing/non-RFC3339 is OMITTED by legis, never shipped as null). `reasons` = +closed-vocab kind codes. `content_hash` = the signed Loomweave content hash (non-empty). + +### Literal samples (validate against the schema) + +```json +{ "status": "checked", "sei": "loomweave:eid:7Q3f...c1", "records": [ + { "sei": "loomweave:eid:7Q3f...c1", "disposition": "cleared", "posture": "protected_override", + "authority": "operator", "as_of": "2026-06-27T14:02:11Z", "reasons": ["operator_override"], + "content_hash": "b3:9f2c...e7" }, + { "sei": "loomweave:eid:7Q3f...c1", "disposition": "cleared", "posture": "operator_signoff", + "authority": "operator", "as_of": "2026-06-26T09:41:55Z", "reasons": ["signoff_cleared"], + "content_hash": "b3:5a10...92" } ] } +``` +```json +{ "status": "checked", "sei": "loomweave:eid:unknown...", "records": [] } +``` +```json +{ "status": "unavailable", "sei": "loomweave:eid:7Q3f...c1", "records": [], + "unavailable": [{ "reason": "trail not signature-verifiable (no protected gate / verifier)" }] } +``` + +## The three transports legis exposes (same `governance_read.v1` envelope on each) + +- **MCP tool** `governance_read`, args `{ "sei": "" }` — surfaces the envelope as its result + (with a matching `outputSchema`); a **tampered** trail returns the MCP **error** envelope + (`AUDIT_INTEGRITY_FAILURE`), not a result. *(You're already an MCP client of legis — likely your path.)* +- **HTTP** `GET /governance/sei/{sei}/governance-read` — body is the envelope; tampered trail = 5xx. + (SEI is captured as a full path segment; URL-encode a SEI containing `/`.) +- **CLI** `legis governance-read ` — stdout the envelope JSON; tampered trail / missing store = + loud (nonzero exit / `status:"unavailable"` for a genuinely absent store, never a silent `checked/[]`). + +## Your `LegisGovernanceClient` — the mapping (satisfies `LegisClient` Protocol) + +```python +class LegisGovernanceClient: # satisfies warpline's LegisClient Protocol + def governance_for_sei(self, sei: str) -> list[dict]: + try: + resp = self._invoke_legis_governance_read(sei) # MCP tool / HTTP GET / CLI + except (TransportError, LegisIntegrityError): + raise LegisGovernanceUnavailable(sei) # tampered/transport -> unavailable + if resp["status"] == "unavailable": + raise LegisGovernanceUnavailable(sei, resp.get("unavailable")) # -> unavailable + return resp["records"] # [] -> absent (= "no verified clearance") ; [...] -> present +``` + +Then in `_h_reverify` (`warpline mcp.py:441`, the single `legis_client=None` site): pass a +`LegisGovernanceClient()`. Expected: the legis federation member flips `weft_reason: disabled` → +`clean` with records under `entities[].governance`; `enrichment.governance` flips `unavailable` → +`present` for cleared entities, `absent` otherwise. + +## Acceptance (your side) + +1. `LegisGovernanceClient.governance_for_sei` satisfies the `LegisClient` Protocol; in + `reverify_worklist(include_federation=True)` the legis member is `clean` (not `disabled`) with + records for a known-cleared entity. +2. Reachable SEI, no clearance → `[]` → `absent`; unavailable/tampered legis → **raises** → + `unavailable`. Never the reverse (`GV-LG`/`GOV-2` discipline). +3. A test asserts `enrichment.governance` does NOT affect the reverify decision (advisory boundary; + `GV-LG-1` `governance_verdict` stays absent). +4. A captured real legis response validates against `governance_read.v1.schema.json` (the tightened + discriminated-union version above). diff --git a/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md b/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md new file mode 100644 index 0000000..8f8fcc3 --- /dev/null +++ b/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md @@ -0,0 +1,403 @@ +# Posture read_floor Fail-Closed Integrity Gate — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `PostureLedger.read_floor()` fail closed when the ledger cannot prove its own chain integrity, so a raw-DB-written/forged tail record can no longer silently set the routing floor (legis-476ab6f125; PRD-0005 criterion 1). + +**Architecture:** `read_floor()` gains a `verify_integrity()` gate (the existing keyless O(N) chain re-hash on `AuditStore`) before its descending floor scan; a failed verification returns `None`, which every caller already maps to the fail-closed `structured` default. The keyless hot read proves **integrity + tail-kind** — NOT operator authorization. Cryptographic `operator_sig` verification under the epoch key stays the operator-side `doctor` check, but note its true scope: `doctor` verifies `operator_sig` only in `_transition_acknowledges` (`doctor.py:649`), reached only via `check_posture_key_reset` (`doctor.py:677`, which early-returns "no key-epoch reset" at `:707` when there is none) **when there is a `KEY_RESET` to acknowledge**. A file-write attacker who *recomputes* the keyless chain on a forged floor-lowering `TRANSITION` of a **non-rekeyed** ledger is therefore caught by **neither** this hot read **nor** `doctor` today — it is a pure conceded raw-file-write residual (`README.md:137`), pinned by an explicit characterization test, not implied-closed. + +**Tech Stack:** Python 3.12, SQLAlchemy Core over SQLite, `uv`, pytest. No new dependencies. + +**Prerequisites:** +- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/posture-read-floor-verify-integrity`). Per the authority grant, the merge to main + any publish is owner-gated; this plan ends at a green branch + an accepted finding. +- `uv sync --dev` already run; `.venv` present. +- Read context (already grounded): `src/legis/posture/ledger.py:92` (`read_floor`), `src/legis/store/audit_store.py:362` (`verify_integrity`, keyless), `src/legis/doctor.py:649` (`_transition_acknowledges`) / `:677` (`check_posture_key_reset` — KEY_RESET-ack path only, early-returns at `:707` with no reset), `tests/posture/test_security_honesty.py` (fixtures + the suite the two new tests land in), `tests/posture/test_ledger.py:128` (`test_read_floor_uses_tail_read` — the one existing test the fix retires; see Task 1 Step 5), `tests/store/test_audit_store.py:22,78` (the canonical `sqlite3.connect` raw-DB-write pattern the new tests reuse), `README.md:137` (the conceded residual to cite). + +**Scope fence (PRD-0005 non-goals):** Do NOT add operator-key verification to the hot read, do NOT touch `src/legis/store/audit_store.py` or `canonical.py` (the cross-tool HMAC contract), do NOT re-architect the posture subsystem. The change touches exactly: `read_floor()` + the module docstring in `ledger.py`; **two new tests** in `test_security_honesty.py`; and **one rewritten test** in `test_ledger.py` (`test_read_floor_uses_tail_read`, whose "must not call read_all" premise the integrity gate deliberately retires). + +**Scope acknowledgment (tracked follow-ups, NOT this PR):** three sibling reads call `read_all()` with no `verify_integrity()` gate — `epoch_reset_unacknowledged` (`ledger.py:120`), `current_epoch_fingerprint` (`ledger.py:151`), `session_opened_recorded` (`ledger.py:299`). `read_floor` is the P0 (it alone feeds the routing chokepoint `floored_registry` with no downstream keyed check) and is closed here. The siblings that feed `set_floor` are defended downstream by the operator key; but `epoch_reset_unacknowledged` feeds the agent-facing `posture_get` MCP tool (`mcp.py:2550`) with **no** keyed backstop — file a follow-up for it. Do not let these remain silently scoped out (the plan's own honesty discipline: make residuals explicit, never implied-closed). + +--- + +### Task 1: read_floor fails closed on a chain-integrity break + +**Files:** +- Modify: `src/legis/posture/ledger.py` — `read_floor` (lines 92–118: add the gate + honest docstring) **and** the module docstring (lines 13–18, which currently claims the floor is found "never the O(N) `read_all` loop" — false post-fix). +- Test (new): `tests/posture/test_security_honesty.py` (add one test, reuse existing fixtures) +- Test (rewrite): `tests/posture/test_ledger.py` — `test_read_floor_uses_tail_read` (Step 5; its premise is retired by the gate) + +**Step 1: Write the failing test** + +Add to `tests/posture/test_security_honesty.py`. Module imports needed at top (add if absent): `import json`, `import sqlite3`, and `from legis.posture.ledger import _sqlite_file` (the production URL→path helper, so the raw write hits the exact file the store engine uses). Reuses the file's existing `_genesis`, `_MemSigner`, `_open_recorded_session`, `set_floor`, `FixedClock`, `KIND_TRANSITION`, `mint_key`. + +```python +def test_read_floor_fails_closed_on_integrity_break(tmp_path): + """A raw-DB tail record that lowers the floor but breaks the keyless hash + chain must NOT be trusted: read_floor() fails closed (returns None -> + structured), never the forged floor. (legis-476ab6f125; PRD-0005 crit 1.) + + Uses the canonical raw-file-write attacker model (sqlite3.connect, as in + tests/store/test_audit_store.py:22,78), not the ORM. In-place-edit / reorder + / seq-gap tamper is already pinned for the same gate at + tests/store/test_audit_store.py:78 and :246; this test exercises the + tail-append vector through read_floor specifically. + """ + key_hex = mint_key() + key_bytes = bytes.fromhex(key_hex) + ledger, _ = _genesis(tmp_path, key_hex=key_hex) # GENESIS @ chill + + # Elevate to protected via a signed transition so a downgrade is visible. + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) + set_floor( + "protected", ledger=ledger, signer=_MemSigner(key_bytes), + agent_id="op", rationale="tighten", clock=FixedClock("t1"), + ) + assert ledger.read_floor() == "protected" + + # Simulate a raw-file-write attacker: append a tail row claiming + # floor="chill" with a BROKEN chain (garbage hashes). INSERT is allowed: + # the append-only triggers block only UPDATE/DELETE. + head_seq, _ = ledger.store.get_latest_sequence_and_hash() + forged = { + "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": None, + "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9", + "rationale": "forged", "session_id": None, + } + conn = sqlite3.connect(str(_sqlite_file(ledger._url))) + try: + conn.execute( + "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, " + "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)", + { + "seq": head_seq + 1, "payload": json.dumps(forged), + "ch": "0" * 64, # does NOT match payload -> verify_integrity fails + "ph": "0" * 64, "xh": "0" * 64, + }, + ) + conn.commit() + finally: + conn.close() + + # Pre-fix: the descending scan returns the forged "chill". Post-fix: the + # broken chain fails verify_integrity, so read_floor fails closed. + assert ledger.read_floor() is None +``` + +**Why this test:** It pins the finding's load-bearing defense — an integrity-breaking forged tail (the realistic naive raw writer) must fail closed. It asserts the *integrity* path (the real fix), not a presence check. + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/posture/test_security_honesty.py::test_read_floor_fails_closed_on_integrity_break -v` + +Expected output (RED — the bug): +``` +FAILED ... assert 'chill' is None +``` +(`read_floor()` currently returns the forged `"chill"`; the assertion `is None` fails — the right reason.) + +**Step 3: Write minimal implementation** + +In `src/legis/posture/ledger.py`, edit `read_floor` (currently lines 92–118): replace the docstring and insert the `verify_integrity()` gate immediately after the absent-file early return, before `_assert_no_batch_in_progress`. + +```python + def read_floor(self) -> str | None: + """The current floor (latest authoritative floor record), or ``None``. + + Fail-closed: the floor sets routing, so the ledger must first PROVE its + own integrity. ``verify_integrity()`` (an O(N) keyless chain re-hash) + gates the read; a chain that does not verify — a raw-write in-place edit, + reorder, or seq gap — yields ``None`` (callers map ``None`` -> the + fail-closed ``structured`` default), never the tampered floor. A missing + DB file or an empty store also report ``None`` (``verify_integrity`` is + True on an empty store; the table-absence check returns ``None`` below). + + The O(N) integrity walk runs on EVERY resolution (the floor is never + cached, design D2) and is deliberate: it is bounded by operator-action + volume (genesis/transition/rekey are operator-gated; session-open churn + is bounded by TTL expiry + human-in-the-loop enabling), immaterial at + posture-ledger scale on local SQLite, and an *unverified* hot read was + the whole bug. The two failure modes are both fail-closed but + asymmetric: a DETECTED tamper (a clean walk that does not verify) + resolves to ``None`` -> ``structured``; an I/O fault (locked/corrupt DB + raising ``OperationalError``) propagates as an exception and aborts the + read — neither is a pass. Do NOT wrap the gate in a try/except that + downgrades that raise to a permissive default. + + SCOPE (honesty): the chain is keyless SHA, so this proves integrity + + tail-kind but NOT operator authorization. A file-write attacker who + *recomputes* the keyless chain on a forged floor-lowering ``TRANSITION`` + passes this gate; on a non-rekeyed ledger that forgery is caught by + NEITHER this keyless hot read NOR ``doctor`` today — it is a PURE + conceded raw-file-write residual (README "Known security limitations", + README.md:137). ``doctor``'s keyed ``operator_sig`` verification + (``_transition_acknowledges``, doctor.py:649) covers ONLY the + ``KEY_RESET``-acknowledgment path (D6), not a ``TRANSITION`` on a ledger + with no reset to acknowledge. A general per-transition ``operator_sig`` + audit in ``doctor`` would close the residual operator-side, but that is + separate follow-up, not this change. See + ``test_read_floor_fails_closed_on_integrity_break`` and + ``test_read_floor_recomputed_chain_forgery_is_conceded_residual``. + """ + path = _sqlite_file(self._url) + if path is not None and not path.exists(): + return None + # Fail closed if the chain cannot prove integrity: a tampered/forged + # ledger must not be trusted to set the routing floor. verify_integrity() + # returns True on an empty store, so an absent/empty ledger still reads + # as None via the table-absence check below, not a spurious failure. + if not self.store.verify_integrity(): + return None + self.store._assert_no_batch_in_progress("read_floor") + with self.store._engine.begin() as conn: + if not self.store._has_log_table(conn): + return None + rows = conn.execute( + select(self.store._log.c.payload) + .order_by(self.store._log.c.seq.desc()) + ) + for row in rows: + payload = json.loads(row.payload) + kind = payload.get("kind") + floor = payload.get("floor") + if kind in self._FLOOR_RECORD_KINDS and floor is not None: + return floor + return None +``` + +Then update the **module docstring** (`ledger.py:13–18`) so it no longer claims the floor is found "never the O(N) `read_all` loop" — that invariant is retired by the gate. Replace that bullet with: + +``` + * The current floor is the latest authoritative floor record's ``floor`` field + (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``). An O(N) keyless + ``verify_integrity`` walk GATES the read (fail-closed: a chain that does not + verify yields ``None`` -> ``structured``); the floor itself is then found by + one descending payload scan from the tail, never a repeated point-read loop + over metadata. Metadata records such as ``OPERATOR_SESSION_OPENED`` must not + lower the effective floor, even if they carry a stale ``floor`` field. +``` + +**Why minimal:** Only the `verify_integrity()` gate + the two honesty docstrings are added; the existing descending scan is untouched. No operator-key handling (deliberately out of the keyless hot read — that is `doctor`'s job and a PRD non-goal). `verify_integrity()` opens/closes its own connection (NullPool), so the subsequent `_engine.begin()` scan is safe; the double O(N) pass is immaterial on a small posture ledger and avoids touching the sensitive `audit_store` HMAC layer. **This consciously reverses a prior architecture decision:** the 2026-06-16 posture-ratchet spec (`docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md:509`) optimized `read_floor()` to stay *off* `read_all()` on the per-request hot path; integrity-before-trust now takes precedence over that O(1) optimization. The tradeoff is intentional and bounded (see the docstring), not an oversight — security before hot-path thrift, justified by the small operator-gated ledger. (The existing `_assert_no_batch_in_progress("read_floor")` is now belt-and-suspenders — `verify_integrity()` runs the same guard first — but is harmless and documents intent; leave it.) + +**Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/posture/test_security_honesty.py::test_read_floor_fails_closed_on_integrity_break -v` + +Expected output: +``` +PASSED +``` + +**Step 5: Rewrite the one existing test the gate retires** + +`tests/posture/test_ledger.py:128–136` (`test_read_floor_uses_tail_read`) monkeypatches `ledger.store.read_all` to RAISE `AssertionError("read_floor must not call read_all (hot path)")`, then asserts `read_floor() == "chill"` on a valid genesis chain. Post-fix, `read_floor()` legitimately calls `read_all` via `verify_integrity()`; `verify_integrity` catches only `(JSONDecodeError, TypeError, ValueError)` (`audit_store.py:373`), so the `AssertionError` propagates and the test **errors**. Its "must not call read_all" premise is exactly the property the integrity-before-trust gate deliberately supersedes — rewrite it to assert the new contract: + +```python +def test_read_floor_verifies_integrity_before_returning_floor(tmp_path): + """read_floor() gates on verify_integrity() before the tail scan: on a valid + chain the gate passes and the floor is returned. Supersedes the old + 'read_floor must not call read_all' guard — the integrity-before-trust gate + DELIBERATELY calls read_all via verify_integrity; that property is RETIRED, + not regressed. (legis-476ab6f125.) + """ + ledger = PostureLedger(_url(tmp_path), initialize=True) + ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0") + assert ledger.store.verify_integrity() is True + assert ledger.read_floor() == "chill" +``` + +(The `read_by_seq`-patching sibling `test_read_floor_does_not_point_read_each_metadata_tail` at `:139` is UNAFFECTED — `verify_integrity` does not call `read_by_seq`.) + +> **TWO TRAPS — DO NOT:** (a) do NOT flip this test to `is None`: a valid genesis chain MUST read `"chill"`, and flipping it wouldn't pass anyway (the old `_boom` raised before any assertion). (b) do NOT "fix" a red `test_read_floor_uses_tail_read` by weakening or reverting the `verify_integrity()` gate to restore the no-`read_all` property — that reintroduces the exact false-green this finding closes. The rename + rewrite IS the fix. + +Run: `uv run pytest tests/posture/test_ledger.py::test_read_floor_verifies_integrity_before_returning_floor -v` → `PASSED`. + +**Step 6: Commit** + +```bash +git add src/legis/posture/ledger.py tests/posture/test_security_honesty.py tests/posture/test_ledger.py +git commit -m "fix(posture): read_floor fails closed on a chain-integrity break + +read_floor() now gates on verify_integrity() before returning the floor, so +a raw-DB-written/forged tail record that breaks the keyless hash chain can no +longer silently set the routing floor (it maps to the fail-closed structured +default). Cryptographic operator_sig verification stays the operator-side +doctor check (KEY_RESET-acknowledgment path only); a recomputed-chain forgery +remains the conceded raw-file-write residual (README.md:137). + +Retires test_read_floor_uses_tail_read's 'must not call read_all' premise (the +integrity gate supersedes it) and corrects the now-false module docstring. + +Closes the load-bearing half of legis-476ab6f125 (PRD-0005 criterion 1). + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +**Definition of Done:** +- [ ] New test written and fails for the right reason (returns "chill" pre-fix) +- [ ] `read_floor` gates on `verify_integrity()` and fails closed to `None` +- [ ] `read_floor` docstring + module docstring (lines 13–18) updated to state the new O(N) cost, the fail-closed asymmetry, and the keyless-read honesty scope (no false `doctor` backstop claim) +- [ ] `test_read_floor_uses_tail_read` rewritten to the new contract (NOT flipped to `is None`, gate NOT weakened) +- [ ] New test passes post-fix; rewritten test passes +- [ ] Committed + +--- + +### Task 2: document the recomputed-chain forgery residual (characterization test) + +**Files:** +- Test (new): `tests/posture/test_security_honesty.py` (add one test — no production change) + +**Step 1: Write the documentation test** + +This test PASSES on the Task-1 code (it asserts a *known limit*, not a new behavior). It exists so the residual is visible and executable in the suite, never implied-closed — the cardinal-sin guard for an honesty tool (a green test an attacker walks around). Reuses the Task-1 imports (`json`, `sqlite3`, `_sqlite_file`) plus the canonical-chain primitives. + +```python +def test_read_floor_recomputed_chain_forgery_is_conceded_residual(tmp_path): + """DOCUMENTS the limit so it is visible in the suite, not implied-closed. + + A file-write attacker who *recomputes* the KEYLESS chain (valid content_hash, + prev_hash, chain_hash) on a forged floor-lowering TRANSITION passes + verify_integrity() — the keyless hot read CANNOT detect this. On a + non-rekeyed ledger it is caught by NEITHER this hot read NOR `doctor`: + doctor's operator_sig verification (_transition_acknowledges) runs ONLY on + the KEY_RESET-acknowledgment path, and there is no KEY_RESET here. It is the + conceded raw-file-write residual (README "Known security limitations", + README.md:137). A general per-transition operator_sig audit in doctor would + close it operator-side (separate follow-up). If a future change makes + read_floor reject this, that is a STRENGTHENING: update this test + deliberately — do not let it silently flip for the wrong reason. + """ + from legis.canonical import canonical_json, content_hash + # Recompute the chain with the PRODUCTION primitive so the residual is not + # undermined by a divergent re-implementation (precedent: test_audit_store.py:10). + from legis.store.audit_store import _chain + + key_hex = mint_key() + key_bytes = bytes.fromhex(key_hex) + ledger, _ = _genesis(tmp_path, key_hex=key_hex) + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) + set_floor( + "protected", ledger=ledger, signer=_MemSigner(key_bytes), + agent_id="op", rationale="tighten", clock=FixedClock("t1"), + ) + assert ledger.read_floor() == "protected" + + # Forge a floor-lowering TRANSITION with a CORRECTLY recomputed keyless chain. + head_seq, head_chain = ledger.store.get_latest_sequence_and_hash() + forged = { + "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": "junk", + "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9", + "rationale": "forged", "session_id": None, + } + c_hash = content_hash(forged) # correct keyless content hash + chain_hash = _chain(head_chain, c_hash) # correct keyless chain link + conn = sqlite3.connect(str(_sqlite_file(ledger._url))) + try: + conn.execute( + "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, " + "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)", + { + "seq": head_seq + 1, "payload": canonical_json(forged), + "ch": c_hash, "ph": head_chain, "xh": chain_hash, + }, + ) + conn.commit() + finally: + conn.close() + + # Integrity holds (keyless chain valid) -> the keyless read is fooled. + # This asserts the DOCUMENTED RESIDUAL, not a desired guarantee. + assert ledger.store.verify_integrity() is True + assert ledger.read_floor() == "chill" +``` + +**Why this test:** Without it, a reader could believe Task 1 fully "authenticated the tail". It makes the conceded residual an executable fact tied to `README.md:137`, and a tripwire: a future change that closes the residual must update this test on purpose. + +**Step 2: Run test to verify it passes** + +Run: `uv run pytest "tests/posture/test_security_honesty.py::test_read_floor_recomputed_chain_forgery_is_conceded_residual" -v` + +Expected output: +``` +PASSED +``` + +(If it FAILS because `read_floor()` returned `None`, the keyless chain was not recomputed correctly in the fixture — `payload` must be `canonical_json(forged)` and `content_hash`/`chain_hash` must be the production-primitive values so `verify_integrity` accepts it. Fix the fixture, not the assertion.) + +**Step 3: Commit** + +```bash +git add tests/posture/test_security_honesty.py +git commit -m "test(posture): pin the recomputed-chain forgery as a documented residual + +read_floor's keyless integrity gate cannot detect a file-write attacker who +recomputes the keyless chain; on a non-rekeyed ledger that is caught by neither +the hot read nor doctor (doctor's operator_sig check is the KEY_RESET-ack path +only). It is the conceded raw-file-write residual (README.md:137). This +characterization test makes the limit visible in the suite so it is never +implied-closed. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +**Definition of Done:** +- [ ] Documentation test added and passing +- [ ] Docstring/name make clear it asserts a residual, not a guarantee, and name the precise reason doctor does not catch it (no KEY_RESET) +- [ ] Committed + +--- + +### Task 3: full verification (suite + coverage floor + gates) + +**Files:** none (verification only). + +**Step 1: Run the full posture suite — confirm no regression** + +Run: `uv run pytest tests/posture -v` + +Expected: all pass — **after** the Task 1 Step 5 rewrite of `test_read_floor_uses_tail_read`. That is the ONLY existing test the gate disturbs (it monkeypatched `read_all` to forbid it; the gate now legitimately calls it). It is a valid-chain test, not a bug-asserting one, so it is *rewritten to the new contract in Task 1*, never "flipped to expect `None`". No other existing test asserts the bug: the valid-chain tests (`test_rekey_preserves_existing_floor`, `test_tty_session_expiry`, `test_every_signature_carries_session_id`, `test_read_floor_does_not_point_read_each_metadata_tail`) build well-formed chains, so `verify_integrity()` is True and their behavior is unchanged. **If `test_read_floor_uses_tail_read` is still red here, the remedy is the Task 1 rewrite — NOT weakening the `verify_integrity()` gate.** + +**Step 2: Run the per-package coverage floor (posture ≥ 93%)** + +Run: `uv run pytest tests/posture --cov=legis.posture --cov-report=term-missing` +then: `uv run python scripts/check_coverage_floors.py` + +Expected: `src/legis/posture/` ≥ 93.0% (both branches of the single added conditional are covered — the `False`→`None` path by Task 1's new test, the integrity-holds path by Task 2 and the existing valid-chain tests). No floor breach. + +**Step 3: Run the CI-equivalent gates** + +Run, expecting all green: +```bash +uv run pytest --cov=legis --cov-fail-under=88 +uv run mypy src/legis +uv run ruff check src +uv run legis governance-gate +``` + +Expected output: pytest passes with total coverage ≥ 88; mypy clean; ruff clean (E4/E7/E9/F); governance-gate passes. (`read_floor` returns `str | None` unchanged, so mypy is unaffected.) + +**Definition of Done:** +- [ ] `tests/posture` green; the one disturbed test rewritten (Task 1 Step 5), the fix NOT weakened +- [ ] Posture per-package coverage ≥ 93%; global ≥ 88 +- [ ] mypy + ruff + governance-gate green +- [ ] Branch ready for review (NOT merged — owner-gated) + +--- + +## After execution — acceptance + closeout (product-owner, post-merge) + +Once the branch is green and merged (owner-gated), this finding is **accepted** against PRD-0005 criterion 1: +- Close `legis-476ab6f125` in the tracker with the close commit (walk proposed→…→done; `commit=main@`). +- The north-star (`metrics.md`: open governance-honesty defects) drops 3 → 2. +- File the **scope-acknowledgment follow-ups** (rank 4 of the review): a finding for `epoch_reset_unacknowledged` (feeds the agent-facing `posture_get` MCP read with no keyed backstop), and note `current_epoch_fingerprint` / `session_opened_recorded` as the same class (defended downstream by the operator key, lower priority). +- Note a **diagnostic-honesty follow-on** (rank 8): post-fix, `doctor.check_posture_ledger` (`doctor.py:578`, warn at `:603`) maps `read_floor()==None` to a "re-run `legis install`" warn — but `None` now also means "chain verification failed", so the warn misdirects an operator (who should investigate storage / restore from backup; `check_posture_chain` already surfaces the real error). Not a false-green (it is a warn), but worth a cleanup so the two `None` causes are distinguished. +- Then plan the next finding (`legis-0c310712a7`, the un-anchored protected batch) — note its overlap risk with PRD-0005's "shared store-transaction wrapper" assumption. + +## Validate before execution (recommended — security-critical) + +This is a governance-honesty security fix on a 93%-floor package. **RECOMMENDED:** re-run `/review-plan docs/plans/2026-06-26-posture-read-floor-verify-integrity.md` for reality/architecture/quality/systems verdicts before execution (a prior review's synthesis does not carry forward). + +**Review history:** a 7-agent + synthesizer review (2026-06-26) returned **CHANGES_REQUESTED** on the prior draft and is fully addressed here: +- **Blocker 1** (rank 1, HIGH): the prior draft missed that `test_ledger.py::test_read_floor_uses_tail_read` breaks (errors) under the gate and gave a misdirecting "expect `None`" remedy that could nudge an implementer to weaken the gate. → Now `test_ledger.py` is in scope, Task 1 Step 5 rewrites the test to the new contract with explicit "do not weaken the gate / do not flip to `is None`" guardrails, and Task 3 Step 1 is corrected. +- **Blocker 2** (rank 2, HIGH): the prior docstrings claimed `doctor`'s `operator_sig` check backstops the recomputed-chain residual — false (`doctor` checks `operator_sig` only on the KEY_RESET-acknowledgment path; Task 2's forged TRANSITION has no KEY_RESET). → Architecture note, `read_floor` docstring, and the Task 2 test docstring now state the residual is caught by neither hot read nor doctor; doctor locators corrected to the verified lines (`_transition_acknowledges` `:649`, `check_posture_key_reset` `:677`, early "no key-epoch reset" return `:707`) after a round-2 grep caught an offset-shifted earlier read. +- Folded in: stale module docstring (rank 3), sibling-read scope acknowledgment + `epoch_reset_unacknowledged` follow-up (rank 4), faithful `sqlite3` raw-write pattern replacing ORM internals (rank 5), in-place-edit/seq-gap coverage comment (rank 6), deliberate-O(N) docstring bound (rank 7), `doctor` diagnostic follow-on (rank 8), and the fail-closed-asymmetry / belt-and-suspenders notes (rank 9). diff --git a/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md b/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md new file mode 100644 index 0000000..ca365d2 --- /dev/null +++ b/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md @@ -0,0 +1,327 @@ +# Protected-Gate Transaction + HeadAnchor Advance — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Give `ProtectedGate` an owned `transaction()` context manager that advances the `HeadAnchor` after the batch commits — parity with `SignoffGate.transaction()` — so a protected record committed inside a batch can no longer leave the anchor at the pre-batch head, where a later tail-truncation would go undetected (legis-0c310712a7; PRD-0005 criterion 2). + +**Architecture:** `ProtectedGate._record_signed()` (`protected.py:286-292`) correctly defers the per-append anchor advance when `self._store.in_batch()` is true (a mid-batch head read is batch-forbidden, Q-M5), trusting a batch *owner* to advance the anchor after commit — but `ProtectedGate` has no such owner, unlike `SignoffGate.transaction()` (`signoff.py:177-189`). This plan adds the missing owner: a `ProtectedGate.transaction()` `@contextmanager` that wraps `self._store.transaction()` and, after commit, calls `self._anchor.update(*self._store.get_latest_sequence_and_hash())`. It is a near-verbatim mirror of the sign-off precedent. + +**Threat reality (state it honestly — this is a DOUBLY-LATENT/preventive fix, not a live-exploited path):** grounding the current callers shows **no code path batches protected appends today, and production wires no anchor at all**. `route_findings` (`route_findings` spans `wardline/governor.py:46-177`; `txn_owner` is chosen at `117-122`) sets its `txn_owner` to `signoff` or `engine` and routes only BLOCK_ESCALATE / SURFACE_OVERRIDE / SURFACE_ONLY — there is **no protected cell in the governor**. Both transports **construct `ProtectedGate` with `anchor=None`** (the constructors at `api/app.py:376-379` and `mcp.py:266-269`) and call `operator_override()`/`submit()` outside any batch. So today the gap is *doubly* latent: the `_record_signed` anchor guard at `protected.py:291` never advances anything because **the anchor is absent (`None`), not merely unadvanced**, AND no caller opens a batch around a protected append. The exposure the finding names is a *future* deployment that both wires an anchor AND wraps a protected append in an `AuditStore.transaction()` with no owner to advance the anchor — for which there is currently no safe API. This fix supplies that API (parity), so the value is **entirely preventive**. + +**PRD-0005 overlap question — RESOLVED by grounding:** PRD-0005 flagged a possible "shared store-transaction wrapper used by both posture and protected." There is none: `route_findings`'s batch owner is `signoff`/`engine` (never protected), and the posture ledger is a **separate** `AuditStore` (`src/legis/posture/ledger.py`) with its own writes. This finding is isolated from legis-476ab6f125 (already closed) and from posture; sequencing is unaffected. + +**Tech Stack:** Python 3.12, SQLAlchemy Core over SQLite, `uv`, pytest. No new dependencies. + +**Prerequisites:** +- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/protected-batch-headanchor`). Per the authority grant, the merge to main + any publish is owner-gated; this plan ends at a green branch + an accepted finding. +- `uv sync --dev` already run; `.venv` present. +- Read context (already grounded): `src/legis/enforcement/protected.py` (`ProtectedGate.__init__` 207-239 → `self._store`/`self._anchor`/`self._key`; `_record_signed` 241-301 with the `in_batch()` anchor guard at 291; `operator_override` 389-413 — the deterministic, judge-free append path the tests use), `src/legis/enforcement/signoff.py:177-189` (the `transaction()` precedent to mirror VERBATIM), `src/legis/store/head_anchor.py` (`HeadAnchor.update`/`check`, `AnchorError`), `src/legis/store/audit_store.py` (`transaction` 180, `in_batch` 214, `get_latest_sequence_and_hash` 444, append-only triggers `audit_log_no_update`/`audit_log_no_delete` at 166/173), `tests/store/test_head_anchor.py:42-72` (the raw-truncation helper + `test_anchor_detects_tail_truncation` precedent), `tests/store/test_batch_read_free_invariant.py:27-31` (the on-disk-store fixture), `tests/enforcement/test_protected_submit.py` (the existing `ProtectedGate` construction fixtures). + +**Scope fence (PRD-0005 non-goals):** Do NOT modify `src/legis/store/audit_store.py`, `head_anchor.py`, or `canonical.py` (the cross-tool HMAC + anchor contracts). Do NOT re-architect the enforcement cell. Do NOT add a protected cell to `route_findings` (out of scope — and routing-coupling is a separate design question). The change is confined to `ProtectedGate` (add `transaction()` + one import) + two tests. + +--- + +### Task 1: add ProtectedGate.transaction() that advances the anchor after commit + +**Files:** +- Modify: `src/legis/enforcement/protected.py` — add `from contextlib import contextmanager` to the imports, and add a `transaction()` method on `ProtectedGate` (mirroring `signoff.py:177-189`). +- Test (new): `tests/enforcement/test_protected_transaction.py` + +**Step 1: Write the failing test** + +Create `tests/enforcement/test_protected_transaction.py`. Uses the raw-truncation pattern from `tests/store/test_head_anchor.py:42-45` and the on-disk store from `tests/store/test_batch_read_free_invariant.py`. `operator_override` is the deterministic append (it bypasses the judge — `protected.py:400-413`), so a stub judge that is never consulted is correct. + +```python +import sqlite3 + +import pytest + +from legis.clock import FixedClock +from legis.enforcement.protected import ProtectedGate +from legis.enforcement.verdict import Verdict +from legis.identity.entity_key import EntityKey +from legis.store.audit_store import AuditStore +from legis.store.head_anchor import AnchorError, HeadAnchor + +KEY = b"k" * 32 +CLOCK = "2026-06-26T12:00:00+00:00" + + +class _UnusedJudge: + """operator_override bypasses the judge; if it is consulted, fail loudly.""" + + def evaluate(self, record): # pragma: no cover - must never be called + raise AssertionError("judge must not be consulted on operator_override") + + +def _anchored_gate(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + anchor = HeadAnchor(str(tmp_path / "gov.anchor"), KEY) + gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY, anchor=anchor) + return store, anchor, gate + + +def _override(gate, rationale): + return gate.operator_override( + policy="protected/secrets", + entity_key=EntityKey.from_locator("m.f"), + rationale=rationale, + operator_id="op", + file_fingerprint="fp", + ast_path="m.f", + ) + + +def _truncate_to(db_path, keep_seq): + """Raw out-of-band tail truncation (drops the append-only triggers first).""" + con = sqlite3.connect(db_path) + try: + con.execute("DROP TRIGGER IF EXISTS audit_log_no_update") + con.execute("DROP TRIGGER IF EXISTS audit_log_no_delete") + con.execute("DELETE FROM audit_log WHERE seq > ?", (keep_seq,)) + con.commit() + finally: + con.close() + + +def test_protected_transaction_advances_anchor_so_truncation_is_detected(tmp_path): + """A protected append batched through ProtectedGate.transaction() advances the + HeadAnchor after commit, so a later tail-truncation back to the pre-batch head + is DETECTED (parity with SignoffGate.transaction()). legis-0c310712a7. + """ + store, anchor, gate = _anchored_gate(tmp_path) + # A pre-batch protected append (outside any batch -> anchor advances normally). + _override(gate, "pre-batch") + pre_seq, _ = store.get_latest_sequence_and_hash() + + # Batch a protected append through the NEW owned transaction API. + with gate.transaction(): + _override(gate, "in-batch") + + # The transaction() advanced the anchor to the in-batch record's head. + head_seq, _ = store.get_latest_sequence_and_hash() + assert head_seq == pre_seq + 1 + + # Truncate the in-batch record out of band, back to the pre-batch head. + _truncate_to(str(tmp_path / "gov.db"), pre_seq) + + # The anchor remembers the higher head -> truncation is detected. + with pytest.raises(AnchorError): + anchor.check(store.read_all()) +``` + +**Why this test:** It pins the finding's fix — the owned transaction API advances the anchor so a batched protected append is truncation-detectable. It exercises the real out-of-band attacker (raw `sqlite3` truncation), not a mock. + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/enforcement/test_protected_transaction.py::test_protected_transaction_advances_anchor_so_truncation_is_detected -v` + +Expected output (RED — the finding's exact gap, "ProtectedGate has no transaction wrapper"): +``` +FAILED ... AttributeError: 'ProtectedGate' object has no attribute 'transaction' +``` + +**Step 3: Write minimal implementation** + +In `src/legis/enforcement/protected.py`: add the import (top of file, with the other stdlib imports) — +```python +from contextlib import contextmanager +``` +— and add this method to `ProtectedGate` (place it next to `verify_integrity`/`records`, e.g. after `operator_override`). It is a verbatim mirror of `SignoffGate.transaction()` (`signoff.py:177-189`): + +```python + @contextmanager + def transaction(self): + """Group this gate's protected appends into one all-or-nothing batch and + advance the anchor once after commit — parity with + ``SignoffGate.transaction()`` (signoff.py). + + The per-append anchor advance is deferred inside a batch (the head read + is batch-forbidden, Q-M5; see the ``in_batch()`` guard in + ``_record_signed``). Advance it once here after the batch commits and the + write lock is released. An exception inside the batch rolls back and + propagates before this runs, so the anchor never advances past a + rolled-back head (AUD-1: the anchor only ever lags, never overshoots). + """ + with self._store.transaction(): + yield + if self._anchor is not None: + self._anchor.update(*self._store.get_latest_sequence_and_hash()) +``` + +**Why minimal:** This is the exact `SignoffGate` precedent — no new anchor logic, no change to `_record_signed` (its `in_batch()` deferral is already correct; this supplies the missing owner that re-advances after commit). `audit_store.py` / `head_anchor.py` are untouched. + +**Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/enforcement/test_protected_transaction.py::test_protected_transaction_advances_anchor_so_truncation_is_detected -v` + +Expected output: +``` +PASSED +``` + +**Step 5: Commit** + +```bash +git add src/legis/enforcement/protected.py tests/enforcement/test_protected_transaction.py +git commit -m "fix(protected): add ProtectedGate.transaction() that advances the anchor + +A protected record appended inside a batch defers its HeadAnchor advance +(the mid-batch head read is forbidden, Q-M5) and, unlike SignoffGate, had no +transaction owner to re-advance it after commit — so a batched protected +append could leave the anchor at the pre-batch head and a later tail-truncation +would go undetected. Add ProtectedGate.transaction(), a verbatim mirror of +SignoffGate.transaction(), as that owner. + +Closes legis-0c310712a7 (PRD-0005 criterion 2). + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +**Definition of Done:** +- [ ] Test written and fails for the right reason (AttributeError: no `transaction`) +- [ ] `from contextlib import contextmanager` added; `ProtectedGate.transaction()` added, mirroring `signoff.py:177-189` +- [ ] Test passes post-fix +- [ ] No other tests broken +- [ ] Committed + +--- + +### Task 2: document the raw-batch parity residual (characterization test) + +**Files:** +- Test (new): `tests/enforcement/test_protected_transaction.py` (add one test — no production change) + +**Step 1: Write the documentation test** + +This PASSES on the Task-1 code — it asserts a KNOWN LIMIT (parity with `SignoffGate`), so it is the honesty tripwire: a protected append inside a **raw** `store.transaction()` (bypassing `gate.transaction()`) still leaves the anchor stale, because nobody advances it after commit. The supported safe path is `gate.transaction()`; a raw batch owner must advance the anchor itself. This mirrors the residual-pinning discipline used for legis-476ab6f125. + +```python +def test_raw_store_transaction_bypasses_anchor_advance_documented_residual(tmp_path): + """DOCUMENTS the parity limit (not a defense): a protected append inside a RAW + store.transaction() — bypassing gate.transaction() — defers the anchor advance + (in_batch() is true) and nothing re-advances it, so a truncation back to the + pre-batch head is NOT detected. The supported safe path is gate.transaction() + (Task 1); a caller that owns a raw batch must advance the anchor itself, + identically to SignoffGate. If a future change closes this (e.g. an after-commit + hook on AuditStore.transaction), update this test deliberately. legis-0c310712a7. + """ + store, anchor, gate = _anchored_gate(tmp_path) + _override(gate, "pre-batch") + pre_seq, _ = store.get_latest_sequence_and_hash() + + # RAW batch (NOT gate.transaction()): the append's anchor advance is deferred + # and never re-applied. + with store.transaction(): + _override(gate, "in-raw-batch") + + # The anchor is STALE at the pre-batch head (the residual). + _truncate_to(str(tmp_path / "gov.db"), pre_seq) + + # Stale anchor == truncated DB head -> truncation is NOT detected. + anchor.check(store.read_all()) # does not raise: asserts the DOCUMENTED residual +``` + +**Why this test:** It makes the parity limit executable so it is never implied-closed — and, paired with Task 1, it is the before/after: raw batch → stale anchor → undetected; `gate.transaction()` → advanced anchor → detected. + +**Step 2: Run test to verify it passes** + +Run: `uv run pytest "tests/enforcement/test_protected_transaction.py::test_raw_store_transaction_bypasses_anchor_advance_documented_residual" -v` + +Expected output: +``` +PASSED +``` + +(If it FAILS because `anchor.check` raised, the raw batch unexpectedly advanced the anchor — investigate; do NOT silence it by weakening the assertion.) + +**Step 3: Commit** + +```bash +git add tests/enforcement/test_protected_transaction.py +git commit -m "test(protected): pin the raw-batch anchor-advance residual + +A protected append inside a raw store.transaction() (bypassing gate.transaction()) +leaves the anchor stale — the supported safe path is gate.transaction(). This +characterization test makes the parity limit visible so it is never implied-closed. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +**Definition of Done:** +- [ ] Documentation test added and passing +- [ ] Name/docstring make clear it asserts a residual, not a guarantee +- [ ] Committed + +--- + +### Task 3: full verification (suite + coverage floor + gates) + +**Files:** none (verification only). + +**Step 1: Run the enforcement suite** + +Run: `uv run pytest tests/enforcement tests/store -q` + +Expected: all pass. The change is purely additive (a new method + import), so no existing test should change behavior. Watch for any test that constructs `ProtectedGate` positionally and could be affected by the new import (none expected). + +**Step 2: Per-package coverage floor** + +Run: `uv run pytest tests/enforcement --cov=legis.enforcement --cov-report=term-missing` +then: `uv run python scripts/check_coverage_floors.py` + +Expected: the `enforcement/` per-package floor (see the `FLOORS` dict in `scripts/check_coverage_floors.py`) holds. The new `transaction()` method's body is covered by Task 1; the deferred-advance path (`in_batch()` true) by Task 2. + +**Step 3: CI-equivalent gates** + +Run, expecting all green: +```bash +uv run pytest --cov=legis --cov-fail-under=88 +uv run mypy src/legis +uv run ruff check src +uv run legis governance-gate +``` + +Expected: pytest passes with total coverage ≥ 88; mypy clean (the `@contextmanager` return type matches `SignoffGate.transaction`'s, which already type-checks); ruff clean; governance-gate passes. + +**Definition of Done:** +- [ ] `tests/enforcement` + `tests/store` green +- [ ] `enforcement/` per-package floor holds; global ≥ 88 +- [ ] mypy + ruff + governance-gate green +- [ ] Branch ready for review (NOT merged — owner-gated) + +--- + +## After execution — acceptance + closeout (product-owner, post-merge) + +Once the branch is green and merged (owner-gated), this finding is **accepted** against PRD-0005 criterion 2: +- Close `legis-0c310712a7` in the tracker (walk confirmed→fixing→verifying→closed; `commit=main@`). +- The north-star (`metrics.md`: open governance-honesty defects) drops **2 → 1**. +- Then plan the last finding (`legis-0186c23a2c`, the unbounded policy-boundary root) — the third and final PRD-0005 item; no overlap with this fix. + +## Validate before execution (recommended — security-critical) + +This is a governance-honesty security fix on a coverage-floored package. **RECOMMENDED:** run `/review-plan docs/plans/2026-06-26-protected-batch-headanchor-transaction.md` (reality/architecture/quality/systems) — or the same ultracode multi-agent review used for legis-476ab6f125 — before execution. Reviewer attention points: (1) confirm `route_findings` truly has no protected cell (the "latent" framing rests on it); (2) confirm `operator_override` is judge-free so the stub judge is sound; (3) confirm the `_truncate_to` trigger-drop + delete reproduces a real out-of-band truncation that `HeadAnchor.check` detects; (4) confirm the new method is a faithful mirror of `SignoffGate.transaction()` with no anchor-overshoot risk. + +## Review fold-ins (round-1 multi-agent review: APPROVED_WITH_WARNINGS — fold in during execution, none block) + +A 7-agent + synthesizer review returned **GO** with these non-blocking improvements; apply them as you execute: + +1. **(rank 1, medium — the highest-value gap) Add a production-default UNANCHORED test in Task 1.** Both transports wire `anchor=None`, so the only production-reachable path of `transaction()` is the `self._anchor is None` no-op arm — and both new tests use `_anchored_gate()`, so that arm is untested (line-coverage floors won't catch it; `pyproject` has no `branch=True`). Add: + ```python + def test_protected_transaction_is_safe_without_an_anchor(tmp_path): + """The production default: ProtectedGate has anchor=None. transaction() + must still batch atomically (the if-anchor guard is a no-op, not a crash).""" + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY) # no anchor= + with gate.transaction(): + _override(gate, "in-batch") + assert len(store.read_all()) == 1 + assert store.verify_integrity() is True + ``` +2. **(rank 2, low) Task 2 — add a landing assertion** after the raw `with store.transaction(): _override(...)` and before `_truncate_to`: `assert store.get_latest_sequence_and_hash()[0] == pre_seq + 1` — proves the `in_batch()`-deferred branch was actually taken, so the residual test can't pass for the wrong reason (a silently no-op'd append). Keep the "do NOT silence by weakening the assertion" guidance. +3. **(rank 3, low) Task 1 — fix the now-contradicting comment** in `_record_signed` (`protected.py:287-290`): it still says "the protected gate is not itself a batch owner." Bring it to `SignoffGate._append` parity (`signoff.py:110-111`): the per-append advance is deferred inside a batch (Q-M5) and `ProtectedGate.transaction()` advances it once after commit. No production-logic change. +4. **(rank 5, low) Task 1 — comment `_truncate_to`**: `# No survivor re-chain needed: AnchorError fires on the head_seq comparison before chain_hash is checked.` Optionally `assert not store.verify_integrity()` after the truncation to make the broken-chain state explicit. +5. **(rank 6, low) `transaction()` docstring — add a nesting caveat**: `gate.transaction()` must be the OUTERMOST batch owner for its store; nesting it inside another gate's transaction on the same thread raises `RuntimeError` (fail-closed, the batch-forbidden read — inherited from the `SignoffGate` contract). Documentation only. +6. **(rank 7, low, OPTIONAL) consider an AUD-1 no-overshoot rollback test**: open an anchored `gate.transaction()`, `_override` inside, `raise` before exit, catch, then assert `len(store.read_all()) == pre_batch_count` and `anchor.check(store.read_all())` does not raise — makes the docstring's no-overshoot claim executable. +7. **(Task 3 DoD — real gap) Add the two CI gates the plan omitted** (CLAUDE.md lists them): `uv run legis policy-boundary-check --root src --repo-root .` and `uv run pytest tests/conformance/test_sei_oracle.py`. A pure-additive enforcement contextmanager is very unlikely to trip either, but the "all CI gates green" DoD requires them. diff --git a/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md b/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md new file mode 100644 index 0000000..bd46869 --- /dev/null +++ b/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md @@ -0,0 +1,360 @@ +# Warpline Preflight: MCP-Stdio Transport + Real Envelope — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace legis's phantom-HTTP warpline preflight client with a minimal stdio JSON-RPC client that consumes the **extant** `warpline_impact_radius_get` / `warpline_reverify_worklist_get` MCP tools with `rev_range`, parses warpline's **real frozen envelope** (`warpline.impact_radius.v1` / `warpline.reverify_worklist.v1`), verifies the GV-LG-3 `meta` invariant, fails SAFE on every fault, and keeps the advisory boundary byte-identical — so the sanctioned SEAM 4 §4A preflight seam actually works against real warpline (legis-a53d92507d). warpline reimplements nothing; legis conforms to what warpline already serves. + +**Architecture:** Today `HttpWarplineClient` (`src/legis/warpline_preflight/client.py`) issues `GET {WARPLINE_API_URL}/api/impact-radius` over `urllib` — a wire **warpline never served** (it has only MCP + CLI). The hub interface-lock SEAM 4 §4A pins the seam to "*same wire shape as `warpline_impact_radius_get`*" (the envelope), and **GV-LG-3** requires legis to read `meta.local_only`/`meta.peer_side_effects` off that envelope (the flat shape has no `meta`). So the transport becomes **MCP-stdio** (owner-confirmed 2026-06-26): a subprocess speaks JSON-RPC over stdio to `warpline-mcp`, calls one tool per verb with `rev_range=".."`, and returns the parsed envelope. The fix preserves the seams that are CORRECT: the `WarplineClient` Protocol (`impact_radius`/`reverify_worklist` → dict), the injectable-transport pattern (today `fetch`; now an injectable `invoke`), and `read_warpline_preflight`'s fail-safe discipline (`service/preflight.py`: `None`/`WarplineError` → `unavailable`, never an empty affected-set). + +**Threat model shift (HTTP → local process):** the HTTP SSRF / redirect / TLS surface (`_validate_base_url`, `_is_loopback`, `_open_no_redirect`, `_NoRedirectHandler`, and the filigree-clone test) is **moot** and is removed. The new surface is local-process: (1) **no shell, list-argv** — `rev_range` travels as a JSON-RPC *param*, never as a CLI argument, so `shell=False` + a list `argv` makes argument/shell injection structurally impossible (note: `WARPLINE_MCP_CMD="warpline-mcp"` IS an implicit `PATH` lookup — `shell=False` eliminates *shell* injection, not PATH ambiguity; **recommend an absolute path**, and reject an empty command); (2) **output bound** — `subprocess.run` buffers stdout, so the size check is **post-capture + timeout-bounded** (a true *incremental* read bound requires the Popen variant — see Task 2's escalation note; the cap is on **bytes**, `text=False`); (3) **timeout** — bound the child (10s) and let `TimeoutExpired` → `WarplineError`; (4) **isolation** — a missing exe / crash / non-zero exit / garbage / oversize / timeout is a `WarplineError` → `unavailable`, never an escape, and the error carries the child's `returncode` + truncated `stderr` for operator diagnosis. + +**Tech Stack:** Python 3.12, stdlib `subprocess` + `json` (NO new dependency — legis's sibling-client ethos), `uv`, pytest. + +**Prerequisites:** +- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/warpline-preflight-mcp`). The merge + any 1.3.0 publish is owner-gated; **do NOT release 1.3.0 with the old mis-frozen seam**. +- `uv sync --dev` already run. +- **A live `warpline-mcp` is REQUIRED for Task 2's DoD gate** (capture one real session transcript — see Task 2). Confirm it is runnable; if it is not reachable, Task 2 cannot complete its gate and you escalate before Task 3 (do not ship an unverified transport). +- Read context (grounded): `src/legis/warpline_preflight/client.py` (the `WarplineClient` Protocol 34-37, `WarplineError` 27, `HttpWarplineClient` 118-143, `MAX_RESPONSE_BYTES` 31, the `fetch` seam at client.py:76 doing the real incremental `resp.read(MAX_RESPONSE_BYTES+1)` bound the new path loosens — call that out), `src/legis/service/preflight.py` (the fail-safe pass-through — KEEP; calls BOTH verbs at 27-28), `src/legis/mcp.py:231-243` (the `WARPLINE_API_URL` wiring) + `:893-905` (preflight output schema — bare `{"type":"object"}`, no downstream ripple), `tests/warpline_preflight/test_client.py` (HTTP-specific; rewritten), `tests/warpline_preflight/fixtures/{warpline-preflight-golden.json,PROVENANCE.md}` + `test_warpline_preflight_oracle.py` (the mis-frozen flat golden + inverted obligation + `GOLDEN_BLOB_SHA` byte-pin at oracle:49 + the deleted-symbol imports `HttpWarplineClient`/`_decode_json_response`), `tests/mcp/test_warpline_advisory_boundary.py:74-99` (`_HostileWarpline` + byte-identity test) **and `:143-174`** (the structural boundary test derived from `_TOOL_HANDLERS` — a load-bearing invariant to PRESERVE), `tests/mcp/test_output_schema_conformance.py:635-639` (flat stub), `tests/mcp/test_server.py:3388,3397` (an `HttpWarplineClient` import + an `isinstance` assertion that must become `WarplineMcpClient`). + +**warpline's REAL surface (authoritative — verified vs warpline source 2026-06-26; treat as the contract):** +- MCP tools (stdio, `warpline-mcp`): `warpline_impact_radius_get` (shim `blast_radius`) → `warpline.impact_radius.v1`; `warpline_reverify_worklist_get` (shim `reverify`) → `warpline.reverify_worklist.v1`. Both accept `arguments.rev_range=".."`. +- Envelope: `{schema, ok:true, query, data, warnings, next_actions, enrichment, meta}`. impact: `data.affected` (list); reverify: `data.items` (NOT `entries`). **No top-level `count`.** `data.completeness` + `data.staleness` mandatory honesty fields. `meta.local_only` + `meta.peer_side_effects` = the GV-LG-3 invariant. + +**Scope fence:** Do NOT change a federation contract (CONFORMS to the already-frozen SEAM 4 §4A). Do NOT let warpline output reach a governance verdict path (advisory-only). Do NOT freeze a `reverify_worklist` dependency before wardline rules (§2A names filigree as the reverify consumer; reverify stays keep-or-drop). Do NOT touch `service/preflight.py`'s fail-safe discipline. Do NOT add a dependency (unless the Task-2 escalation explicitly approves the `mcp` SDK). + +--- + +### Task 1: domain client `WarplineMcpClient` over an injectable `invoke` seam (envelope parse + GV-LG-3 + degraded-floor, all fail-closed) + +**Files:** Rewrite `src/legis/warpline_preflight/client.py` (keep `WarplineClient` Protocol + `WarplineError` + `MAX_RESPONSE_BYTES`; replace `HttpWarplineClient` and all HTTP helpers with `WarplineMcpClient` + an `Invoke` seam; the real stdio invoker is Task 2). Rewrite `tests/warpline_preflight/test_client.py` (drop every HTTP test incl. the filigree-clone test; new tests inject a fake `invoke`). + +**Step 1: failing tests** (offline — inject a recorder `invoke`). Include the fault paths so guardrail-(b) is TESTED, not just asserted: + +```python +import pytest +from legis.warpline_preflight.client import WarplineMcpClient, WarplineClient, WarplineError + +_VALID_META = {"local_only": True, "peer_side_effects": []} +_KEEP = object() # sentinel: "use the valid default meta" — DISTINCT from None (None IS a test case) +def _env(schema, data_key, items, *, meta=_KEEP, completeness="FULL"): + data = {data_key: items, "staleness": {"commits_behind": 0}} + if completeness is not None: + data["completeness"] = completeness + return {"schema": schema, "ok": True, "query": {"rev_range": "aaa..bbb"}, "data": data, + "warnings": [], "next_actions": {}, "enrichment": {"sei": "present"}, + "meta": dict(_VALID_META) if meta is _KEEP else meta} # meta=None -> {"meta": None}, a real case + +def _recorder(responses): + calls = [] + def invoke(tool, arguments): + calls.append((tool, arguments)); return responses.pop(0) + invoke.calls = calls; return invoke + +def test_protocol_is_runtime_checkable(): + assert isinstance(WarplineMcpClient(invoke=_recorder([{}])), WarplineClient) + +def test_impact_radius_calls_tool_with_rev_range_and_passes_envelope_through(): + e = _env("warpline.impact_radius.v1", "affected", [{"sei": "loomweave:eid:" + "a"*32}]) + inv = _recorder([e]); out = WarplineMcpClient(invoke=inv).impact_radius("aaa", "bbb") + assert out == e + assert inv.calls[0] == ("warpline_impact_radius_get", {"rev_range": "aaa..bbb"}) + +def test_reverify_calls_reverify_tool(): + e = _env("warpline.reverify_worklist.v1", "items", []) + inv = _recorder([e]); WarplineMcpClient(invoke=inv).reverify_worklist("a", "b") + assert inv.calls[0][0] == "warpline_reverify_worklist_get" + +@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None]) +def test_non_dict_envelope_is_warpline_error(bad): + with pytest.raises(WarplineError): + WarplineMcpClient(invoke=_recorder([bad])).impact_radius("a", "b") + +def test_wrong_schema_or_not_ok_is_warpline_error(): + wrong = _env("warpline.reverify_worklist.v1", "items", []) # wrong schema for impact + with pytest.raises(WarplineError, match="schema"): + WarplineMcpClient(invoke=_recorder([wrong])).impact_radius("a", "b") + notok = _env("warpline.impact_radius.v1", "affected", []); notok["ok"] = False + with pytest.raises(WarplineError, match="ok"): + WarplineMcpClient(invoke=_recorder([notok])).impact_radius("a", "b") + +def test_gv_lg_3_hostile_or_malformed_meta_is_refused_fail_closed(): + e = _env("warpline.impact_radius.v1", "affected", []); e["meta"] = {"local_only": True, "peer_side_effects": ["did_a_thing"]} + with pytest.raises(WarplineError, match="side effect"): + WarplineMcpClient(invoke=_recorder([e])).impact_radius("a", "b") + for bad_meta in ({"local_only": False, "peer_side_effects": []}, {"peer_side_effects": []}, "not-a-dict", None, 5): + em = _env("warpline.impact_radius.v1", "affected", [], meta=bad_meta) + with pytest.raises(WarplineError): # non-dict / missing / False local_only all refuse + WarplineMcpClient(invoke=_recorder([em])).impact_radius("a", "b") + +def test_degraded_envelope_missing_completeness_is_warpline_error(): + e = _env("warpline.impact_radius.v1", "affected", [], completeness=None) # completeness omitted + with pytest.raises(WarplineError, match="completeness"): + WarplineMcpClient(invoke=_recorder([e])).impact_radius("a", "b") +``` + +**Step 2: RED** (`WarplineMcpClient` missing). **Step 3: implement.** Delete `HttpWarplineClient`, `_urllib_fetch`, `_open_no_redirect`, `_NoRedirectHandler`, `_decode_json_response`, `_is_loopback`, `_validate_base_url`, and the `urllib`/`http.client`/`ipaddress` imports. Keep `WarplineError`, `MAX_RESPONSE_BYTES`, the Protocol. Add: + +```python +Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below) +_IMPACT = ("warpline.impact_radius.v1", "warpline_impact_radius_get") +_REVERIFY = ("warpline.reverify_worklist.v1", "warpline_reverify_worklist_get") + +class WarplineMcpClient: + """Consume warpline's EXTANT MCP tools (advisory preflight). Pass the frozen + envelope through verbatim (the bare-object MCP output schema makes pass-through + lossless). Advisory-ONLY; every contract fault fails CLOSED -> WarplineError.""" + def __init__(self, *, invoke: "Invoke") -> None: + self._invoke = invoke + def impact_radius(self, base: str, head: str) -> dict[str, Any]: + return self._call(*_IMPACT, base, head) + def reverify_worklist(self, base: str, head: str) -> dict[str, Any]: + return self._call(*_REVERIFY, base, head) + def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]: + env = self._invoke(tool, {"rev_range": f"{base}..{head}"}) + if not isinstance(env, dict): + raise WarplineError(f"{tool} returned {type(env).__name__}, expected an envelope object") + if env.get("schema") != schema: + raise WarplineError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}") + if env.get("ok") is not True: + raise WarplineError(f"{tool} envelope is not ok=true: {env.get('ok')!r}") + meta = env.get("meta") + if not isinstance(meta, dict): # malformed meta fails closed (GV-LG-3 input) + raise WarplineError(f"{tool} envelope meta is {type(meta).__name__}, expected an object") + if meta.get("local_only") is not True: + raise WarplineError(f"{tool} meta.local_only is not true: {meta.get('local_only')!r}") + if meta.get("peer_side_effects"): + raise WarplineError(f"{tool} claims a peer side effect (GV-LG-3): {meta.get('peer_side_effects')!r}") + data = env.get("data") + if not isinstance(data, dict) or "completeness" not in data: # degraded -> unavailable, not bare empty 'checked' + raise WarplineError(f"{tool} envelope data is missing the mandatory 'completeness' field") + return env +``` + +**Why these checks:** `isinstance(meta, dict)` closes the non-dict-meta escape (a truthy non-dict would otherwise `AttributeError` past the GV-LG-3 gate). Requiring `data.completeness` means a *degraded* warpline degrades to `unavailable` rather than a bare empty `checked` (rank-8 honesty floor; `staleness` is surfaced via pass-through). Pass-through keeps `service/preflight.py` and the bare-object output schema unchanged. `impact_radius`/`reverify_worklist` stay independent so reverify is keep-or-drop. + +**Step 4: GREEN. Step 5: commit.** **DoD:** parses `data.affected`/`data.items` via pass-through; schema/ok/meta(incl. non-dict)/completeness all fail closed to `WarplineError`; HTTP code + filigree-clone test deleted; Protocol/Error/cap preserved; the fault tests above are GREEN; committed. + +--- + +### Task 2: production stdio JSON-RPC invoker (`StdioMcpInvoke`) — hardened fail-safe + a LIVE-capture DoD gate + +**Files:** Modify `client.py` (add `StdioMcpInvoke` + `_read_jsonrpc_result`). Test `tests/warpline_preflight/test_stdio_invoke.py` (new). + +**Step 1: failing tests** — drive the real subprocess+stdio path against tiny fake `warpline-mcp` stub scripts in `tmp_path`, AND (DoD gate) against a captured **live** transcript. Every fault asserts `WarplineError`: + +```python +import sys, json, textwrap, pytest +from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError + +def _script(tmp_path, body): + p = tmp_path / "fake.py"; p.write_text(textwrap.dedent(body)); return [sys.executable, str(p)] + +_OK = ''' + import sys, json + for line in sys.stdin: + m = json.loads(line); mid = m.get("id") + if m.get("method") == "initialize": + print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"f","version":"0"}}}), flush=True) + elif m.get("method") == "tools/call": + env = {"schema":"warpline.impact_radius.v1","ok":True,"query":m["params"]["arguments"],"data":{"affected":[{"sei":"x"}],"completeness":"FULL","staleness":{"commits_behind":0}},"warnings":[],"next_actions":{},"enrichment":{},"meta":{"local_only":True,"peer_side_effects":[]}} + print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[{"type":"text","text":json.dumps(env)}],"structuredContent":env,"isError":False}}), flush=True) +''' + +def test_round_trips_against_fake_server(tmp_path): + env = StdioMcpInvoke(command=_script(tmp_path, _OK))("warpline_impact_radius_get", {"rev_range": "a..b"}) + assert env["data"]["affected"] == [{"sei": "x"}] + +def test_replays_a_REAL_captured_session(tmp_path): + """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session + (see PROVENANCE). A green here means the real message order + result shape + (structuredContent vs content[].text + protocolVersion) were exercised, not a + legis-shaped assumption. If no live capture exists, this test FAILS (xfail is + not allowed) and Task 2 is not done — escalate (see the gate note).""" + # Replace this body with bytes captured from a REAL warpline-mcp session. + # Until then it FAILS (not passes, not xfails) so the gate cannot be skipped: + pytest.fail("Wire a REAL captured warpline-mcp transcript here — Task 2 HARD DoD GATE; do NOT skip/xfail. See the gate note below.") + +@pytest.mark.parametrize("body,match", [ + ('import sys\n', "no JSON-RPC response"), # empty stdout + ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error +]) +def test_fault_paths_all_raise_warpline_error(tmp_path, body, match): + with pytest.raises(WarplineError, match=match): + StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"}) + +def test_missing_executable_is_warpline_error(tmp_path): + with pytest.raises(WarplineError): + StdioMcpInvoke(command=[str(tmp_path / "nope")])("warpline_impact_radius_get", {"rev_range": "a..b"}) + +def test_empty_command_is_warpline_error(): + with pytest.raises(WarplineError, match="empty"): + StdioMcpInvoke(command=[])("warpline_impact_radius_get", {"rev_range": "a..b"}) + +def test_timeout_is_warpline_error(tmp_path): + with pytest.raises(WarplineError): + StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)("warpline_impact_radius_get", {"rev_range": "a..b"}) + +def test_oversize_stdout_is_warpline_error(tmp_path): + body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n' + with pytest.raises(WarplineError, match="too large"): + StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"}) +``` + +**Step 2: RED. Step 3: implement** — list-argv, no shell, `text=False` (so the cap is a BYTE count), empty-argv rejected, the ENTIRE post-spawn parse wrapped so any fault → `WarplineError`, and `stderr`/`returncode` surfaced: + +```python +import subprocess + +def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict: + for line in stdout_text.splitlines(): + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError as exc: + raise WarplineError(f"warpline-mcp emitted a non-JSON line: {exc}") from exc + if isinstance(msg, dict) and msg.get("id") == response_id: + if "error" in msg: + raise WarplineError(f"warpline-mcp returned a JSON-RPC error: {msg['error']}") + result = msg.get("result") + if not isinstance(result, dict): + raise WarplineError(f"warpline-mcp result is {type(result).__name__}, expected an object") + return result + raise WarplineError(f"warpline-mcp produced no JSON-RPC response for id={response_id}") + +class StdioMcpInvoke: + """Production Invoke: a stdio JSON-RPC call to warpline-mcp. Fail-safe: EVERY + fault -> WarplineError. shell=False + list argv (rev_range is a JSON param, never + an argv token); explicit command (absolute path recommended; empty rejected); + text=False byte-bounded stdout (post-capture; see the cap note); 10s timeout.""" + def __init__(self, *, command: list[str], timeout: float = 10.0) -> None: + self._command = command + self._timeout = timeout + def __call__(self, tool: str, arguments: dict) -> dict: + if not self._command: + raise WarplineError("warpline-mcp command is empty (WARPLINE_MCP_CMD blank?)") + msgs = ( + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"legis","version":"1"}}}, + {"jsonrpc":"2.0","method":"notifications/initialized"}, + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":arguments}}, + ) + stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8") + try: + proc = subprocess.run(self._command, input=stdin, capture_output=True, + timeout=self._timeout, shell=False, check=False) # text=False -> bytes + except (OSError, ValueError, subprocess.SubprocessError) as exc: + raise WarplineError(f"warpline-mcp spawn/timeout failed: {exc}") from exc + if len(proc.stdout) > MAX_RESPONSE_BYTES: + raise WarplineError("warpline-mcp response too large") + err = (proc.stderr or b"")[:400].decode("utf-8", "replace") + try: + result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2) + if result.get("isError"): + raise WarplineError(f"warpline tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})") + sc = result.get("structuredContent") + if isinstance(sc, dict): + return sc + for block in result.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return json.loads(block["text"]) + raise WarplineError(f"warpline tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})") + except WarplineError: + raise + except Exception as exc: # ANY parse fault fails closed + raise WarplineError(f"warpline tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc +``` + +> **HARD DoD GATE (rank-3) — verify against a LIVE `warpline-mcp` before this task is done.** Capture one real session: send the three messages above to a real `warpline-mcp`, save its stdout transcript, and wire it into `test_replays_a_REAL_captured_session` (and into the golden capture for Task 4). Confirm: (a) the server **exits on stdin-EOF** (else `subprocess.run` blocks to timeout on every call); (b) it does **not** require interleaved I/O (read the `initialize` response before the client sends `tools/call`); (c) the real `protocolVersion`; (d) result shape (`structuredContent` vs `content[0].text`). **If (a) or (b) fails, STOP and escalate to the owner the `subprocess.run` → `Popen` (interleaved read-loop + true incremental byte bound) vs `mcp`-SDK-dependency decision BEFORE Task 3 — do not ship an unverified/flaky transport.** The Popen variant also delivers the real incremental read bound that the post-capture `subprocess.run` size check only approximates (the HTTP path's `resp.read(MAX_RESPONSE_BYTES+1)` was a true bound — this loosens it within the 10s window; the oversize test asserts the post-capture guard still fails closed). + +**Step 4: GREEN (fake + live replay). Step 5: commit.** **DoD:** the live-capture replay test is GREEN (or the escalation branch was taken); all fault variants raise `WarplineError`; no shell, list argv, empty-argv rejected, `text=False` byte cap, timeout, `stderr`/`returncode` in messages; committed. + +--- + +### Task 3: rewire `mcp.py` — `WARPLINE_MCP_CMD` config + construction + fail-safe + +**Files:** Modify `src/legis/mcp.py:231-243`; update `tests/mcp/test_server.py:3388,3397` (the `HttpWarplineClient` import + the `isinstance(..., HttpWarplineClient)` assertion → `WarplineMcpClient`). + +Replace the `WARPLINE_API_URL` block (it's the wrong config for a non-HTTP transport): +```python + warpline = None + warpline_cmd = os.environ.get("WARPLINE_MCP_CMD") + if warpline_cmd: + import shlex + from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError, WarplineMcpClient + try: + argv = shlex.split(warpline_cmd) + if not argv: + raise WarplineError("WARPLINE_MCP_CMD is blank") + warpline = WarplineMcpClient(invoke=StdioMcpInvoke(command=argv), repo=_legis_repo_root()) + except (WarplineError, ValueError) as exc: + logging.getLogger(__name__).warning( + "WARPLINE_MCP_CMD is set but invalid (%s); warpline advisory context " + "disabled (governance unaffected).", exc) + warpline = None +``` +**(DISCOVERED at Task 2 — the live capture caught this; REQUIRED) Thread the `repo` argument.** Real `warpline_impact_radius_get` / `warpline_reverify_worklist_get` REQUIRE a `"repo"` argument (a non-empty repo path; without it warpline returns JSON-RPC `-32602 invalid params` → the client fail-safes to `WarplineError` → `unavailable`, i.e. the seam is dead-on-arrival again, just a different reason than the old HTTP one). So: (1) give `WarplineMcpClient.__init__` a `repo: str` param and have `_call` send `arguments={"repo": self._repo, "rev_range": f"{base}..{head}"}` (the live envelope confirms warpline echoes `query.repo` and fills `depth`/etc. server-side defaults). (2) Update the Task-1 `test_client.py` assertions that check `args == {"rev_range": ...}` to expect the `repo` key too. (3) In `mcp.py`, supply the repo value from **legis's existing repo-root resolution** — find it (`config.py` / the git surface / the runtime root; the captured session used `/home/john/legis`). Do NOT invent a config knob if legis already resolves its repo root — reuse it; replace the `_legis_repo_root()` placeholder above with the real resolver. This is the load-bearing reason the seam works end-to-end against real warpline. + +Note: construction only stores the command; a bad command's failure surfaces at *call* time (caught by `read_warpline_preflight` → `unavailable`). The empty-`shlex.split` case is rejected up front. **Tests:** unset `WARPLINE_MCP_CMD` → `warpline is None` → preflight `unavailable`; blank/`" "` → fail-safe `None`. Grep `tests/` for `WARPLINE_API_URL` and update; fix `test_server.py:3388/3397` (`HttpWarplineClient` → `WarplineMcpClient`). **Commit.** **DoD:** runtime builds `WarplineMcpClient(repo=...)` from `WARPLINE_MCP_CMD`; the `repo` arg is sent (sourced from legis's repo-root resolver) and the Task-1 tests assert it; unset/blank/invalid → `None` (fail-safe); no `WARPLINE_API_URL`/`HttpWarplineClient` in `src/`; `test_server.py` symbols updated; full `mypy src/legis` + `pytest` collection now succeed (the Task-1 cross-task breaks are resolved here); committed. + +--- + +### Task 4: replace the mis-frozen fixtures with REAL envelopes; a NON-CIRCULAR oracle; reverse the producer-obligation + +**Files:** Replace `tests/warpline_preflight/fixtures/warpline-preflight-golden.json` (flat → real envelopes). Rewrite `fixtures/PROVENANCE.md`. Rewrite `tests/warpline_preflight/test_warpline_preflight_oracle.py`. Update `tests/mcp/test_warpline_advisory_boundary.py:74-99`. Update `tests/mcp/test_output_schema_conformance.py:635-639`. + +**(rank-1 — the load-bearing blocker) The oracle must be NON-CIRCULAR.** The whole point of this fix is that a golden must be validated by flowing through legis's REAL parse path with HARDCODED assertions — never `json.loads(golden)` re-parsed and asserted against itself. Specify it explicitly: +```python +def test_golden_flows_through_the_real_parser_with_hardcoded_assertions(tmp_path): + """Drive the FROZEN golden bytes through WarplineMcpClient._call (the real + schema/ok/meta/completeness validation), via a fake invoke replaying the bytes. + Assert HARDCODED values from the golden — NEVER a re-parse of the golden.""" + golden = json.loads((FIX / "warpline-preflight-golden.json").read_text()) + impact = WarplineMcpClient(invoke=lambda tool, args: golden["impact_radius"]).impact_radius("b", "h") + assert impact["schema"] == "warpline.impact_radius.v1" + assert impact["data"]["affected"][0]["sei"] == "loomweave:eid:" # hardcoded + assert impact["meta"]["local_only"] is True and impact["meta"]["peer_side_effects"] == [] + assert impact["data"]["completeness"] == "FULL" + reverify = WarplineMcpClient(invoke=lambda tool, args: golden["reverify_worklist"]).reverify_worklist("b", "h") + assert reverify["data"]["items"][0]["sei"] == "loomweave:eid:" # hardcoded, NOT re-parsed +``` +Keep a **Layer-1 byte-pin** (`GOLDEN_BLOB_SHA` at oracle:49 — **re-pin it** after the golden changes; self-catching via the byte-pin test) and a **Layer-2** `test_golden_matches_warpline_source` that points at **warpline's MCP contract fixture** (not the old REST path) and `pytest.skip`s cleanly when absent. + +**Capture the golden from a LIVE warpline run** (use the Task-2 transcript or `warpline blast-radius/reverify --json`), saved verbatim as real `warpline.impact_radius.v1`/`warpline.reverify_worklist.v1` envelopes. **(rank-6) Add a machine-readable provenance marker** to the golden, e.g. a top-level `"_provenance": {"source": "live-captured" | "pending-live-capture"}`, and a CI-visible test that **FAILS when `source == "pending-live-capture"` unless an explicit escape env var is set** — so "pending" can never silently masquerade as vendored (the discipline does not rely on prose). Name the var `LEGIS_WARPLINE_GOLDEN_PENDING_OK`; skeleton: `if golden.get("_provenance", {}).get("source") == "pending-live-capture" and not os.environ.get("LEGIS_WARPLINE_GOLDEN_PENDING_OK"): pytest.fail("golden is pending live capture — set LEGIS_WARPLINE_GOLDEN_PENDING_OK=1 only as a temporary escape")`. If no live warpline is reachable, construct from the §-spec, set `source: "pending-live-capture"`, record it in PROVENANCE.md, and let that CI assertion hold the line. + +**(rank-5) `_HostileWarpline` + the conformance stub** must both return a real envelope **with a GV-LG-3-VALID meta** (`local_only:true, peer_side_effects:[]`) — so the *advisory payload* (hostile values in `data.affected`/etc.), not a contract violation, is what's proven inert (a GV-LG-3-violating meta would now be REFUSED → `unavailable`, silently making the byte-identity test vacuous). Apply this to BOTH `test_warpline_advisory_boundary.py:74-81` AND `test_output_schema_conformance.py:635-639` (the latter asserts `status=='checked'`, so an invalid-meta stub would flip it to `unavailable`). **Add a guard** in the byte-identity test that the hostile-warpline side actually reached `status=='checked'` (not `'unavailable'`), so the comparison cannot silently degrade to `unavailable==unavailable` — concretely, in the test's governance-paths helper after the `policy_evaluate` calls: `pf = call_tool(runtime, "warpline_preflight_get", {...}); assert pf["structuredContent"]["status"] == "checked"`. **Add a positive test** that an invalid-meta envelope yields `unavailable` (pins GV-LG-3 end-to-end). **State explicitly** that the structural boundary test (`test_warpline_advisory_boundary.py:143-174`, derived from `_TOOL_HANDLERS`) is **preserved unchanged** — it is the load-bearing "warpline can't reach a verdict" invariant. + +**Rewrite PROVENANCE.md:** delete the "WARPLINE PRODUCER-SIDE OBLIGATION"; record that legis conforms to warpline's extant envelope (live-captured), cite SEAM 4 §4A + GV-LG-3. Remove the oracle's deleted-symbol imports (`HttpWarplineClient`, `_decode_json_response`). **Commit** in logical groups. **DoD:** no flat `{affected,count}`/`{entries,count}` anywhere; the oracle flows the golden through the real parser with hardcoded assertions (non-circular); golden carries a machine-checked provenance marker; conformance/boundary stubs carry a valid meta + the byte-identity guard + a GV-LG-3 positive test; the structural boundary test preserved; PROVENANCE reversed; committed. + +--- + +### Task 5: full verification (suite + the 1.2.0 invariants + corrected coverage floors + gates) + +**Files:** `scripts/check_coverage_floors.py` (add a `src/legis/warpline_preflight/` floor, ~85%, once the new client lands — the package currently has **no** floor entry, so it's guarded only by the global 88%). Otherwise verification only. + +Run, expecting green: +```bash +uv run pytest tests/warpline_preflight tests/mcp -q +uv run pytest tests/mcp/test_warpline_advisory_boundary.py -q # byte-identity + structural (143-174) invariants +uv run pytest --cov=legis --cov-fail-under=88 +uv run python scripts/check_coverage_floors.py # NOTE actual floors: mcp.py 80, service/ 92 (the v1 plan's "~92/95" was wrong) +uv run mypy src/legis +uv run ruff check src +uv run legis governance-gate +uv run legis policy-boundary-check --root src --repo-root . +uv run pytest tests/conformance/test_sei_oracle.py +``` +**Watch:** the 1.2.0 advisory-boundary byte-identity + attestation forge-resistance invariants stay green. **Grep `src/ tests/`** for any lingering `WARPLINE_API_URL`, `/api/impact-radius`, `/api/reverify-worklist`, `"count"`, `"entries"`, `HttpWarplineClient`, or `_decode_json_response` in the warpline path — none should remain. **DoD:** all gates green; a `warpline_preflight/` coverage floor added; the 1.2.0 invariants hold; no HTTP/`count`/`entries`/deleted-symbol residue; branch ready for review (NOT merged — owner-gated). + +--- + +## After execution — acceptance + closeout (product-owner, post-merge) +- Close `legis-a53d92507d`. This is a **federation-seam-quality** bet, **NOT** a north-star (governance-honesty) item — it fails safe; do not move the north-star. +- **Do NOT release 1.3.0 until this is merged** (the 1.3.0-prep carries the mis-frozen golden). +- **reverify stays gated on wardline** (§2A names filigree). If drop: remove `reverify_worklist` from `service/preflight.py:28` + the client method (clean, independent). If bless: stays. Do not freeze it before wardline rules. + +## Revision history + validate-before-execution +**Round 1 (7-agent + synthesizer, 2026-06-26): CHANGES_REQUESTED** — no cardinal-sin risk (advisory boundary, fail-open, GV-LG-3 all confirmed sound), but two verification-layer blockers, now fixed: **(blocker 1)** the oracle is re-specified NON-CIRCULAR (golden flows through `WarplineMcpClient._call` with hardcoded assertions, Layer-2 points at warpline's MCP contract fixture); **(blocker 2)** guardrail-(b) fail-safe is closed in CODE (non-dict-meta guard, the whole post-spawn parse wrapped → `WarplineError`, empty-argv rejected, `_read_jsonrpc_result` tightened) AND in TESTS (the `...` isError stub completed + non-dict-meta/scalar-result/non-JSON-line/empty-argv/timeout/oversize variants, all asserting `WarplineError` → `unavailable`). Folded in: the live-capture HARD DoD gate + Popen/SDK escalation branch (rank 3), the honest output-cap wording + `text=False` byte bound (rank 4), the conformance-stub valid-meta + byte-identity guard + GV-LG-3 positive test + structural-invariant statement (rank 5), the machine-readable golden provenance marker (rank 6), `stderr`/`returncode` in errors (rank 7), the `data.completeness` degraded-floor (rank 8), corrected coverage floors + a new `warpline_preflight` floor (rank 9), and the missed symbols + corrected PATH/threat-model wording (rank 10). **Re-run `/review-plan` (the ultracode review) on this revision before executing** — round 1's synthesis does not carry forward. diff --git a/docs/plans/2026-06-27-governance-read-v1-per-sei.md b/docs/plans/2026-06-27-governance-read-v1-per-sei.md new file mode 100644 index 0000000..fe18f58 --- /dev/null +++ b/docs/plans/2026-06-27-governance-read-v1-per-sei.md @@ -0,0 +1,499 @@ +# governance_read.v1 — per-SEI governance read (warpline→legis seam) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute this plan task-by-task. +> **REVISION 2** — incorporates the 7-agent review (CHANGES_REQUESTED → addressed): the CLI +> false-green (must-fix #1), the tautological oracle (#2), missing-DB downgrade (#3), schema +> discriminator (#5), as_of guard (#6), MCP `_one_of` outputSchema (#7), durable artifacts (#8), and +> all warnings. The contract (`contracts/governance_read.v1.schema.json` + the warpline prompt at +> `docs/contracts/warpline-governance-read.v1-prompt.md`) is **already authored & on disk** (the +> tightened, discriminated-union version) — Task 1 tests + commits it, it does not re-derive it. + +**Goal:** Expose legis's verified per-SEI governance clearances as the published `governance_read.v1` +contract on CLI + MCP + HTTP, so warpline's `reverify_worklist(include_federation=True)` can enrich +its worklist advisorily (never gate) with legis governance facts. + +**Architecture:** Project the EXISTING forge-proof per-SEI read (`read_sei_attestations`, +`service/governance.py:223`) into a posture-record shape. One service projection +(`read_governance_for_sei`) + a verified-gate wrapper (`read_governance_for_sei_gate`, mirroring +`evaluate_override_rate_gate`) + a shared unavailable-envelope helper, all in `service/governance.py`. +Three thin adapters; each owns its fail-closed "is the trail signature-verifiable?" pre-gate exactly +as `attestation_get` does. The wire shape is FROZEN in `contracts/governance_read.v1.schema.json`. + +**Tech Stack:** Python 3.12, `uv`, FastAPI, JSON-RPC stdio, argparse, `jsonschema>=4.21`, pytest. + +**Prerequisites:** +- `uv sync --dev` run. +- Read before starting: `service/governance.py:223-350` (`read_sei_attestations`, the forge-proof + core) and `:381-409` (`evaluate_override_rate_gate`, the verify-gate pattern Task 2/5 mirror); + `mcp.py:324` (`_one_of`), `:2292-2333` (`_governance_trail_records`, `_tool_attestation_get`), + `:1268-1316` (the `attestation_get` `_one_of` outputSchema); `api/app.py:717-723` + `:865-871`; + `cli.py:263` (`_missing_sqlite_db`), `:287-329` (`_check_override_rate`); the FROZEN + `contracts/governance_read.v1.schema.json`; `tests/conformance/test_warpline_attestation_oracle.py` + (the frozen-golden pattern Task 3's oracle mirrors). + +--- + +## Global Constraints (every task; the reviewer's attention lens — copy verbatim) + +1. **FAIL-CLOSED / no false-green (cardinal sin).** Unverifiable trail → `status:"unavailable"` + (discriminated, with reason), NEVER silent `records:[]`. A tampered trail (signature OR + hash-chain contiguity) RAISES loudly. `records:[]` is reachable ONLY under `status:"checked"`. + **Both verification halves are mandatory on every path: `store.verify_integrity()` (the chain / + delete-reorder-truncate defence) AND `TrailVerifier.verify()` (signatures).** `TrailVerifier.verify` + alone is NOT sufficient — it has no seq-contiguity walk (that lives only in `verify_integrity`). +2. **Honest scope (cleared-only, v1).** `records:[]` = "no verified clearance for this SEI on the + verified trail" — NOT "ungoverned," NOT "unknown SEI." legis is an SEI consumer; it cannot and + must not pretend to distinguish unknown-from-ungoverned. +3. **Forge-proofing inherited, not re-implemented.** `read_governance_for_sei` obtains records ONLY + by calling `read_sei_attestations`; no trail re-walk, no re-derived admission, no unsigned field. +4. **`posture` claims only the PROVABLE mechanism.** `operator_override` → `"protected_override"`; + `signoff_cleared` → `"operator_signoff"`. Never an enforcement cell for a sign-off. +5. **Record field is `disposition`, not `status`** (avoids the envelope-status / enrichment.governance + 3-way collision). +6. **Service layer is single source of truth.** The projection, the verified-gate wrapper, and the + unavailable helper live in `service/governance.py`; adapters are thin; no governance decision + duplicated in an adapter (the CLI verify path goes through the service wrapper, NOT a hand-rolled + `TrailVerifier` call). +7. **Guardrails (must not degrade):** advisory-boundary byte-identity; attestation forge-resistance + (reused, untouched); coverage floors via `scripts/check_coverage_floors.py` — actual floors are + **`src/legis/service/` = 92.0, `src/legis/mcp.py` = 80.0, `src/legis/api/` = 88.0** (global 88); + all CI gates green (pytest, mypy `src/legis`, ruff `E4/E7/E9/F`, SEI oracle, policy-boundary, + governance-gate). +8. **No contract mutation post-commit, no touching** `audit_store.py`/`head_anchor.py`/`canonical.py` + (stays `ensure_ascii=False`). Signing keys out of reach. `.v1` is a one-way door — any change is + `.v2`, never an edit. +9. Commit trailer `Co-Authored-By: Claude Opus 4.8 (1M context) `. Do NOT push + or open a PR (owner-gated). Implementers: commit ONLY your task's files; no history mutation. + +--- + +### Task 1: Test + commit the FROZEN contract (`governance_read.v1.schema.json`) + +The schema and the warpline prompt are ALREADY on disk (authored as the contract freeze). This task +adds the validation tests and commits all three together. + +**Files:** +- Already on disk (commit, do not rewrite): `contracts/governance_read.v1.schema.json`, + `docs/contracts/warpline-governance-read.v1-prompt.md` +- Create: `tests/contract/test_governance_read_v1_schema.py` (dir is `tests/contract`, NO `s`) +- Modify: `pyproject.toml` dev group — add `"jsonschema[format]"` (enables the `date-time` format + assertion; `jsonschema` is already pinned, this adds the rfc3339 backend). Run `uv sync --dev`. + +**Step 1: Write the failing test** — load `contracts/governance_read.v1.schema.json`, build +`Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)`. Tests: +- POSITIVE: checked+two-clearances validates; checked+empty-records validates; unavailable+reason + validates. +- DISCRIMINATOR NEGATIVES (must-fix #5): `test_unavailable_without_reason_rejected` + (`{status:unavailable, records:[]}` no `unavailable` key → rejected); `test_unavailable_with_records_rejected` + (`{status:unavailable, records:[rec], unavailable:[…]}` → rejected); `test_checked_with_unavailable_key_rejected`. +- CONSTRAINT NEGATIVES (warnings): `test_unknown_status_rejected`; `test_record_extra_field_rejected`; + `test_envelope_extra_field_rejected`; `test_empty_content_hash_rejected`; `test_empty_envelope_sei_rejected`; + `test_empty_record_sei_rejected`; `test_empty_reasons_rejected` (minItems:1); `test_invalid_posture_rejected`. +- FORMAT (must-fix #6): `test_non_rfc3339_as_of_rejected` (`as_of:"not-a-date"` → rejected *because* + `format_checker` is wired; assert it passes WITHOUT the checker to prove the checker is load-bearing). +- DRIFT GUARD (must-fix #8): `test_prompt_schema_block_matches_committed_file` — extract the JSON + fenced block under "## The contract" in `docs/contracts/warpline-governance-read.v1-prompt.md`, + `json.loads` both it and the committed schema file, assert the two parsed objects are EQUAL + (JSON-equal, not byte-equal — robust to whitespace). + +The reference shapes for these tests are the three literal samples in the warpline prompt + the +ad-hoc bad shapes; reuse the validation matrix already proven in +`scratchpad`-free form (the schema was validated at authoring — these tests pin it permanently). + +**Step 2: Run to verify failure** — `uv run pytest tests/contract/test_governance_read_v1_schema.py -v` +(fails: test file absent / `jsonschema[format]` not synced). + +**Step 3:** No schema to write (frozen on disk). Add the dep, `uv sync --dev`, write the tests. + +**Step 4: Run to verify pass** — same command; all green. `uv run python -c "import json,jsonschema; +jsonschema.Draft202012Validator.check_schema(json.load(open('contracts/governance_read.v1.schema.json')))"`. + +**Step 5: Commit** — schema + prompt + tests + pyproject change, one "freeze governance_read.v1 +contract" commit. + +**Definition of Done:** +- [ ] All positive + 3 discriminator-negative + 8 constraint-negative + 1 format + 1 drift test pass. +- [ ] `format_checker` is wired and proven load-bearing (the without-checker assertion). +- [ ] Prompt's schema block parses EQUAL to the committed schema file. +- [ ] Contract committed; `.v1` is now frozen (changes → `.v2`). + +--- + +### Task 2: Service — projection + verified-gate wrapper + unavailable helper + +**Files:** +- Modify: `src/legis/service/governance.py` (after `read_sei_attestations`, ~line 350) +- Modify: `src/legis/service/__init__.py` (export `read_governance_for_sei`, + `read_governance_for_sei_gate`, `governance_read_unavailable`) +- Test: `tests/service/test_governance_read.py` + +**Step 1: Write the failing test** — cover: +- `test_projects_override_to_clearance_record`: a record admitted as `operator_override` → + `posture:"protected_override"`, full dict asserted. **Fixture uses `Verdict.OVERRIDDEN_BY_OPERATOR.value`, + not the literal string** (warning), with a comment naming the coupling. +- `test_signoff_projection_full_dict` (monkeypatch `read_sei_attestations`): assert the WHOLE record + dict (sei/disposition/posture=`operator_signoff`/authority/as_of/reasons/content_hash), not just + posture (warning: expand the signoff test). +- `test_verified_but_no_clearance_is_checked_empty` → `{status:checked, sei, records:[]}` (honest + absence, NOT unavailable). +- `test_as_of_none_is_omitted` (must-fix #6): monkeypatch `read_sei_attestations` to return an + attestation with `recorded_at: None` → the record is OMITTED (empty records), not emitted with + `as_of: null`. +- `test_as_of_non_rfc3339_is_omitted` (must-fix #6): `recorded_at:"garbage"` → omitted. +- `test_unknown_kind_is_omitted` (warning): monkeypatch an attestation with `kind:"future_kind"` → + `_POSTURE_BY_KIND.get` returns None → omitted (WHEN IN DOUBT, OMIT), no KeyError crash. +- `test_status_propagated_not_hardcoded`: monkeypatch `read_sei_attestations` to return + `status:"checked"` (current invariant) and assert the wrapper carries it; add an assert/guard so a + future non-`checked` status from `read_sei_attestations` does not get silently relabeled. +- `test_gate_raises_on_signature_tamper` (must-fix #1): build a protected record set, corrupt a + signature, call `read_governance_for_sei_gate(records, sei, hmac_key=…, protected_policies=…)` → + raises `AuditIntegrityError`. +- `test_gate_requires_key_for_protected`: protected records present, `hmac_key=None` → raises + `ProtectedKeyRequiredError`. +- `test_unavailable_helper_shape`: `governance_read_unavailable(sei, "reason")` → + `{status:unavailable, sei, records:[], unavailable:[{reason}]}`. + +**Step 2: Run to verify failure** (`ImportError`). + +**Step 3: Implement** in `service/governance.py`: + +```python +from datetime import datetime + +_POSTURE_BY_KIND = { + "operator_override": "protected_override", # provable: protected_cell + OVERRIDDEN_BY_OPERATOR signed + "signoff_cleared": "operator_signoff", # provable: SIGNED_OFF + integrity-bound request +} +# Three coupled points: read_sei_attestations' admitted kinds, these map keys, and the schema's +# `posture` enum. A new clearance kind must update all three. reasons = clearance-kind code (WHAT +# happened); posture = provable mechanism (HOW) — distinct axes, 1:1 in v1. + + +def _is_rfc3339(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + return True + except ValueError: + return False + + +def read_governance_for_sei(verified_runtime_records: list, sei: str) -> dict[str, Any]: + """Per-SEI VERIFIED GOVERNANCE CLEARANCES as the ``governance_read.v1`` envelope. + + A pure PROJECTION of ``read_sei_attestations`` (the forge-proof admitted set) — adds NO admission + logic and reads NO unsigned field, inheriting the signature-coverage/asymmetric-error guarantees. + A clearance whose ``recorded_at`` is absent or non-RFC3339, or whose ``kind`` has no known + posture, is OMITTED (asymmetric-error: a missing clearance only wastes warpline work; a malformed + one is the unsafe direction). The caller owns the ``status:"unavailable"`` pre-gate via + ``governance_read_unavailable`` for the no-key / unverifiable-trail case. + """ + att = read_sei_attestations(verified_runtime_records, sei) + if att.get("status") != "checked": + # read_sei_attestations is contracted to return "checked" here (the handler owns the + # unavailable pre-gate). If that ever changes, fail loud rather than relabel it "checked". + raise AuditIntegrityError( + f"read_sei_attestations returned unexpected status {att.get('status')!r}" + ) + records: list[dict[str, Any]] = [] + for a in att["attestations"]: + posture = _POSTURE_BY_KIND.get(a["kind"]) + if posture is None: + continue # unknown kind -> omit, never fabricate a posture + if not _is_rfc3339(a.get("recorded_at")): + continue # missing / malformed timestamp -> omit, never ship as_of:null + records.append( + { + "sei": sei, + "disposition": "cleared", + "posture": posture, + "authority": "operator", + "as_of": a["recorded_at"], + "reasons": [a["kind"]], + "content_hash": a["content_hash"], + } + ) + return {"status": "checked", "sei": sei, "records": records} + + +def read_governance_for_sei_gate( + records: list, sei: str, *, hmac_key: str | None, protected_policies +) -> dict[str, Any]: + """Verified governance read for the CLI/batch path: detect protected -> require key -> verify + signatures (fail closed) -> project. Mirrors ``evaluate_override_rate_gate`` exactly, so the CLI + measures the same trust the HTTP/MCP paths do (Constraint 6). The store-level hash-chain check + (``verify_integrity``) is the CALLER's responsibility BEFORE this call (as in + ``_check_override_rate``) — both halves are mandatory (Constraint 1). + """ + protected_present = any( + _requires_protected_verification(r.payload, protected_policies) for r in records + ) + if protected_present and not hmac_key: + raise ProtectedKeyRequiredError( + "Protected audit records require LEGIS_HMAC_KEY for verification" + ) + if hmac_key: + verifier = TrailVerifier(hmac_key.encode("utf-8"), protected_policies) + try: + verifier.verify(records) + except TamperError as exc: + raise AuditIntegrityError( + f"Protected audit trail verification failed: {exc}" + ) from exc + return read_governance_for_sei(records, sei) + + +def governance_read_unavailable(sei: str, reason: str) -> dict[str, Any]: + """The shared ``governance_read.v1`` unavailable envelope (one shape across all 3 adapters). + NEVER a silent ``checked``/``[]`` — an unverifiable trail reads as "could not check" (GOV-2).""" + return {"status": "unavailable", "sei": sei, "records": [], "unavailable": [{"reason": reason}]} +``` + +(`TamperError` is already imported in `governance.py` for `evaluate_override_rate_gate`; confirm and +reuse the same import. `AuditIntegrityError`, `ProtectedKeyRequiredError`, +`_requires_protected_verification`, `TrailVerifier` likewise.) + +**Step 4: Run to verify pass**; then `uv run mypy src/legis`. + +**Step 5: Commit.** + +**Definition of Done:** +- [ ] All ~10 tests pass; mypy clean. +- [ ] Projection calls `read_sei_attestations`; omits unknown-kind / bad-`as_of`; propagates/guards + status; reads no unsigned field. +- [ ] `read_governance_for_sei_gate` mirrors `evaluate_override_rate_gate` (key-required + signature + verify, `AuditIntegrityError` on tamper); three names exported. + +--- + +### Task 3: MCP tool `governance_read` (handler + `_one_of` outputSchema + frozen-golden oracle) + +**Files:** +- Modify: `src/legis/mcp.py` — name list (~106); tool def w/ `_one_of` outputSchema (near the + `attestation_get` def ~1268); handler (near `_tool_attestation_get` ~2308); `_TOOL_HANDLERS` + registry (~2568, the dict is `_TOOL_HANDLERS`, not `_TOOLS`). +- Test: `tests/mcp/test_governance_read_tool.py` +- Modify: `tests/mcp/test_output_schema_conformance.py` (per-tool tests — add explicit cases for the + new tool's BOTH variants; this file is NOT auto-iterating). +- Create: `tests/conformance/test_governance_read_oracle.py` (FROZEN GOLDEN — mirror + `test_warpline_attestation_oracle.py`) +- Create (committed by the oracle on first run): `tests/conformance/fixtures/governance_read_*.json` + +**Step 1: Write the failing tests:** +- Handler tests: (a) verifiable trail with a clearance → `{status:checked, records:[…]}`; + (b) `protected_gate is None or trail_verifier is None` → `{status:unavailable, …}` (NOT empty + checked); (c) tampered trail → `AuditIntegrityError` → `AUDIT_INTEGRITY_FAILURE` error envelope. + Reuse runtime fixtures from `tests/mcp/test_attestation*.py`. +- outputSchema conformance: add two explicit cases in `test_output_schema_conformance.py` — the + `checked` variant and the `unavailable` variant of `governance_read` each validate against the + declared `_one_of` outputSchema. +- **Frozen-golden oracle** (must-fix #2): mirror `test_warpline_attestation_oracle.py` exactly — + `_build_attested_store` (reuse the attestation oracle's helper) to write GENUINE signed records for + BOTH clearance kinds; `call_tool(runtime, "governance_read", {"sei": _SEI})`; write + `structuredContent` to a committed fixture; pin its git blob SHA1 as `GOLDEN_BLOB_SHA`; the test + asserts LIVE output == FROZEN golden by VALUE (not `schema.validate(live)`). Add + `test_both_clearance_kinds_are_pinned`. The golden is a projection of the existing attestation + golden — cross-check the content_hashes match. + +**Step 2: Run to verify failure** (unknown tool / missing fixture). + +**Step 3: Implement** — handler mirrors `_tool_attestation_get`: + +```python +def _tool_governance_read(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.governance import governance_read_unavailable, read_governance_for_sei + + sei = _require(args, "sei") + # FAIL-CLOSED pre-gate (same invariant as attestation_get): a verifiable answer needs BOTH a + # protected gate AND a trail_verifier. Missing either -> unavailable (discriminated), never []. + if runtime.protected_gate is None or runtime.trail_verifier is None: + return _tool_result( + governance_read_unavailable( + sei, "trail not signature-verifiable (no protected gate / verifier)" + ) + ) + # _governance_trail_records -> verified_records runs BOTH verify_integrity (chain) and + # TrailVerifier.verify (signatures), raising AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on + # a tampered protected trail (loud, never a result). + return _tool_result(read_governance_for_sei(_governance_trail_records(runtime), sei)) +``` + +Tool def: inputSchema `{sei: string, required}`; **outputSchema built with `_one_of([checked_variant, +unavailable_variant])` using the v1 `clearance_record` fields** (NOT attestation_get's fields, +must-fix #7) — `checked_variant`: `status:const checked`, `records` array of the 7-field record; +`unavailable_variant`: `status:const unavailable`, `records` maxItems 0, `unavailable` array of +`{reason}`. Description: "Per-SEI VERIFIED governance CLEARANCE facts (governance_read.v1). +Advisory — never gate on this. records:[] under 'checked' = no verified clearance, NOT ungoverned; +'unavailable' ≠ 'absent'." Register in the name list + `_TOOL_HANDLERS`. + +**Step 4: Run to verify pass** — the tool tests + the oracle + `test_output_schema_conformance.py` +(and any meta-test that every tool has an outputSchema). + +**Step 5: Commit** (incl. the committed golden fixture). + +**Definition of Done:** +- [ ] Tool registered (name list, def w/ `_one_of` outputSchema over v1 fields, `_TOOL_HANDLERS`). +- [ ] Unavailable pre-gate gates on BOTH objects; tamper → `AUDIT_INTEGRITY_FAILURE`. +- [ ] Oracle is a FROZEN GOLDEN with a pinned blob SHA, asserting LIVE == FROZEN (not schema-validate); + both clearance kinds pinned. + +--- + +### Task 4: HTTP route `GET /governance/sei/{sei:path}/governance-read` + +**Files:** +- Modify: `src/legis/api/app.py` (route near `identity_gaps`/`lineage_integrity` ~865; import the two + service fns with the other service imports) +- Test: `tests/api/test_governance_read_route.py` + +**Step 1: Write the failing test** — TestClient: (a) wired gate+verifier + a clearance → 200 +`{status:checked, records:[…]}`; (b) no gate/verifier → 200 `{status:unavailable, …}`; (c) tampered +trail → 500. Add (d) a SEI containing `/` (e.g. URL-encoded) round-trips to the handler (the +`{sei:path}` capture, warning). Mirror the identity-gap/lineage app-construction fixtures. + +**Step 2: Run to verify failure** (404). + +**Step 3: Implement** inside `create_app`: + +```python + @app.get("/governance/sei/{sei:path}/governance-read") + def governance_read(sei: str) -> dict: + # {sei:path} captures a SEI that may contain '/'. FAIL-CLOSED pre-gate mirrors the MCP tool; + # verified_governance_records() runs BOTH chain + signature checks and maps tamper to HTTP 500. + if protected_gate is None or trail_verifier is None: + return _governance_read_unavailable( + sei, "trail not signature-verifiable (no protected gate / verifier)" + ) + return _read_governance_for_sei(verified_governance_records(), sei) +``` + +Import `read_governance_for_sei as _read_governance_for_sei`, `governance_read_unavailable as +_governance_read_unavailable`. + +**Step 4: Run to verify pass.** + +**Step 5: Commit.** + +**Definition of Done:** +- [ ] Route returns the v1 envelope; unavailable w/o gate/verifier; 500 on tamper; `{sei:path}` + handles a `/`-bearing SEI. + +--- + +### Task 5: CLI `legis governance-read ` (verified path through the service gate) + +**Files:** +- Modify: `src/legis/cli.py` — subparser near `governance-gate` (~109); dispatch near ~741; the + `_governance_read(db_url, sei)` helper near `_check_override_rate` (~292) +- Test: `tests/cli/test_governance_read_cli.py` + +**Step 1: Write the failing test** (mirror existing CLI test invocation): +- (a) `LEGIS_HMAC_KEY` set + verifiable store w/ a clearance → exit 0, stdout JSON `{status:checked, + records:[…]}`. +- (b) NO `LEGIS_HMAC_KEY` → exit 0, stdout `{status:unavailable,…}` (can't verify sigs). +- (c) **CHAIN-tampered store** (delete/reorder a non-protected record — NOT just a bad signature, + must-fix #1) → nonzero exit AND stderr contains "audit integrity". This is the false-green + regression test; it MUST exercise `verify_integrity`, which `TrailVerifier.verify` alone would miss. +- (d) signature-tampered protected store → nonzero exit + "audit integrity" on stderr. +- (e) **missing/relocated DB** (point `--db` at a nonexistent path, must-fix #3) → stdout + `{status:unavailable,…}` ("governance store not found"), NOT a silent `checked/[]` from an + auto-created empty DB. + +**Step 2: Run to verify failure.** + +**Step 3: Implement** — subparser `governance-read` with positional `sei` and `--db` +(default `governance_db_url()`); **no `--json` flag** (output is always JSON; warning). Dispatch +branch `if args.command == "governance-read": return _governance_read(args.db, args.sei)`. Helper: + +```python +def _governance_read(db_url: str, sei: str) -> int: + import os + from legis.config import protected_policies + from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError + from legis.service.governance import governance_read_unavailable, read_governance_for_sei_gate + from legis.store.audit_store import AuditStore + + hmac_key = os.environ.get("LEGIS_HMAC_KEY") + if not hmac_key: + # No key -> signatures unverifiable from the CLI -> unavailable, never silent checked/[]. + print(json.dumps(governance_read_unavailable( + sei, "trail not signature-verifiable (LEGIS_HMAC_KEY unset)"), sort_keys=True)) + return 0 + missing_db = _missing_sqlite_db(db_url) + if missing_db is not None: + # Absent store on a READ = unavailable (NOT the override-rate CI PASS_WITH_NOTICE axis, and + # NOT an auto-created empty DB read as checked/[]). + print(json.dumps(governance_read_unavailable( + sei, f"governance store not found: {missing_db}"), sort_keys=True)) + return 0 + store = AuditStore(db_url) + if not store.verify_integrity(): # the chain / delete-reorder-truncate half (mandatory) + print("Error: audit integrity failure: database hash chain verification failed", file=sys.stderr) + return 1 + records = store.read_all() + try: # the signature half, through the service gate (Constraint 6) + envelope = read_governance_for_sei_gate( + records, sei, hmac_key=hmac_key, protected_policies=protected_policies()) + except (ProtectedKeyRequiredError, AuditIntegrityError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print(json.dumps(envelope, sort_keys=True)) + return 0 +``` + +**Step 4: Run to verify pass** — especially the chain-tamper (c) and missing-DB (e) cases. + +**Step 5: Commit.** + +**Definition of Done:** +- [ ] Both verify halves run: `store.verify_integrity()` (chain) BEFORE `read_all`, then the service + gate (signatures). Chain-tamper test (c) is RED without `verify_integrity`, GREEN with it. +- [ ] No key → unavailable; missing DB → unavailable; tamper → nonzero + "audit integrity" stderr. +- [ ] Exceptions caught are `ProtectedKeyRequiredError`/`AuditIntegrityError` (TamperError is wrapped + inside the service gate) — no bare `except`. + +--- + +### Task 6: Guardrails — advisory-boundary pin, coverage floors, full gate sweep + +**Files:** `tests/mcp/test_warpline_advisory_boundary.py` (one edit); otherwise verification. + +**Steps:** +1. **Advisory-boundary structural pin** (warning): add `read_governance_for_sei`, + `read_governance_for_sei_gate`, and `governance_read_unavailable` to the explicit symbol list in + `test_runtime_warpline_referenced_in_no_verdict_path_function` + (`tests/mcp/test_warpline_advisory_boundary.py:~218-235`), so the new service fns are pinned as + non-verdict-path. Run that file + the byte-identity advisory test — both green. +2. **Forge-resistance untouched:** `uv run pytest -k "attestation"` green. +3. **Coverage floors** (corrected numbers): `uv run pytest --cov=legis --cov-fail-under=88` then + `uv run python scripts/check_coverage_floors.py` — `service/` ≥92, `mcp.py` ≥80, `api/` ≥88. Add a + missing unavailable/tamper-branch test if a floor dips. +4. **Full CI-equivalent sweep:** `uv run ruff check src`, `uv run mypy src/legis`, + `uv run pytest tests/conformance/test_sei_oracle.py`, + `uv run legis policy-boundary-check --root src --repo-root .`, `uv run legis governance-gate`. +5. **Cross-transport schema agreement:** one captured MCP result (the Task 3 golden), one HTTP body, + and one CLI stdout each validate against `contracts/governance_read.v1.schema.json` (with the + format checker). + +**Definition of Done:** +- [ ] Advisory-boundary tests green; the three new service fns pinned in the structural test. +- [ ] All coverage floors hold (corrected: service 92 / mcp 80 / api 88); all CI gates green locally. +- [ ] One captured output per transport validates against the frozen v1 schema. + +--- + +## Execution notes for the controller + +- **Scope gate (review must-fix #4):** the owner directed parallel execution (warpline is building + its side now), so cleared-only v1 proceeds. It is a SAFE SUBSET — a future `governance_read.v2` + that ADDS dispositions/kinds leaves every v1 record valid. The cleared-only scope is flagged to + warpline in the prompt for pre-finalize confirmation; do NOT expand scope here. If warpline needs + in-flight governance, that is a `.v2` + a new plan, never a v1 edit. +- **Schema amendment (must-fix #5):** already applied — the committed schema enforces the + discriminated union, and the warpline prompt carries the same tightened bytes + a + backward-compatible note. No further coordination needed unless warpline reports a consumer break + (it should not — the tightening only constrains legis's own output). +- Order is strict: Task 1 (contract) → Task 2 (service) → Tasks 3/4/5 (adapters, each depends on + Task 2) → Task 6. Within each task: TDD (red → green → commit). +- Local merge only; do NOT push or open a PR (owner-gated, like the prior three findings). diff --git a/docs/product/current-state.md b/docs/product/current-state.md index 869630a..0d25529 100644 --- a/docs/product/current-state.md +++ b/docs/product/current-state.md @@ -1,22 +1,26 @@ -# Current State — Legis Checkpoint: 2026-06-25 (2nd) · committed (PDR-0004) +# Current State — Legis Checkpoint: 2026-06-28 · committed (PDR-0007, PDR-0008) ## The bet right now -**Keep the governance-honesty surface true post-gold** (north-star: open governance-honesty defects → 0, currently **3**) — close the three confirmed P2 findings. The **Warpline federation seam is COMPLETE** (Tasks 1–8 + spec fix), held on merge by the owner until warpline's own body of work lands. +**Keep the governance-honesty surface true post-gold** (north-star: open governance-honesty defects → **0** by 2026-07-15; currently **1**). The last confirmed P2 finding is **legis-0186c23a2c** (policy_boundary_check accepts roots outside the source root) — unplanned; closing it takes the north-star to 0. This session shipped two **federation seams** (seam-quality, NOT north-star items) and deployed the governance_read surface locally. -## In flight -- **Warpline interfaces** (legis-1734128d34) — **COMPLETE** on branch `warpline-interfaces` (`5a30cd8..1e21418`): advisory preflight consumer + `attestation_get` with the **forge-proof per-SEI attestation classifier** (Task 8, both kinds shipped) + byte-identical advisory-boundary spine. All CI gates green (pytest 1237, mypy clean, coverage 92.13%, ruff). **Held on merge** (owner: warpline mid-work, will push when done). Adversarial forge phase: zero forges admitted. -- **Governance-honesty P2 findings** — confirmed, ready, unclaimed: unverified posture tail (legis-476ab6f125); protected batch without HeadAnchor advance (legis-0c310712a7); policy_boundary_check accepts roots outside source root (legis-0186c23a2c). The north-star Now bet; untouched. +## In flight / not yet started +- **legis-0186c23a2c** (unbounded policy-boundary root) — the **last** north-star P2 finding; confirmed, unplanned. Closing it → north-star **0**. **Next session's primary candidate.** +- **G1 integration tail** (legis-9a47068338) — legis-side DONE; **warpline must wire `LegisGovernanceClient` + restart its MCP connection** to flip its legis member `disabled`→`clean`. Awaiting warpline's live confirmation (not legis-blocked). -## Open questions / blocked-on-owner -- **Merge / publish** `warpline-interfaces` — held by owner (warpline's body of work mid-flight; they push when done). This is the only remaining gate; nothing else blocks. -- **Warpline wire format** (§6) — inferred/TO-CONFIRM; ships shape-validating, degrades to `unavailable`. Gates real integration (confirmed when warpline lands), not unit work. -- **Inferred vision/metrics** — PDR-0001's reversal trigger fires on the owner's first review (the `(set)` time-to-close TARGET still needs an owner number). -- *(Resolved this session: Task 8 ratification → done (PDR-0004); spec §4.1 correction → done.)* +## Recently shipped this session (LOCAL main; NOT pushed) +- **`governance_read.v1`** (PDR-0007, legis-9a47068338) — per-SEI governance read legis publishes for warpline; cleared-only; CLI+MCP+HTTP + frozen discriminated-union contract; verified (1335 passed, **mutation-proven** false-green-free) + deployed to the global `legis` tool (1.3.0). Integration pending warpline. +- **Plainweave preflight consumer** (PDR-0008, parallel session `27f12da`) — legis reads Plainweave's `preflight_facts.v1` advisory/enrich-only; 1377 passed at HEAD. +- Follow-ups filed: **legis-a0e286f5aa** (MCP outputSchema looser than the frozen contract — Minor). -## Last checkpoint did -- **Ratified + implemented Task 8** — the forge-proof attestation classifier (both `operator_override` + `signoff_cleared`), via a ground→implement→adversarial-forge workflow; zero forges admitted, both positives admit (PDR-0004). -- Corrected design spec §4.1/§4.2/§7 (false unconditional fail-closed claim → conditional; confirmed discriminator). -- Independently re-ran the full CI gate (pytest 1237, mypy clean, coverage 92.13%, ruff) — green. +## Open questions / blocked-on-owner (escalations) +- **⚠ Release (push + publish) — owner-gated; the one escalation this session.** Local `main` (HEAD `27f12da`) is now far ahead of origin: the 1.3.0 line + 3 prior fixes + warpline-preflight + **G1 governance_read.v1** + **Plainweave consumer**. Direct push is **ruleset-blocked** (PR required). Decide: **(a) SCOPE** — what ships next (fold G1 + Plainweave into 1.3.0, or cut a 1.4.0)? **(b) MECHANISM** — shall I push a branch + open the PR, and who cuts the GitHub Release / PyPI publish? **Nothing is pushed; `governance_read.v1` stays a LOCAL freeze until this lands** (keeps the v1 contract revisable if warpline needs a change before publish). +- **warpline handshake — live confirmation pending** (the G1 integration half; sibling-side). +- **Plainweave live e2e capture** — the parallel session's golden is CONSTRUCTED, not live-captured (hub MCP misroutes Plainweave); a flagged follow-up. -## Next session, start here -Either the owner's merge sign-off when warpline's work lands (the branch is complete and green), **or** pick up the north-star Now bet — the three P2 governance-honesty findings (legis-476ab6f125, -0c310712a7, -0186c23a2c), still unclaimed. +## What this checkpoint did +- Recorded **PDR-0007** (build `governance_read.v1` — cleared-only per-SEI federation read, owner-directed) and **PDR-0008** (record the parallel session's Plainweave advisory consumer under [[0003-federation-read-doctrine]]/[[0006-warpline-preflight-conform-to-extant-mcp-envelope]] doctrine). +- Refreshed `metrics.md` (north-star unchanged at 1; advisory boundary re-proven ×2; the new federation surface **mutation-proven** false-green-free; CI green, 1377 passed @ HEAD) and `roadmap.md` (two seams → recently-shipped). +- Reconciled the tracker: filed **legis-9a47068338** (G1 feature) + **legis-a0e286f5aa** (outputSchema follow-up). + +## Next session starts here +**Plan legis-0186c23a2c** (the last north-star finding) via the `/axiom-planning` → review → execute path — closing it takes the north-star to **0**. OR, if the owner is ready: the **release decision** (scope + PR mechanism) for the now-substantial unpushed local `main`. The warpline + Plainweave integration tails are sibling-side, not legis-blocked. diff --git a/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md b/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md new file mode 100644 index 0000000..edb7623 --- /dev/null +++ b/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md @@ -0,0 +1,27 @@ +# PDR-0005 — Accept the governance-honesty bet (partial): 2 of 3 P2 findings closed; north-star 3 → 1 + +Date: 2026-06-27 Status: accepted (within grant; merges local-only, push/publish owner-gated) Author: claude (opus, product-owner) +Supersedes: — Related: PRD-0005; tracker legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c; plans `docs/plans/2026-06-26-posture-read-floor-verify-integrity.md`, `docs/plans/2026-06-26-protected-batch-headanchor-transaction.md` + +## Context +PRD-0005 (the Now bet) is the north-star: open governance-honesty defects → 0 by 2026-07-15, over three codex-confirmed P2 findings. This session executed the first two against a governance-honesty surface whose cardinal sin is a false-green. + +## Options considered +1. Plan → execute each finding directly (single-pass). +2. Plan → multi-agent adversarial review → revise → re-review → subagent-driven TDD (fresh implementer + spec/quality review per task) → final whole-branch review → accept. Higher assurance. +3. Defer to a later session. + +## The call +Option 2, for both findings. +- **legis-476ab6f125** (unverified posture tail): `read_floor()` now gates on `verify_integrity()` and fails closed to `None`→`structured`; the recomputed-chain forgery is pinned as the conceded raw-file-write residual (README.md:137), not implied-closed. **Closed @ main eb28e4b.** The round-1 review caught two real defects in the plan before any code: a test the gate breaks (whose mis-prescribed remedy could have nudged an implementer to *weaken the gate*), and a false "doctor backstops this residual" docstring claim. Both fixed pre-execution. +- **legis-0c310712a7** (un-anchored protected batch): `ProtectedGate.transaction()` (a verbatim `SignoffGate.transaction()` mirror) advances the HeadAnchor after commit; doubly-latent/preventive (production wires `anchor=None` and no caller batches protected appends today). **Closed @ main 79b4008.** Review APPROVED_WITH_WARNINGS; clean execution. + +North-star **3 → 1**. legis-0186c23a2c (unbounded policy-boundary root) remains. Two P3 follow-ups filed so the closures are honest, not net-zero: legis-fcd59caa67 (ungated sibling reads `epoch_reset_unacknowledged` et al.) and legis-dfdeade118 (doctor `None`-cause diagnostic). + +## Rationale +The adversarial review caught two false-green-adjacent defects on read_floor *before code was written* — exactly the failure mode the honesty surface exists to prevent — validating the heavier method for this bet. Both fixes preserve fail-closed and pass every CI gate (mypy/ruff/coverage floors/governance-gate/policy-boundary/SEI oracle). Median time-to-close = 6 days (within the owner-set ≤14). + +## Reversal trigger +- If legis-476ab6f125 or legis-0c310712a7 **reopens** (a regression or a missed bypass) → reopen + a PDR-0001 reversal-trigger review. +- If **2026-07-15 passes with legis-0186c23a2c still open** → the north-star date target is missed → PDR-0001 reversal review (re-date or re-scope). +- If a **new confirmed governance-honesty bypass lands before 2026-07-15** → the north-star denominator grows → re-assess the date (PRD-0005 open-question). diff --git a/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md b/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md new file mode 100644 index 0000000..96d1aaa --- /dev/null +++ b/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md @@ -0,0 +1,23 @@ +# PDR-0006 — Warpline preflight: conform to warpline's extant MCP envelope (transport = MCP); reverse the producer-obligation; reverify kept droppable + +Date: 2026-06-27 Status: accepted (transport owner-confirmed 2026-06-26; merge local-only, push/1.3.0 publish owner-gated) Author: claude (opus, product-owner) +Supersedes: — Related: tracker legis-a53d92507d; plan `docs/plans/2026-06-26-warpline-preflight-mcp-transport.md`; hub interface-lock SEAM 4 §4A / GV-LG-1 / GV-LG-3; [[0003-federation-read-doctrine]] + +## Context +The warpline maintainer found that legis's warpline preflight client spoke a **phantom HTTP wire** (`GET /api/impact-radius`) warpline never served — it had been copied from legis's loomweave HTTP-client pattern; warpline is MCP/CLI-only. Worse, a 1.3.0-prep commit (6f50a33, on local main) **froze the wrong flat shape** as a golden conformance vector and recorded a "WARPLINE PRODUCER-SIDE OBLIGATION" demanding warpline build an HTTP producer to match legis's flat assumption — the inverse of the real contract. The seam failed safe (→ `unavailable`) but was dead-on-arrival, and the golden asserted a wire warpline doesn't serve. + +## Options considered +1. **Keep the flat HTTP shape; warpline ships an HTTP producer** (last night's producer-obligation). REJECTED — forces a sibling to reimplement something already extant; inverts SEAM 4 §4A (which pins the seam to `warpline_impact_radius_get` — the envelope) and GV-LG-3 (legis reads `meta.local_only`/`peer_side_effects` off the envelope; the flat shape has no `meta`). +2. **Transport = CLI subprocess** (`warpline … --json`). Workable, but the CLI verbs lack `--rev-range`, so legis must reconstruct the rev_range→affected resolution via a two-step — and the friction tempts a "warpline, add --rev-range" ask, a softer version of the same mistake. +3. **Transport = MCP stdio** — consume the EXTANT `warpline_impact_radius_get` / `warpline_reverify_worklist_get` tools with `rev_range`; one call per verb; the seam-lock literally references the MCP tool. + +## The call +**Option 3** — owner-confirmed 2026-06-26, *reversing an initial CLI pick* after the owner's challenge ("didn't warpline tell us the standard? aren't we forcing them to reimplement something already extant?"). A stdlib stdio JSON-RPC client (no new dependency) over an injectable `invoke` seam; parses the real `warpline.impact_radius.v1`/`.reverify_worklist.v1` envelope (pass-through), enforces GV-LG-3 `meta` + `data.completeness`, fails closed on every fault; **real live-captured fixtures** (non-circular oracle, committed session transcripts); the producer-obligation **reversed**. Shipped to local main @ **075edd0**. Validated by a 7-agent review (round 1 CHANGES_REQUESTED — 2 verification-layer blockers; round 2 APPROVED_WITH_WARNINGS) + a final opus whole-branch review. The live-capture DoD gate caught warpline's *undocumented required* `repo` argument. + +## Rationale +Legis conforms to what warpline already serves; warpline reimplements nothing. This is the durable federation principle, a sibling of [[0003-federation-read-doctrine]]'s facts-not-verdicts: **consume the sibling's extant standard; never force a sibling to reshape to a consumer's convenience.** The advisory boundary is preserved (byte-identity + the derived structural test); this is a **federation-seam-quality** bet, NOT a north-star governance-honesty item (it fails safe — governance is byte-identical with/without warpline). + +## Reversal trigger +- If **wardline rules (SEAM 4 §2A)** that legis must not consume `reverify_worklist` (filigree is the lock's named reverify consumer) → **drop the reverify half** (the client method + `service/preflight.py:28`); the client is structured for a clean removal. If wardline blesses it as a new seam → a new PDR records the sanctioned dependency. +- If the **advisory-boundary byte-identity invariant** ever fails (a governance verdict diverges with warpline present vs absent) → reopen immediately (the guardrail). +- If warpline's **MCP surface changes** (envelope schema / required args) → the client's parse/args layer reopens (it degrades to `unavailable` until then). diff --git a/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md b/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md new file mode 100644 index 0000000..e86281b --- /dev/null +++ b/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md @@ -0,0 +1,27 @@ +# PDR-0007 — Build `governance_read.v1`: a per-SEI governance read legis publishes for warpline (cleared-only v1) + +Date: 2026-06-27 Status: accepted (owner-directed: build, parallel-execute with warpline, deploy locally; PUBLISH/push owner-gated) Author: claude (opus, product-owner) +Supersedes: — Related: tracker legis-a0e286f5aa (follow-up); plan `docs/plans/2026-06-27-governance-read-v1-per-sei.md`; contract `contracts/governance_read.v1.schema.json`; warpline prompt `docs/contracts/warpline-governance-read.v1-prompt.md`; hub SEAM 4 / GV-LG-1; [[0003-federation-read-doctrine]]; [[0004-ratify-implement-forge-proof-attestation-classifier]]; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]] + +## Context +The federation maintainer asked for a per-SEI governance read so warpline's `reverify_worklist(include_federation=True)` can enrich its worklist advisorily with legis governance facts (warpline→legis direction — the inverse of the PDR-0006 warpline-preflight seam). Legis is the governance authority; warpline echoes its facts as `enrichment.governance: present|absent|unavailable` and **never gates** on them. The closest existing surface is `read_sei_attestations` (the forge-proof per-SEI read behind MCP `attestation_get`, PDR-0004); legis had no *published contract artifact* and no CLI/HTTP exposure of a per-SEI governance read. + +## Options considered +1. **Greenfield governance read, new shape.** Rejected — legis already holds a forge-proof per-SEI read; a parallel implementation duplicates admission logic and adds a new forge surface. +2. **Broad scope (in-flight + uncleared governance: open sign-offs, BLOCKED verdicts).** Considered — richest context for "what must I re-verify." Rejected for v1: expands the forge surface and the shape warpline needs is enrichment-only; in-flight is a clean v2 extension. +3. **Cleared-only v1, projecting `read_sei_attestations` into a posture-record shape, published as `governance_read.v1` on CLI + MCP + HTTP.** CHOSEN. + +## The call +**Option 3.** `read_governance_for_sei` is a pure projection of the forge-proof admitted set (`operator_override` → `protected_override`; `signoff_cleared` → `operator_signoff`) into the frozen `governance_read.v1` shape — fail-closed (unverifiable → discriminated `unavailable`; tampered → loud raise; verified-but-none → `checked`/`[]`), a discriminated-union schema, a frozen-golden conformance oracle, on all three transports. **Scope confirmed cleared-only by warpline** (the coordination point): warpline accepts the disclosed caveat that a BLOCKED-awaiting-signoff entity reads as `absent`, and never renders `absent` as "ungoverned." Owner approved building it, directed parallel execution with warpline, and directed the local deploy of the build to the global `legis` tool. + +## Rationale +Legis is the governance authority, so it owns the contract; cleared-only is the strongest *honest* answer and a **safe subset** — a future `governance_read.v2` ADDS dispositions/kinds and leaves every v1 record valid. The projection reuses the forge-proof discriminator wholesale (no new admission path, no unsigned field), so the 1.2.0 forge-resistance invariant is inherited, not re-litigated. The advisory boundary is preserved (warpline never gates; GV-LG-1 stays asserted) — this is a **federation-seam-quality** bet, NOT a north-star governance-honesty item (it fails safe; legis governance is byte-identical with/without the consumer). Validated by a 7-agent ultracode plan review (CHANGES_REQUESTED → all 8 must-fixes folded in, including a **Critical CLI false-green**: the CLI verify path used `TrailVerifier.verify` which lacks the hash-chain contiguity walk), a serial subagent-TDD build, a final opus whole-branch review (READY_TO_MERGE), my independent gate run (1335 passed, coverage 92.39%, floors hold), and a **mutation-proof** that the chain-tamper guard is load-bearing (neutering `verify_integrity()` flipped the CLI to a `checked`/`[]` false-green; the test caught it). + +## Reversal trigger +- If warpline (or any consumer) needs **in-flight / uncleared** governance context (open sign-offs, BLOCKED verdicts) → `governance_read.v2` (ADD, never mutate v1) + a new PDR. (Disclosed caveat, not a defect.) +- If the **advisory-boundary byte-identity invariant** fails (a legis verdict diverges with the read present vs absent) → reopen immediately (the guardrail). +- If the v1 read is found to **leak an unsigned field or admit a forged/non-cleared record** → reopen (forge-resistance regression; the projection's whole safety rests on reading only admitted-attestation fields). +- If warpline's `LegisClient` Protocol / `enrichment.governance` shape changes such that cleared-only no longer maps → reconcile the contract before any v1 publish. + +## Status note +The **legis surface is done, verified, and deployed locally** (global `legis` tool rebuilt from local main → `governance_read` reachable on CLI+MCP). The **integration half** (warpline wires `LegisGovernanceClient`, restarts its MCP connection, flips its legis member `disabled`→`clean`) is **pending warpline's live confirmation** — G1 is legis-side-done, not integration-done. The contract is frozen **locally**; PUBLISH (push/PyPI) is owner-gated (see current-state escalation). diff --git a/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md b/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md new file mode 100644 index 0000000..ab68ab4 --- /dev/null +++ b/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md @@ -0,0 +1,23 @@ +# PDR-0008 — legis consumes Plainweave's preflight-facts producer as an advisory sibling + +Date: 2026-06-27 Status: accepted Author: claude (opus) — **executed in a parallel legis session** (commit `27f12da`, John Morrissey, 2026-06-27 22:08); recorded here for workspace continuity +Supersedes: — Related: commit `27f12da`; Plainweave producer `plainweave_preflight_facts_get` / envelope `weft.plainweave.preflight_facts.v1` (Plainweave ADR-006); GV-LG-3; [[0003-federation-read-doctrine]]; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]] + +## Context +Plainweave is a Weft sibling whose only implemented producer (`plainweave_preflight_facts_get`, envelope `weft.plainweave.preflight_facts.v1`) had **no sibling consumer** and had never been exercised end-to-end. A parallel legis session landed legis as a read-only **advisory consumer** of it, mirroring the existing warpline advisory-preflight read (PDR-0006) exactly. This is a *different* federation seam from PDR-0007's `governance_read.v1`: there legis is the **producer** (warpline consumes); here legis is the **consumer** (Plainweave produces). + +## The call (recorded, not re-decided) +This is an **application of accepted doctrine** ([[0003-federation-read-doctrine]] facts-not-verdicts; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]] consume-the-extant-standard), not a new bet — so it is recorded for continuity rather than re-litigated. What landed (`27f12da`): +- `legis/plainweave_preflight/client.py` — injectable `PlainweaveMcpClient` + `StdioMcpInvoke`; every contract fault fails **closed** → `PlainweaveError`. GV-LG-3 validated against Plainweave's *real* envelope shape (`data.authority_boundary.{local_only, live_peer_calls, governance_verdicts}` + mandatory `data.freshness`/`facts`) — Plainweave's `meta` carries no `local_only`/`peer_side_effects` (those are warpline's). +- `service/preflight.read_plainweave_preflight` — discriminated `checked`/`unavailable` sibling of `read_warpline_preflight`; None/fault → `unavailable` with a reason, NEVER `INTERNAL_ERROR`, NEVER empty-as-clean. +- `mcp.py` `plainweave_preflight_get` tool (separate advisory sibling, not merged with warpline's); `runtime.plainweave` from `PLAINWEAVE_MCP_CMD`, default None → `unavailable`; governance unaffected when absent/unconfigured. + +## Rationale +ADVISORY ONLY, enrich-only — never changes a legis policy/governance decision; the advisory boundary is the load-bearing invariant. Pinned by tests: a byte-identity test (a hostile Plainweave client cannot perturb a real verdict), a structural test (no verdict-path function references `runtime.plainweave`), a GV-LG-3 test (refuses any producer claiming `governance_verdicts`), and the honest-degrade path (absent → `unavailable`; `ok:false` → `unavailable`). Gates green at the commit: ruff, mypy, pytest **1377 passed**, per-package floors (`plainweave_preflight` 96.6%), SEI oracle, policy-boundary-check. + +## Reversal trigger +- If the **advisory-boundary byte-identity invariant** fails for the Plainweave read (a legis verdict diverges with Plainweave present vs absent) → reopen immediately. +- If Plainweave's `weft.plainweave.preflight_facts.v1` envelope changes (shape / authority_boundary fields) → the client's parse/boundary layer reopens (degrades to `unavailable` until then). + +## Status note +The conformance oracle is driven over a **CONSTRUCTED** golden (built from the producer contract), NOT a live capture — the hub session's MCP wiring misroutes Plainweave. **Live end-to-end capture is a flagged follow-up** (a legis-rooted session), tracked separately by the parallel session. This PDR records the decision so the next `RESUME` does not mistake `27f12da` for unexplained drift. diff --git a/docs/product/metrics.md b/docs/product/metrics.md index ae9cca2..e27f3b8 100644 --- a/docs/product/metrics.md +++ b/docs/product/metrics.md @@ -1,4 +1,4 @@ -# Metrics — Legis Last read: 2026-06-25 +# Metrics — Legis Last read: 2026-06-28 > Legis is a governance-_honesty_ tool, not an engagement product. Its north-star > is integrity of the honesty surface (no provable false-greens, no unverified @@ -9,19 +9,22 @@ ## North-star | Metric | Target (falsifiable) | Current | Read on | Trend | |--------|----------------------|---------|---------|-------| -| Open **confirmed governance-honesty defects** (security/governance findings that let the honesty surface be bypassed or trust be trusted unverified) | = 0 by 2026-07-15 | 3 (legis-476ab6f125, -0c310712a7, -0186c23a2c) | 2026-06-24 | → | +| Open **confirmed governance-honesty defects** (security/governance findings that let the honesty surface be bypassed or trust be trusted unverified) | = 0 by 2026-07-15 | **1, unchanged** (legis-0186c23a2c) — this session's federation work (PDR-0007 governance_read.v1, PDR-0008 Plainweave consumer) is seam-QUALITY, NOT P2 findings, so it does not move the denominator | 2026-06-28 | → | + +> Note (2026-06-26): closing legis-476ab6f125 surfaced two P3 follow-ups — legis-fcd59caa67 (ungated sibling reads `epoch_reset_unacknowledged` et al.) and legis-dfdeade118 (doctor `None`-cause diagnostic). These are NOT in the P2 north-star denominator (lower severity, partially mitigated / not a false-green) but are tracked so the closure is honest, not net-zero theatre. ## Input metrics (the levers that move the north-star) | Metric | Target | Current | Read on | |--------|--------|---------|---------| -| Confirmed P2 security findings remaining open | 0 by 2026-07-15 | 3 | 2026-06-24 | -| Median time-to-close on a confirmed security finding | ≤ 14 days `(set)` | unmeasured | 2026-06-24 | +| Confirmed P2 security findings remaining open | 0 by 2026-07-15 | 1 | 2026-06-26 | +| Median time-to-close on a confirmed security finding | ≤ 14 days (owner-set 2026-06-25) | 6 days (2 samples: legis-476ab6f125, -0c310712a7; both 2026-06-20→06-26) | 2026-06-26 | ## Guardrails (must NOT degrade) | Metric | Floor / ceiling | Current | Read on | |--------|-----------------|---------|---------| -| **Advisory-boundary invariant** — governance verdicts byte-identical with a sibling (Warpline) absent vs present | must hold (binary) | **holds — proven** by `tests/mcp/test_warpline_advisory_boundary.py` (byte-identical on real verdicts) + a derived structural guard over all tool handlers; warpline consumed only in its sibling tool | 2026-06-25 | -| CI green (tests + mypy) | 100% | `warpline-interfaces` branch (Tasks 1–8 complete): pytest 1237 passed, mypy clean (78 files), coverage 92.13% — CI-equivalent gate run locally; `main` green at 1.1.1 | 2026-06-25 | +| **Advisory-boundary invariant** — governance verdicts byte-identical with a sibling absent vs present | must hold (binary) | **holds — re-proven ×2 this session.** (a) `governance_read.v1` (PDR-0007) is a read with no enforcement path; its 3 service fns pinned non-verdict-path in `test_warpline_advisory_boundary.py`; warpline never gates (GV-LG-1). (b) The Plainweave consumer (PDR-0008) carries its own byte-identity + structural + GV-LG-3 tests. | 2026-06-28 | +| **New-federation-surface false-green resistance** — a new governance read never reads tamper/absence as a pass | must hold (binary) | **holds — MUTATION-PROVEN.** Neutering `verify_integrity()` in the `governance-read` CLI flipped a chain-tampered store to `{status:checked, records:[]}` exit 0 (the exact false-green); the regression test caught it. Both verify halves (chain + signatures) run on all 3 transports; tamper fails loud (HTTP 500 / MCP AUDIT_INTEGRITY_FAILURE / CLI nonzero). | 2026-06-28 | +| CI green (tests + mypy) | 100% | **`main` @ 27f12da green** — pytest **1377 passed** at HEAD (governance_read build independently verified at 1335 @ 395d7fc; Plainweave added ~42), mypy clean, ruff clean, coverage 92.39%+, all per-package floors hold (+ new `plainweave_preflight` 96.6%), governance-gate/policy-boundary/SEI-oracle pass | 2026-06-28 | | **Attestation classifier forge-resistance** — `attestation_get` admits no forged / non-human-cleared record | must hold (binary) | **holds** — adversarial forge phase (4 lenses, live-run probes) admitted **0** forges; admission gates on the signature marker + keys only on signed fields + integrity-bound sign-off join (PDR-0004) | 2026-06-25 | -| Release publish gated on **live Loomweave SEI conformance** | must pass before any PyPI publish | gate in place (PR #8 line) | 2026-06-24 | -| Test coverage vs configured floors (`scripts/check_coverage_floors.py`) | ≥ floors | **92.14% total** (floor 88); all per-package floors hold (mcp.py 92.4%, service/ 95.0%) — read on `warpline-interfaces` | 2026-06-25 | +| Release publish gated on **live Loomweave SEI conformance** | skip-not-fail in remote CI (owner Path B, 2026-06-25); gates publish only when `LOOMWEAVE_URL` is set | **skip-not-fail** restored (PR #18/#19) — not a hard publish blocker absent a live Loomweave; the gate fires only when `LOOMWEAVE_URL` is configured | 2026-06-27 | +| Test coverage vs configured floors (`scripts/check_coverage_floors.py`) | ≥ floors | **92.25% total** (floor 88); all 8 per-package floors hold (+ a new `warpline_preflight` floor 88, actual 91.9%) — read on `main` @ 075edd0 | 2026-06-27 | diff --git a/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md b/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md new file mode 100644 index 0000000..284317e --- /dev/null +++ b/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md @@ -0,0 +1,47 @@ +# PRD-0005 — Governance-honesty integrity, post-gold Status: ready-for-planning +Decision: PDR-0001 (north-star established this as the Now bet; the three items are codex-confirmed P2 defects, so the is-it-worth-solving call is moot — proven bugs against the gold honesty guarantee) Bet (roadmap.md): Now Target metric (metrics.md): Open confirmed governance-honesty defects + +## Problem +**Who:** the governed agents and human operators who rely on Legis's posture floor, protected-cell audit chain, and policy-boundary scan being *true*, plus the sibling tools (Warpline) about to consume its attestations. **Their pain:** three confirmed code paths let the honesty guarantee be bypassed — (1) the posture hot-read trusts an *unverified* ledger tail, so a raw-DB writer can force `floor="chill"` and the routing path believes it; (2) protected records committed inside an external batch leave the HeadAnchor at the pre-batch head, so truncation back to a stale anchor goes undetected; (3) `policy_boundary_check` scans attacker-supplied absolute roots *outside* the source boundary, leaking arbitrary tree contents/parse results. **Desired outcome:** each path authenticates its input or fails closed — the surface returns to Legis's defining property, "no provable false-green; an unauthorized change is always *detectable*." **Why now:** 1.2.0 just shipped; these are the *only* confirmed open defects against the gold-line honesty guarantee, and the north-star (open governance-honesty defects → 0) is gated entirely on them. + +## Success metric (the signal the bet paid off) +**Open confirmed governance-honesty defects** (metrics.md north-star): **BASELINE 3** (read 2026-06-24) → **TARGET 0**, by **2026-07-15**. Falsification: >0 confirmed honesty-bypass defects still open on 2026-07-15 → the bet missed. + +## Acceptance criteria (falsifiable) +1. **SUCCESS — legis-476ab6f125 (unverified posture tail) closed.** The hot keyless `read_floor()` verifies chain integrity (`verify_integrity()`) + tail-kind discipline and **fails closed** (returns `None` → `structured`) when integrity cannot be proven. Cryptographic `operator_sig` verification under the epoch key stays the operator-side `doctor` check — it needs the operator key the routing read deliberately must not hold (matching the existing keyless-read / key-holding-doctor split). A regression test that appends a raw-DB tail record `floor="chill"` **whose chain does not verify** shows the read fails closed (red before the fix, green after); a second test **documents** that a forgery which *recomputes* the keyless chain is the conceded raw-file-write residual (README §Known security limitations, README.md:137) — made visible in the suite, never silently implied-closed. Merged to main, CI green, by 2026-07-15. + *Reject branch:* `read_floor()` still returns a floor from an integrity-broken ledger → finding stays open, criterion unmet. +2. **SUCCESS — legis-0c310712a7 (un-anchored protected batch) closed.** A protected record committed inside an external `AuditStore.transaction()` advances the HeadAnchor after commit (or an owned protected-gate transaction API does). A regression test that batches a protected append then truncates to the pre-batch anchor shows truncation is **detected**. Merged, CI green, by 2026-07-15. + *Reject branch:* anchor still stale post-batch → unmet. +3. **SUCCESS — legis-0186c23a2c (unbounded policy-boundary root) closed.** `policy_boundary_check` normalizes roots and requires containment inside the configured source/repo boundary. A regression test passing an absolute root outside the boundary shows the scan is **refused** (not walked). Merged, CI green, by 2026-07-15. + *Reject branch:* out-of-boundary root still scanned → unmet. +4. **SUCCESS (aggregate) — north-star reads 0.** The metrics.md north-star reads **0** open confirmed governance-honesty defects on 2026-07-15. + *Reject branch:* >0 → bet rejected; open a follow-up PDR on the remainder. +5. **GUARDRAIL — no false-green / fail-closed preserved.** For each of the three paths, an adversarial test proves that absent / empty / malformed / unverifiable input resolves to **BLOCKED / fail-closed / refused — never a pass**. + *Reject branch:* any path reads an absent/unverifiable input as a pass → bet rejected even if 1–4 pass (the cardinal sin). +6. **GUARDRAIL — the 1.2.0 invariants stay green.** The advisory-boundary byte-identity test (`tests/mcp/test_warpline_advisory_boundary.py` + the derived structural handler guard) and the attestation forge-resistance test pass unchanged over the same window. + *Reject branch:* either regresses → bet rejected. +7. **GUARDRAIL — gates hold.** Coverage floors (global ≥88; `service/` ≥95; `mcp.py` ≥~92 via `scripts/check_coverage_floors.py`) and all CI gates (pytest, mypy, ruff E4/E7/E9/F, SEI oracle, `legis policy-boundary-check`, `legis governance-gate`) are green on the merge. + *Reject branch:* any gate red → not acceptable. + +## Non-goals (this bet) +- **Re-architecting** the posture, protected-cell, or policy subsystems — fix the bypass *in place*; structural rework is a separate bet. +- **Changing any federation contract** (SEI consume, git-rename provider, Filigree sign-off binding, Wardline routing, Warpline preflight/attestation) — those escalate to the owner. +- **New MCP tools** or surface additions; this bet hardens existing paths only. +- Making Legis **tamper-*proof*** — the conceded residual threats already in the README "Known security limitations" (raw-DB-write deletion/truncation beyond the opt-in mitigation) are out of scope; the honest claim stays "detectable," not "impossible." + +## Constraints & guardrails +- **Fail-closed is the contract:** every decision path must resolve an absent/empty/malformed/unverifiable input to BLOCKED/unavailable/refused, never to a pass (CLAUDE.md cardinal-sin rule). +- **Keys stay out of agent reach:** no fix may move governance state into editable TOML or caller-controlled fields. +- **`canonical.py` stays `ensure_ascii=False`** (byte-for-byte HMAC contract with Wardline) — no fix touches the signing canonicalization. +- **Cadence guardrail (metrics.md input metric):** median time-to-close on a confirmed security finding ≤ 14 days (owner-set 2026-06-25). A process bound, not a headline acceptance bar. + +## Open questions / assumptions +- **Assumes the three findings remain independent** (no shared fix surface). If closing one perturbs another's path (e.g., a shared store-transaction wrapper used by both posture and protected), sequencing changes — flag at planning. +- **Assumes "confirmed governance-honesty defect" = the codex-confirmed P2 set.** If a new honesty-bypass finding lands before 2026-07-15, the north-star denominator grows and the date target may need a PDR-0001 reversal-trigger review. +- **Assumes regression tests can simulate** the raw-DB-tail / stale-anchor / out-of-boundary attacker at unit level (per `tests/conftest.py` store isolation, no live keychain). If any proof requires the OS keychain it self-skips and drops to integration — name it in the plan. + +## Handoff +- **Top item → /axiom-planning:** **legis-476ab6f125** (unverified posture tail) — the most direct false-green on a hot read path; becomes the first executable, codebase-validated implementation plan. +- **Solution shape → /axiom-solution-architect:** the authenticate-vs-fail-closed *mechanism* per finding (e.g., does the posture read verify the full chain or a signed tail-anchor; does protected get an owned transaction API or an after-commit hook). The PRD names the boundary; the design picks the mechanism. +- **Sequencing / forecast → /axiom-program-management:** the three findings as a sequenced set against the 2026-07-15 north-star date; this PRD emits no dated commitment. +- **Tracker IDs:** legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 65afd84..f7832e9 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -1,12 +1,17 @@ -# Roadmap — Legis Updated: 2026-06-24 (PDR-0001) +# Roadmap — Legis Updated: 2026-06-28 (PDR-0007, PDR-0008) > Sequencing, WSJF / cost-of-delay, and dated forecasts are produced by > /axiom-program-management. This file records bets as INTENT, not a delivery > schedule. Do not compute WSJF here; hand the committed bet over for sequencing. ## Now (committed, in-flight) -- **Governance-honesty integrity, post-gold** — keep the surface that earns the gold line _true_: close the confirmed P2 codex-security findings that let the honesty surface be bypassed (unverified posture tail, un-anchored protected batch, unbounded policy-boundary root) · tracker: legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c · metric: north-star (open governance-honesty defects → 0) -- **Federation interface readiness — Warpline seam** — publish the advisory preflight consumer + per-SEI attestation read warpline requested, without ever letting advisory context reach a verdict · tracker: legis-1734128d34 · metric: guardrail (advisory-boundary invariant holds) +- **Governance-honesty integrity, post-gold** — keep the surface that earns the gold line _true_: close the confirmed P2 codex-security findings that let the honesty surface be bypassed. **2 of 3 done** (unverified posture tail @ eb28e4b, un-anchored protected batch @ 79b4008 — CLOSED); **remaining: unbounded policy-boundary root** · tracker: legis-0186c23a2c · metric: north-star (open governance-honesty defects → 0; now **1**) + +## Recently shipped (record, not in-flight) +- **`governance_read.v1` — per-SEI governance read legis publishes for warpline** (PDR-0007) — cleared-only v1 projecting the forge-proof attestation read into a published contract on CLI+MCP+HTTP; warpline consumes advisorily (never gates). **legis-side done + verified + deployed to the local global tool; integration pending warpline's live handshake.** Push/publish owner-gated · tracker: legis-a0e286f5aa (follow-up). +- **Plainweave preflight — advisory consumer** (PDR-0008, parallel session `27f12da`) — legis reads Plainweave's `weft.plainweave.preflight_facts.v1` producer enrich-only; first sibling consumer of that producer. Live e2e capture is a flagged follow-up. Push owner-gated. +- **Federation interface readiness — Warpline seam** (legis-1734128d34) — advisory preflight consumer + forge-proof per-SEI attestation read · **SHIPPED in 1.2.0** (PR #17; PDR-0002/0004). +- **Warpline preflight — conform to the extant MCP envelope** (legis-a53d92507d) — replaced the phantom-HTTP seam + mis-frozen golden with an MCP-stdio client over warpline's real envelope · **shipped to local main @ 075edd0** (push / 1.3.0 publish owner-gated; PDR-0006). ## Next (shaped, decreasing certainty) - **v2: unify keyed signing onto operator elevation sessions** — migrate protected-cell verdict + sign-off signing onto the elevation-session primitive shipped in the posture-ratchet line · tracker: legis-11b3a3dd14, legis-2d0537655d diff --git a/pyproject.toml b/pyproject.toml index 2d127b1..adc7fe4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "pytest>=8.0", "pytest-cov>=5.0", "httpx>=0.27", - "jsonschema>=4.21", + "jsonschema[format]>=4.21", "mypy>=1.19", "ruff>=0.8", "types-PyYAML>=6.0", diff --git a/scripts/check_coverage_floors.py b/scripts/check_coverage_floors.py index aa1a4f5..a99423d 100644 --- a/scripts/check_coverage_floors.py +++ b/scripts/check_coverage_floors.py @@ -33,6 +33,8 @@ "src/legis/api/": 88.0, # currently ~89.8 "src/legis/mcp.py": 80.0, # currently ~82 "src/legis/doctor.py": 88.0, # currently ~91 + "src/legis/warpline_preflight/": 88.0, # currently ~92 + "src/legis/plainweave_preflight/": 88.0, # advisory sibling consumer } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 1e7fa62..f7a2e44 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -125,7 +125,7 @@ const CAPABILITIES = [ - +

version snapshot v1.1.1 — the gold release line. Moving facts live in the repo.

@@ -145,11 +145,13 @@ const CAPABILITIES = [ state exists for that change? It owns the verdicts, the enforcement cells, the HMAC-signed protected records, and the SEI-keyed sign-off ledger.

- - Legis is a “forced me to do the right thing” discipline. Its worth is the - effort the threat model forces and the residual tiers it names honestly (raw DB-file write, - model-robustness, response-integrity-rests-on-TLS) — not a claim to withstand an attacker who - already holds those capabilities. The system is only as load-bearing as the effort put into it. + + Legis is deconfliction-first, not security — a “forced me to do the + right thing” discipline plus an attributable, tamper-evident record, never an + access-control gate, and low-security by design. Its worth is the effort the threat model forces and + the residual tiers it names honestly (raw DB-file write, model-robustness, + response-integrity-rests-on-TLS) — not a claim to withstand an attacker who already holds those + capabilities. Do not lean on it to keep anyone out, or for secure, regulated, or confidential data. diff --git a/src/legis/__init__.py b/src/legis/__init__.py index ef0fdfa..6648be9 100644 --- a/src/legis/__init__.py +++ b/src/legis/__init__.py @@ -1,3 +1,3 @@ """Legis — the git/CI + governance layer of the Weft suite.""" -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/legis/api/app.py b/src/legis/api/app.py index f739f35..be805ac 100644 --- a/src/legis/api/app.py +++ b/src/legis/api/app.py @@ -62,6 +62,8 @@ from legis.service.governance import compute_override_rate as _compute_override_rate from legis.service.governance import read_identity_gaps as _read_identity_gaps from legis.service.governance import read_lineage_integrity as _read_lineage_integrity +from legis.service.governance import governance_read_unavailable as _governance_read_unavailable +from legis.service.governance import read_governance_for_sei as _read_governance_for_sei from legis.service.governance import evaluate_policy as _evaluate_policy from legis.service.governance import request_signoff as _request_signoff from legis.service.governance import resolve_for_record as _resolve_for_record @@ -870,6 +872,17 @@ def identity_gaps() -> dict: def lineage_integrity() -> dict: return _read_lineage_integrity(identity, verified_governance_records) + @app.get("/governance/sei/{sei:path}/governance-read") + def governance_read(sei: str) -> dict: + # {sei:path} captures a SEI that may contain '/'. FAIL-CLOSED pre-gate mirrors the MCP + # tool; verified_governance_records() runs BOTH chain + signature checks and maps tamper + # to HTTP 500. Missing either object -> unavailable (discriminated), never checked/[]. + if protected_gate is None or trail_verifier is None: + return _governance_read_unavailable( + sei, "trail not signature-verifiable (no protected gate / verifier)" + ) + return _read_governance_for_sei(verified_governance_records(), sei) + # --- agent-programmable policy grammar (WP-4.1) --- @app.post("/policy/evaluate") diff --git a/src/legis/cli.py b/src/legis/cli.py index 3c7a642..995b84a 100644 --- a/src/legis/cli.py +++ b/src/legis/cli.py @@ -114,6 +114,15 @@ def build_parser() -> argparse.ArgumentParser: "--db", default=gov_db_default, help="Governance store URL (defaults to the server's governance store)", ) + gov_read = subparsers.add_parser( + "governance-read", + help="Read per-SEI verified governance clearances (governance_read.v1); output is always JSON", + ) + gov_read.add_argument("sei", help="Stable Entity Identity (SEI) to query") + gov_read.add_argument( + "--db", default=gov_db_default, + help="Governance store URL (defaults to the server's governance store)", + ) backfill = subparsers.add_parser( "sei-backfill", help="Resolve legacy locator-keyed governance records through Loomweave batch resolve", @@ -335,6 +344,74 @@ def _check_override_rate(db_url: str) -> int: return 1 if res.status is GateStatus.FAIL else 0 +def _governance_read(db_url: str, sei: str) -> int: + """Per-SEI verified governance clearances (governance_read.v1) for the CLI path. + + Both verification halves are mandatory (Constraint 1 / must-fix #1): + 1. ``store.verify_integrity()`` — the hash-chain / seq-contiguity / delete-reorder + half; catches a tampered non-protected record that ``TrailVerifier.verify`` + is silent about (it only verifies records it recognises as protected). + 2. ``read_governance_for_sei_gate`` — the signature half, through the service + gate (Constraint 6), exactly as ``_check_override_rate`` does it. + + Fail-closed paths: no key → unavailable; missing DB → unavailable; chain + tamper → exit 1; signature tamper → exit 1. No bare ``except``. + Output is always JSON (no ``--json`` flag needed / warning avoided). + """ + import os + + from legis.config import protected_policies + from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError + from legis.service.governance import governance_read_unavailable, read_governance_for_sei_gate + from legis.store.audit_store import AuditStore + + hmac_key = os.environ.get("LEGIS_HMAC_KEY") + if not hmac_key: + # No key → signatures are unverifiable from the CLI → unavailable, never + # a silent checked/[] that claims clearances were confirmed. + print(json.dumps(governance_read_unavailable( + sei, "trail not signature-verifiable (LEGIS_HMAC_KEY unset)"), sort_keys=True)) + return 0 + + missing_db = _missing_sqlite_db(db_url) + if missing_db is not None: + # Absent store on a READ = unavailable (NOT the override-rate CI + # PASS_WITH_NOTICE axis, and NOT an auto-created empty DB read as + # checked/[] — that would be a false-green on the "no governance yet" + # case, indistinguishable from "verified and found none"). + print(json.dumps(governance_read_unavailable( + sei, f"governance store not found: {missing_db}"), sort_keys=True)) + return 0 + + store = AuditStore(db_url) + # HALF 1: hash-chain / seq-contiguity / delete-reorder defence. + # Must run BEFORE read_all + the service gate (which only checks + # signatures, not the chain). A seq gap in non-protected records + # passes TrailVerifier.verify silently but fails here. + if not store.verify_integrity(): + print( + "Error: audit integrity failure: database hash chain verification failed", + file=sys.stderr, + ) + return 1 + + records = store.read_all() + try: + # HALF 2: signature verification, through the service gate (Constraint 6). + # TamperError is wrapped into AuditIntegrityError inside the gate. + envelope = read_governance_for_sei_gate( + records, sei, hmac_key=hmac_key, protected_policies=protected_policies() + ) + except (ProtectedKeyRequiredError, AuditIntegrityError) as exc: + # Always prefix "audit integrity" so tooling / tests can key on a single contract + # substring regardless of which half of the verification gate raised. + print(f"Error: audit integrity: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(envelope, sort_keys=True)) + return 0 + + def _run_doctor(args) -> int: from legis.doctor import run_doctor @@ -741,6 +818,9 @@ def main(argv: list[str] | None = None, *, run=uvicorn.run) -> int: if args.command in {"check-override-rate", "governance-gate"}: return _check_override_rate(args.db) + if args.command == "governance-read": + return _governance_read(args.db, args.sei) + if args.command == "sei-backfill": report = run_pre_sei_backfill( AuditStore(args.db), diff --git a/src/legis/doctor.py b/src/legis/doctor.py index b54a77b..c1fa8b6 100644 --- a/src/legis/doctor.py +++ b/src/legis/doctor.py @@ -727,7 +727,7 @@ def key_provider(fingerprint: str) -> str | None: message=( f"posture key epoch reset on {when} by {agent} — unacknowledged. The floor " f"remains {floor}; acknowledge the reset with a signed `legis posture set` " - "under the new key (doctor stays non-zero until then). [operator]" + "under the new key (doctor stays non-zero until then)." ), repairable=False, ) @@ -775,7 +775,7 @@ def key_provider(fingerprint: str) -> str | None: "warn", message=( "operator key present in LEGIS_OPERATOR_KEY (plaintext-in-env) — usable " - "but a residual: prefer the keychain/age backend. [operator]" + "but a residual: prefer the keychain/age backend." ), ) if key_provider(epoch_fp) is not None: @@ -785,7 +785,7 @@ def key_provider(fingerprint: str) -> str | None: "warn", message=( "LEGIS_OPERATOR_KEY is present but does not match the current key epoch; " - "another custody backend is reachable. [operator]" + "another custody backend is reachable." ), ) return DoctorCheck(cid, "ok", message="operator key reachable") @@ -795,8 +795,7 @@ def key_provider(fingerprint: str) -> str | None: "warn", message=( "LEGIS_OPERATOR_KEY is present but does not match the current key epoch — " - "`posture set` will refuse until a matching custody backend is available. " - "[operator]" + "`posture set` will refuse until a matching custody backend is available." ), ) return DoctorCheck( @@ -805,7 +804,7 @@ def key_provider(fingerprint: str) -> str | None: message=( "operator key not reachable in any backend — `posture set` will refuse; " "`legis posture rekey` to recover (mints a new epoch and preserves the " - "current floor). [operator]" + "current floor)." ), ) diff --git a/src/legis/enforcement/protected.py b/src/legis/enforcement/protected.py index e183d2c..bb1b723 100644 --- a/src/legis/enforcement/protected.py +++ b/src/legis/enforcement/protected.py @@ -12,6 +12,7 @@ import logging from collections.abc import Callable +from contextlib import contextmanager from dataclasses import dataclass from typing import Any @@ -284,10 +285,10 @@ def build(seq: int, _prev_hash: str) -> dict[str, Any]: return payload seq = self._store.append_signed(build) - # Never read the head mid-batch: it is a batch-forbidden fresh-connection - # read (Q-M5). The protected gate is not itself a batch owner, but it - # shares the governance store with the sign-off gate, so guard defensively - # — the next non-batch append re-advances the anchor (AUD-1 lag contract). + # Advance the anchor after the commit (AUD-1) — but never mid-batch: the + # head read is a fresh-connection read the batch forbids (Q-M5), and a + # per-append advance inside a batch is wasted anyway since only the final + # head matters. ``transaction()`` advances it once when the batch commits. if self._anchor is not None and not self._store.in_batch(): self._anchor.update(*self._store.get_latest_sequence_and_hash()) signature = captured["signature"] @@ -412,6 +413,29 @@ def operator_override( extensions=extensions, ) + @contextmanager + def transaction(self): + """Group this gate's protected appends into one all-or-nothing batch and + advance the anchor once after commit — parity with + ``SignoffGate.transaction()`` (signoff.py). + + The per-append anchor advance is deferred inside a batch (the head read + is batch-forbidden, Q-M5; see the ``in_batch()`` guard in + ``_record_signed``). Advance it once here after the batch commits and the + write lock is released. An exception inside the batch rolls back and + propagates before this runs, so the anchor never advances past a + rolled-back head (AUD-1: the anchor only ever lags, never overshoots). + + This method must be the OUTERMOST batch owner for its store; nesting it + inside another gate's transaction on the same thread raises RuntimeError + (fail-closed, the batch-forbidden read — inherited from the + ``SignoffGate`` contract). + """ + with self._store.transaction(): + yield + if self._anchor is not None: + self._anchor.update(*self._store.get_latest_sequence_and_hash()) + def records(self): """The governance trail this gate writes to — for verified reads.""" return self._store.read_all() diff --git a/src/legis/mcp.py b/src/legis/mcp.py index b70a271..91b8d3e 100644 --- a/src/legis/mcp.py +++ b/src/legis/mcp.py @@ -103,7 +103,9 @@ "policy_boundary_check", "posture_get", "warpline_preflight_get", + "plainweave_preflight_get", "attestation_get", + "governance_read", } ) _OVERRIDE_RATE_NOTE = "measures operator force-pasts; not movable by agent retries" @@ -179,6 +181,7 @@ class McpRuntime: posture_ledger: Any | None = None coached_engine: EnforcementEngine | None = None warpline: Any | None = None # advisory sibling; NEVER read by a verdict path + plainweave: Any | None = None # advisory sibling; NEVER read by a verdict path def _load_policy_cell_registry() -> PolicyCellRegistry: @@ -229,19 +232,45 @@ def build_runtime(agent_id: str) -> McpRuntime: filigree = HttpFiligreeClient(filigree_url) warpline = None - warpline_url = os.environ.get("WARPLINE_API_URL") - if warpline_url: - from legis.warpline_preflight.client import HttpWarplineClient, WarplineError - + warpline_cmd = os.environ.get("WARPLINE_MCP_CMD") + if warpline_cmd: + import shlex + from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError, WarplineMcpClient try: - warpline = HttpWarplineClient(warpline_url) - except WarplineError: + argv = shlex.split(warpline_cmd) + if not argv: + raise WarplineError("WARPLINE_MCP_CMD is blank") + from legis.config import project_root + warpline = WarplineMcpClient(invoke=StdioMcpInvoke(command=argv), repo=str(project_root())) + except (WarplineError, ValueError) as exc: logging.getLogger(__name__).warning( - "WARPLINE_API_URL is set but invalid; warpline advisory context " - "disabled (governance unaffected)." - ) + "WARPLINE_MCP_CMD is set but invalid (%s); warpline advisory context " + "disabled (governance unaffected).", exc) warpline = None + plainweave = None + plainweave_cmd = os.environ.get("PLAINWEAVE_MCP_CMD") + if plainweave_cmd: + import shlex + from legis.plainweave_preflight.client import ( + PlainweaveError, + PlainweaveMcpClient, + ) + from legis.plainweave_preflight.client import ( + StdioMcpInvoke as PlainweaveStdioMcpInvoke, + ) + try: + argv = shlex.split(plainweave_cmd) + if not argv: + raise PlainweaveError("PLAINWEAVE_MCP_CMD is blank") + from legis.config import project_root + plainweave = PlainweaveMcpClient(invoke=PlainweaveStdioMcpInvoke(command=argv), repo=str(project_root())) + except (PlainweaveError, ValueError) as exc: + logging.getLogger(__name__).warning( + "PLAINWEAVE_MCP_CMD is set but invalid (%s); plainweave advisory context " + "disabled (governance unaffected).", exc) + plainweave = None + protected_gate = None trail_verifier = None signoff_gate = None @@ -306,6 +335,7 @@ def build_runtime(agent_id: str) -> McpRuntime: # state (audit H6 / the no-local-state-on-init invariant). posture_ledger=PostureLedger(posture_db_url(), initialize=False), warpline=warpline, + plainweave=plainweave, ) @@ -913,6 +943,41 @@ def tool_definitions() -> list[dict[str, Any]]: ] ), }, + { + "name": "plainweave_preflight_get", + "description": ( + "ADVISORY preflight context from the plainweave sibling: scoped " + "requirement/intent facts (envelope weft.plainweave.preflight_facts.v1, " + "ADR-006) over base..head. Purely advisory — NEVER a governance " + "verdict (plainweave emits facts only; Legis owns policy/audit). " + "Discriminated: 'checked' carries the advisory facts envelope; " + "'unavailable' (client unconfigured, transport failure, error " + "envelope, or payload shape mismatch) carries reasons. Never read a " + "missing 'checked' as 'nothing impacted'." + ), + "inputSchema": _schema(["base"], {"base": string, "head": string}), + "outputSchema": _one_of( + [ + _schema( + ["status", "preflight_facts"], + { + "status": {"type": "string", "enum": ["checked"]}, + "preflight_facts": {"type": "object"}, + }, + ), + _schema( + ["status", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, { "name": "identity_gap_list", "description": ( @@ -1313,6 +1378,90 @@ def tool_definitions() -> list[dict[str, Any]]: ] ), }, + { + "name": "governance_read", + "description": ( + "Per-SEI VERIFIED governance CLEARANCE facts (governance_read.v1). " + "Advisory — never gate on this. records:[] under 'checked' = no " + "verified clearance for this SEI on the verified trail, NOT " + "'ungoverned'; 'unavailable' = trail not signature-verifiable, " + "NOT 'absent'. A tampered trail -> AUDIT_INTEGRITY_FAILURE. " + "Distinct from attestation_get: projects clearance_record shape " + "(disposition/posture/authority/as_of/reasons), not raw attestation " + "fields (kind/seq)." + ), + "inputSchema": _schema(["sei"], {"sei": string}), + "outputSchema": _one_of( + [ + # checked variant: verified trail read; records may be empty + _schema( + ["status", "sei", "records"], + { + "status": {"type": "string", "enum": ["checked"]}, + "sei": string, + "records": { + "type": "array", + "items": _schema( + [ + "sei", + "disposition", + "posture", + "authority", + "as_of", + "reasons", + "content_hash", + ], + { + "sei": string, + "disposition": { + "type": "string", + "enum": ["cleared"], + }, + "posture": { + "type": "string", + "enum": [ + "protected_override", + "operator_signoff", + ], + }, + "authority": { + "type": "string", + "enum": ["operator"], + }, + "as_of": string, + "reasons": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "operator_override", + "signoff_cleared", + ], + }, + }, + "content_hash": string, + }, + ), + }, + }, + ), + # unavailable variant: trail not signature-verifiable + _schema( + ["status", "sei", "records", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "sei": string, + "records": {"type": "array", "maxItems": 0}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, ] @@ -2286,6 +2435,18 @@ def _tool_warpline_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> d ) +def _tool_plainweave_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.preflight import read_plainweave_preflight + + return _tool_result( + read_plainweave_preflight( + runtime.plainweave, + base=_require(args, "base"), + head=args.get("head", "HEAD"), + ) + ) + + def _governance_trail_records(runtime: McpRuntime) -> list[Any]: """The verified governance trail the SEI lineage-honesty reads consume. @@ -2330,6 +2491,26 @@ def _tool_attestation_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str return _tool_result(read_sei_attestations(_governance_trail_records(runtime), sei)) +def _tool_governance_read(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.governance import governance_read_unavailable, read_governance_for_sei + + sei = _require(args, "sei") + # FAIL-CLOSED pre-gate (same invariant as attestation_get): a verifiable answer + # needs BOTH a protected gate AND a trail_verifier. Missing either -> unavailable + # (discriminated), never a silent checked/[] that reads as "no clearance". + if runtime.protected_gate is None or runtime.trail_verifier is None: + return _tool_result( + governance_read_unavailable( + sei, "trail not signature-verifiable (no protected gate / verifier)" + ) + ) + # _governance_trail_records runs verified_records, which runs BOTH + # verify_integrity (chain/delete-reorder-truncate) AND TrailVerifier.verify + # (signatures), raising AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on a + # tampered protected trail (loud, never a result). + return _tool_result(read_governance_for_sei(_governance_trail_records(runtime), sei)) + + def _tool_identity_gap_list(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: return _tool_result( read_identity_gaps(runtime.identity, lambda: _governance_trail_records(runtime)) @@ -2586,7 +2767,9 @@ def _tool_posture_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, An "policy_boundary_check": _tool_policy_boundary_check, "posture_get": _tool_posture_get, "warpline_preflight_get": _tool_warpline_preflight_get, + "plainweave_preflight_get": _tool_plainweave_preflight_get, "attestation_get": _tool_attestation_get, + "governance_read": _tool_governance_read, } diff --git a/src/legis/plainweave_preflight/__init__.py b/src/legis/plainweave_preflight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/legis/plainweave_preflight/client.py b/src/legis/plainweave_preflight/client.py new file mode 100644 index 0000000..26b3cd9 --- /dev/null +++ b/src/legis/plainweave_preflight/client.py @@ -0,0 +1,158 @@ +"""Plainweave preflight client — legis reads ADVISORY requirement/intent facts. + +Injectable ``invoke`` seam (a stdio JSON-RPC invoker for production). SECURITY: +Plainweave is PURELY ADVISORY. Nothing it returns may reach a governance verdict +path (policy_evaluate, the gates, sign-off, or the honesty reads). Governance +verdicts are byte-identical whether plainweave is available or not. Every +contract fault fails CLOSED → PlainweaveError. + +Plainweave's ONE implemented producer is ``plainweave_preflight_facts_get`` → +envelope ``weft.plainweave.preflight_facts.v1`` (ADR-006). Legis is a CONSUMER of +that frozen envelope: it validates the envelope shape (schema / ok / +data.authority_boundary / data.freshness / data.facts) and passes it through +verbatim — it does NOT interpret, re-shape, or act on the facts. Plainweave's +``meta`` carries no ``local_only``/``peer_side_effects`` (those are warpline's); +the boundary assertions live in ``data.authority_boundary`` instead +(``local_only`` / ``live_peer_calls`` / ``governance_verdicts``), so the GV-LG-3 +fail-closed gate validates THOSE fields. +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any, Callable, Protocol, runtime_checkable + +Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below) + +MAX_RESPONSE_BYTES = 1_000_000 + +PREFLIGHT_SCHEMA = "weft.plainweave.preflight_facts.v1" +PREFLIGHT_TOOL = "plainweave_preflight_facts_get" + + +class PlainweaveError(RuntimeError): + """A Plainweave call failed at the transport or contract layer.""" + + +@runtime_checkable +class PlainweaveClient(Protocol): + def preflight_facts(self, base: str, head: str) -> dict[str, Any]: ... + + +class PlainweaveMcpClient: + """Consume plainweave's EXTANT preflight-facts MCP tool (advisory preflight). + Pass the frozen ``weft.plainweave.preflight_facts.v1`` envelope through verbatim + (the bare-object MCP output schema makes pass-through lossless). Advisory-ONLY; + every contract fault fails CLOSED -> PlainweaveError.""" + + def __init__(self, *, invoke: "Invoke", repo: str) -> None: + self._invoke = invoke + self._repo = repo + + def preflight_facts(self, base: str, head: str) -> dict[str, Any]: + return self._call(PREFLIGHT_SCHEMA, PREFLIGHT_TOOL, base, head) + + def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]: + # The producer tool takes NO 'repo' arg (its signature is scope_kind/base/ + # head/requirement_ids/entity_refs/baseline_id); the "which plainweave" is + # implicit in what the launched MCP command roots on. A commit-range scope + # advertises base..head; the producer never resolves the live diff itself + # (it honestly reports freshness "partial"). Sending an unknown kwarg would + # be rejected by the producer, so the args are exactly scope_kind/base/head. + env = self._invoke(tool, {"scope_kind": "commit_range", "base": base, "head": head}) + if not isinstance(env, dict): + raise PlainweaveError(f"{tool} returned {type(env).__name__}, expected an envelope object") + if env.get("schema") != schema: + raise PlainweaveError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}") + if env.get("ok") is not True: # error envelope / degraded -> unavailable + raise PlainweaveError(f"{tool} envelope is not ok=true: {env.get('ok')!r}") + data = env.get("data") + if not isinstance(data, dict): + raise PlainweaveError(f"{tool} envelope data is {type(data).__name__}, expected an object") + boundary = data.get("authority_boundary") + if not isinstance(boundary, dict): # malformed boundary fails closed (GV-LG-3 input) + raise PlainweaveError( + f"{tool} data.authority_boundary is {type(boundary).__name__}, expected an object" + ) + if boundary.get("local_only") is not True: + raise PlainweaveError( + f"{tool} authority_boundary.local_only is not true: {boundary.get('local_only')!r}" + ) + if boundary.get("live_peer_calls") is not False: # a live peer call is a side effect (GV-LG-3) + raise PlainweaveError( + f"{tool} authority_boundary.live_peer_calls is not false: {boundary.get('live_peer_calls')!r}" + ) + if boundary.get("governance_verdicts") is not False: # this producer must NEVER claim a verdict (GV-LG-3) + raise PlainweaveError( + f"{tool} authority_boundary.governance_verdicts is not false: " + f"{boundary.get('governance_verdicts')!r}" + ) + if "freshness" not in data or "facts" not in data: # degraded -> unavailable, not bare empty 'checked' + raise PlainweaveError( + f"{tool} envelope data is missing the mandatory 'freshness'/'facts' fields" + ) + return env + + +def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict: + for line in stdout_text.splitlines(): + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError as exc: + raise PlainweaveError(f"plainweave-mcp emitted a non-JSON line: {exc}") from exc + if isinstance(msg, dict) and msg.get("id") == response_id: + if "error" in msg: + raise PlainweaveError(f"plainweave-mcp returned a JSON-RPC error: {msg['error']}") + result = msg.get("result") + if not isinstance(result, dict): + raise PlainweaveError(f"plainweave-mcp result is {type(result).__name__}, expected an object") + return result + raise PlainweaveError(f"plainweave-mcp produced no JSON-RPC response for id={response_id}") + + +class StdioMcpInvoke: + """Production Invoke: a stdio JSON-RPC call to plainweave-mcp. Fail-safe: EVERY + fault -> PlainweaveError. shell=False + list argv (arguments are JSON params, + never argv tokens); explicit command (absolute path recommended; empty + rejected); text=False byte-bounded stdout (post-capture); 10s timeout.""" + + def __init__(self, *, command: list[str], timeout: float = 10.0) -> None: + self._command = command + self._timeout = timeout + + def __call__(self, tool: str, arguments: dict) -> dict: + if not self._command: + raise PlainweaveError("plainweave-mcp command is empty (PLAINWEAVE_MCP_CMD blank?)") + msgs = ( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "legis", "version": "1"}}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}, + ) + stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8") + try: + proc = subprocess.run(self._command, input=stdin, capture_output=True, + timeout=self._timeout, shell=False, check=False, text=False) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + raise PlainweaveError(f"plainweave-mcp spawn/timeout failed: {exc}") from exc + if len(proc.stdout) > MAX_RESPONSE_BYTES: + raise PlainweaveError("plainweave-mcp response too large") + err = (proc.stderr or b"")[:400].decode("utf-8", "replace") + try: + result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2) + if result.get("isError"): + raise PlainweaveError(f"plainweave tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})") + sc = result.get("structuredContent") + if isinstance(sc, dict): + return sc + for block in result.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return json.loads(block["text"]) + raise PlainweaveError(f"plainweave tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})") + except PlainweaveError: + raise + except Exception as exc: # ANY parse fault fails closed + raise PlainweaveError(f"plainweave tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc diff --git a/src/legis/posture/ledger.py b/src/legis/posture/ledger.py index 87eb7a2..2efc7f4 100644 --- a/src/legis/posture/ledger.py +++ b/src/legis/posture/ledger.py @@ -11,11 +11,12 @@ ``None``; callers map that to the fail-closed ``structured`` default, NEVER ``chill``. Only an explicit ``GENESIS`` record makes ``chill`` the floor. * The current floor is the latest authoritative floor record's ``floor`` field - (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``), found by one descending - payload scan from the tail, never the O(N) ``read_all`` loop or a repeated - point-read loop over metadata. Metadata records such as - ``OPERATOR_SESSION_OPENED`` must not lower the effective floor, even if they - carry a stale ``floor`` field. + (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``). An O(N) keyless + ``verify_integrity`` walk GATES the read (fail-closed: a chain that does not + verify yields ``None`` -> ``structured``); the floor itself is then found by + one descending payload scan from the tail, never a repeated point-read loop + over metadata. Metadata records such as ``OPERATOR_SESSION_OPENED`` must not + lower the effective floor, even if they carry a stale ``floor`` field. """ from __future__ import annotations @@ -92,15 +93,50 @@ def __init__(self, url: str, *, initialize: bool = True) -> None: def read_floor(self) -> str | None: """The current floor (latest authoritative floor record), or ``None``. - Single descending table scan, never a ``read_all`` loop and never a - point-read loop over metadata tails. A missing DB file or an empty store - both report ``None`` (fail-closed: callers map ``None`` -> ``structured``). - Metadata records are skipped so an operator session record cannot lower - an already-raised floor by becoming the tail. + Fail-closed: the floor sets routing, so the ledger must first PROVE its + own integrity. ``verify_integrity()`` (an O(N) keyless chain re-hash) + gates the read; a chain that does not verify — a raw-write in-place edit, + reorder, or seq gap — yields ``None`` (callers map ``None`` -> the + fail-closed ``structured`` default), never the tampered floor. A missing + DB file or an empty store also report ``None`` (``verify_integrity`` is + True on an empty store; the table-absence check returns ``None`` below). + + The O(N) integrity walk runs on EVERY resolution (the floor is never + cached, design D2) and is deliberate: it is bounded by operator-action + volume (genesis/transition/rekey are operator-gated; session-open churn + is bounded by TTL expiry + human-in-the-loop enabling), immaterial at + posture-ledger scale on local SQLite, and an *unverified* hot read was + the whole bug. The two failure modes are both fail-closed but + asymmetric: a DETECTED tamper (a clean walk that does not verify) + resolves to ``None`` -> ``structured``; an I/O fault (locked/corrupt DB + raising ``OperationalError``) propagates as an exception and aborts the + read — neither is a pass. Do NOT wrap the gate in a try/except that + downgrades that raise to a permissive default. + + SCOPE (honesty): the chain is keyless SHA, so this proves integrity + + tail-kind but NOT operator authorization. A file-write attacker who + *recomputes* the keyless chain on a forged floor-lowering ``TRANSITION`` + passes this gate; on a non-rekeyed ledger that forgery is caught by + NEITHER this keyless hot read NOR ``doctor`` today — it is a PURE + conceded raw-file-write residual (README "Known security limitations", + README.md:137). ``doctor``'s keyed ``operator_sig`` verification + (``_transition_acknowledges``, doctor.py:649) covers ONLY the + ``KEY_RESET``-acknowledgment path (D6), not a ``TRANSITION`` on a ledger + with no reset to acknowledge. A general per-transition ``operator_sig`` + audit in ``doctor`` would close the residual operator-side, but that is + separate follow-up, not this change. See + ``test_read_floor_fails_closed_on_integrity_break`` and + ``test_read_floor_recomputed_chain_forgery_is_conceded_residual``. """ path = _sqlite_file(self._url) if path is not None and not path.exists(): return None + # Fail closed if the chain cannot prove integrity: a tampered/forged + # ledger must not be trusted to set the routing floor. verify_integrity() + # returns True on an empty store, so an absent/empty ledger still reads + # as None via the table-absence check below, not a spurious failure. + if not self.store.verify_integrity(): + return None self.store._assert_no_batch_in_progress("read_floor") with self.store._engine.begin() as conn: if not self.store._has_log_table(conn): diff --git a/src/legis/service/__init__.py b/src/legis/service/__init__.py index 883a87d..ac2e9a7 100644 --- a/src/legis/service/__init__.py +++ b/src/legis/service/__init__.py @@ -21,6 +21,9 @@ bind_signoff_issue, compute_override_rate, evaluate_policy, + governance_read_unavailable, + read_governance_for_sei, + read_governance_for_sei_gate, read_identity_gaps, read_lineage_integrity, read_sei_attestations, @@ -31,7 +34,7 @@ submit_protected_override, verified_records, ) -from legis.service.preflight import read_warpline_preflight +from legis.service.preflight import read_plainweave_preflight, read_warpline_preflight from legis.service.wardline import route_wardline_scan __all__ = [ @@ -47,6 +50,9 @@ "RequiredInput", "bind_signoff_issue", "compute_override_rate", + "governance_read_unavailable", + "read_governance_for_sei", + "read_governance_for_sei_gate", "read_identity_gaps", "read_lineage_integrity", "read_sei_attestations", @@ -58,6 +64,7 @@ "submit_operator_override", "submit_protected_override", "read_warpline_preflight", + "read_plainweave_preflight", "route_wardline_scan", "verified_records", ] diff --git a/src/legis/service/governance.py b/src/legis/service/governance.py index 380d471..ffa813a 100644 --- a/src/legis/service/governance.py +++ b/src/legis/service/governance.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import Any @@ -350,6 +351,96 @@ def read_sei_attestations(verified_runtime_records: list, sei: str) -> dict[str, return {"status": "checked", "sei": sei, "attestations": attestations} +_POSTURE_BY_KIND = { + "operator_override": "protected_override", # provable: protected_cell + OVERRIDDEN_BY_OPERATOR signed + "signoff_cleared": "operator_signoff", # provable: SIGNED_OFF + integrity-bound request +} +# Three coupled points: read_sei_attestations' admitted kinds, these map keys, and the schema's +# `posture` enum. A new clearance kind must update all three. reasons = clearance-kind code (WHAT +# happened); posture = provable mechanism (HOW) — distinct axes, 1:1 in v1. + + +def _is_rfc3339(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + return True + except ValueError: + return False + + +def read_governance_for_sei(verified_runtime_records: list, sei: str) -> dict[str, Any]: + """Per-SEI VERIFIED GOVERNANCE CLEARANCES as the ``governance_read.v1`` envelope. + + A pure PROJECTION of ``read_sei_attestations`` (the forge-proof admitted set) — adds NO admission + logic and reads NO unsigned field, inheriting the signature-coverage/asymmetric-error guarantees. + A clearance whose ``recorded_at`` is absent or non-RFC3339, or whose ``kind`` has no known + posture, is OMITTED (asymmetric-error: a missing clearance only wastes warpline work; a malformed + one is the unsafe direction). The caller owns the ``status:"unavailable"`` pre-gate via + ``governance_read_unavailable`` for the no-key / unverifiable-trail case. + """ + att = read_sei_attestations(verified_runtime_records, sei) + if att.get("status") != "checked": + # read_sei_attestations is contracted to return "checked" here (the handler owns the + # unavailable pre-gate). If that ever changes, fail loud rather than relabel it "checked". + raise AuditIntegrityError( + f"read_sei_attestations returned unexpected status {att.get('status')!r}" + ) + records: list[dict[str, Any]] = [] + for a in att["attestations"]: + posture = _POSTURE_BY_KIND.get(a["kind"]) + if posture is None: + continue # unknown kind -> omit, never fabricate a posture + if not _is_rfc3339(a.get("recorded_at")): + continue # missing / malformed timestamp -> omit, never ship as_of:null + records.append( + { + "sei": sei, + "disposition": "cleared", + "posture": posture, + "authority": "operator", + "as_of": a["recorded_at"], + "reasons": [a["kind"]], + "content_hash": a["content_hash"], + } + ) + return {"status": "checked", "sei": sei, "records": records} + + +def read_governance_for_sei_gate( + records: list, sei: str, *, hmac_key: str | None, protected_policies +) -> dict[str, Any]: + """Verified governance read for the CLI/batch path: detect protected -> require key -> verify + signatures (fail closed) -> project. Mirrors ``evaluate_override_rate_gate`` exactly, so the CLI + measures the same trust the HTTP/MCP paths do (Constraint 6). The store-level hash-chain check + (``verify_integrity``) is the CALLER's responsibility BEFORE this call (as in + ``_check_override_rate``) — both halves are mandatory (Constraint 1). + """ + protected_present = any( + _requires_protected_verification(r.payload, protected_policies) for r in records + ) + if protected_present and not hmac_key: + raise ProtectedKeyRequiredError( + "Protected audit records require LEGIS_HMAC_KEY for verification" + ) + if hmac_key: + verifier = TrailVerifier(hmac_key.encode("utf-8"), protected_policies) + try: + verifier.verify(records) + except TamperError as exc: + raise AuditIntegrityError( + f"Protected audit trail verification failed: {exc}" + ) from exc + return read_governance_for_sei(records, sei) + + +def governance_read_unavailable(sei: str, reason: str) -> dict[str, Any]: + """The shared ``governance_read.v1`` unavailable envelope (one shape across all 3 adapters). + NEVER a silent ``checked``/``[]`` — an unverifiable trail reads as "could not check" (GOV-2).""" + return {"status": "unavailable", "sei": sei, "records": [], "unavailable": [{"reason": reason}]} + + def _requires_protected_verification(payload: dict[str, Any], protected_policies) -> bool: """Gate-local protected-detection for the KEYLESS branch of the override-rate gate: would refusing to score this record be right because it genuinely needs diff --git a/src/legis/service/preflight.py b/src/legis/service/preflight.py index f92f511..9bca22b 100644 --- a/src/legis/service/preflight.py +++ b/src/legis/service/preflight.py @@ -1,11 +1,12 @@ -"""The warpline advisory preflight read — discriminated checked/unavailable. +"""The advisory preflight reads — discriminated checked/unavailable. -SECURITY: warpline is PURELY ADVISORY. This read is a SIBLING of the governance -honesty reads, never embedded in one; a failure here is contained as -``unavailable`` and never escapes as INTERNAL_ERROR, exactly as +SECURITY: warpline and plainweave are PURELY ADVISORY. Each read is a SIBLING of +the governance honesty reads, never embedded in one; a failure here is contained +as ``unavailable`` and never escapes as INTERNAL_ERROR, exactly as ``read_identity_gaps`` converts a ``LoomweaveError``. An unreachable/unconfigured -warpline → ``unavailable`` with a reason, never an empty affected-set that reads -as "nothing impacted". +sibling → ``unavailable`` with a reason, never an empty fact-set that reads as +"nothing impacted". The two reads are independent siblings — neither is merged +into the other, and neither perturbs a governance verdict. """ from __future__ import annotations @@ -36,3 +37,36 @@ def read_warpline_preflight( "impact_radius": impact, "reverify_worklist": worklist, } + + +def read_plainweave_preflight( + plainweave_client: Any | None, base: str, head: str +) -> dict[str, Any]: + """Read Plainweave's ADVISORY preflight facts (envelope + ``weft.plainweave.preflight_facts.v1``, ADR-006) over base..head. + + Mirrors ``read_warpline_preflight`` exactly: discriminated ``checked`` / + ``unavailable``. An unconfigured client or ANY contract fault (caught as + ``PlainweaveError`` — the transport converts every exception to it) degrades + to ``unavailable`` with a reason, NEVER an INTERNAL_ERROR and NEVER a silent + empty ``checked`` that reads as "nothing impacted". This is an advisory + SIBLING; it never changes a Legis policy/governance decision. + """ + from legis.plainweave_preflight.client import PlainweaveError + + if plainweave_client is None: + return { + "status": "unavailable", + "unavailable": [{"reason": "plainweave client not configured"}], + } + try: + facts = plainweave_client.preflight_facts(base, head) + except PlainweaveError as exc: + return { + "status": "unavailable", + "unavailable": [{"reason": f"plainweave check failed: {exc}"}], + } + return { + "status": "checked", + "preflight_facts": facts, + } diff --git a/src/legis/warpline_preflight/client.py b/src/legis/warpline_preflight/client.py index 04a0f2f..d535ca9 100644 --- a/src/legis/warpline_preflight/client.py +++ b/src/legis/warpline_preflight/client.py @@ -1,34 +1,25 @@ """Warpline preflight client — legis reads ADVISORY impact/reverify hints. -Stdlib ``urllib`` with an injectable ``fetch`` so tests run offline; no new -dependency. SECURITY: Warpline is PURELY ADVISORY. This client exposes only -read-only GETs; nothing it returns may reach a governance verdict path +Injectable ``invoke`` seam (MCP stdio invoker is Task 2). SECURITY: Warpline +is PURELY ADVISORY. Nothing it returns may reach a governance verdict path (policy_evaluate, the gates, sign-off, or the honesty reads). Governance -verdicts are byte-identical whether WARPLINE_API_URL is set or unset. +verdicts are byte-identical whether warpline is available or not. Every +contract fault fails CLOSED → WarplineError. """ from __future__ import annotations import json -import http.client -import ipaddress -import logging -import os -import urllib.error -import urllib.parse -import urllib.request +import subprocess from typing import Any, Callable, Protocol, runtime_checkable -Fetch = Callable[[str, str, "dict | None"], dict] +Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below) -logger = logging.getLogger(__name__) +MAX_RESPONSE_BYTES = 1_000_000 class WarplineError(RuntimeError): - """A Warpline call failed at the transport or decode layer.""" - - -MAX_RESPONSE_BYTES = 1_000_000 + """A Warpline call failed at the transport or contract layer.""" @runtime_checkable @@ -37,107 +28,103 @@ def impact_radius(self, base: str, head: str) -> dict[str, Any]: ... def reverify_worklist(self, base: str, head: str) -> dict[str, Any]: ... -def _urllib_fetch( - method: str, url: str, body: dict | None, headers: dict[str, str] | None = None -) -> dict: - data = json.dumps(body).encode("utf-8") if body is not None else None - req = urllib.request.Request(url, data=data, method=method) - if data is not None: - req.add_header("Content-Type", "application/json") - for name, value in (headers or {}).items(): - req.add_header(name, value) - try: - with _open_no_redirect(req) as resp: # noqa: S310 (trusted Warpline URL) - decoded = _decode_json_response(resp, f"{method} {url}") - except urllib.error.HTTPError as exc: - if 300 <= exc.code < 400: - raise WarplineError(f"{method} {url} redirect not allowed: {exc.code}") from exc - raise WarplineError(f"{method} {url} failed: {exc}") from exc - except (urllib.error.URLError, ValueError, OSError, http.client.HTTPException) as exc: - raise WarplineError(f"{method} {url} failed: {exc}") from exc - return _require_dict(decoded, f"{method} {url}") - - -class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override] - return None - - -def _open_no_redirect(req: urllib.request.Request) -> Any: - opener = urllib.request.build_opener(_NoRedirectHandler()) - return opener.open(req, timeout=10.0) - - -def _decode_json_response(resp: Any, context: str) -> Any: - headers = getattr(resp, "headers", {}) or {} - content_type = headers.get("Content-Type", "application/json") - if "json" not in content_type.lower(): - raise WarplineError(f"{context} returned non-JSON content type: {content_type}") - raw = resp.read(MAX_RESPONSE_BYTES + 1) - if len(raw) > MAX_RESPONSE_BYTES: - raise WarplineError(f"{context} response too large") - return json.loads(raw.decode("utf-8")) - - -def _require_dict(value: Any, context: str) -> dict[str, Any]: - if not isinstance(value, dict): - raise WarplineError(f"{context} returned {type(value).__name__}, expected object") - return value - - -def _is_loopback(host: str) -> bool: - if host == "localhost": - return True - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - return False - - -def _validate_base_url(base_url: str) -> str: - parsed = urllib.parse.urlparse(base_url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise WarplineError("Warpline base URL must be an http(s) URL with a host") - allow_insecure_remote = os.environ.get("LEGIS_ALLOW_INSECURE_REMOTE_HTTP") == "1" - if parsed.scheme == "http" and not _is_loopback(parsed.hostname): - if not allow_insecure_remote: - raise WarplineError("Warpline base URL must use HTTPS unless it is loopback") - # ID-SEI-1: plaintext to a remote Warpline. TLS is the only integrity - # control on responses (the request HMAC authenticates requests, not - # responses), so an on-path attacker can tamper with what legis reads - # back. Dev/loopback only; never production. - logger.warning( - "LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1 is permitting a plaintext HTTP " - "connection to non-loopback Warpline host %r; responses are forgeable " - "without TLS. Dev/loopback use only.", - parsed.hostname, - ) - return base_url.rstrip("/") +_IMPACT = ("warpline.impact_radius.v1", "warpline_impact_radius_get") +_REVERIFY = ("warpline.reverify_worklist.v1", "warpline_reverify_worklist_get") -class HttpWarplineClient: - def __init__( - self, - base_url: str, - *, - fetch: Fetch | None = None, - ) -> None: - self._base = _validate_base_url(base_url) - self._fetch = fetch if fetch is not None else self._transport_fetch +class WarplineMcpClient: + """Consume warpline's EXTANT MCP tools (advisory preflight). Pass the frozen + envelope through verbatim (the bare-object MCP output schema makes pass-through + lossless). Advisory-ONLY; every contract fault fails CLOSED -> WarplineError.""" - def _transport_fetch(self, method: str, url: str, body: dict | None) -> dict: - return _urllib_fetch(method, url, body, {}) + def __init__(self, *, invoke: "Invoke", repo: str) -> None: + self._invoke = invoke + self._repo = repo def impact_radius(self, base: str, head: str) -> dict[str, Any]: - q = urllib.parse.urlencode({"base": base, "head": head}) - return _require_dict( - self._fetch("GET", f"{self._base}/api/impact-radius?{q}", None), - "Warpline impact_radius", - ) + return self._call(*_IMPACT, base, head) def reverify_worklist(self, base: str, head: str) -> dict[str, Any]: - q = urllib.parse.urlencode({"base": base, "head": head}) - return _require_dict( - self._fetch("GET", f"{self._base}/api/reverify-worklist?{q}", None), - "Warpline reverify_worklist", + return self._call(*_REVERIFY, base, head) + + def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]: + env = self._invoke(tool, {"repo": self._repo, "rev_range": f"{base}..{head}"}) + if not isinstance(env, dict): + raise WarplineError(f"{tool} returned {type(env).__name__}, expected an envelope object") + if env.get("schema") != schema: + raise WarplineError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}") + if env.get("ok") is not True: + raise WarplineError(f"{tool} envelope is not ok=true: {env.get('ok')!r}") + meta = env.get("meta") + if not isinstance(meta, dict): # malformed meta fails closed (GV-LG-3 input) + raise WarplineError(f"{tool} envelope meta is {type(meta).__name__}, expected an object") + if meta.get("local_only") is not True: + raise WarplineError(f"{tool} meta.local_only is not true: {meta.get('local_only')!r}") + if meta.get("peer_side_effects"): + raise WarplineError(f"{tool} claims a peer side effect (GV-LG-3): {meta.get('peer_side_effects')!r}") + data = env.get("data") + if not isinstance(data, dict) or "completeness" not in data: # degraded -> unavailable, not bare empty 'checked' + raise WarplineError(f"{tool} envelope data is missing the mandatory 'completeness' field") + return env + + +def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict: + for line in stdout_text.splitlines(): + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError as exc: + raise WarplineError(f"warpline-mcp emitted a non-JSON line: {exc}") from exc + if isinstance(msg, dict) and msg.get("id") == response_id: + if "error" in msg: + raise WarplineError(f"warpline-mcp returned a JSON-RPC error: {msg['error']}") + result = msg.get("result") + if not isinstance(result, dict): + raise WarplineError(f"warpline-mcp result is {type(result).__name__}, expected an object") + return result + raise WarplineError(f"warpline-mcp produced no JSON-RPC response for id={response_id}") + + +class StdioMcpInvoke: + """Production Invoke: a stdio JSON-RPC call to warpline-mcp. Fail-safe: EVERY + fault -> WarplineError. shell=False + list argv (rev_range is a JSON param, never + an argv token); explicit command (absolute path recommended; empty rejected); + text=False byte-bounded stdout (post-capture; see the cap note); 10s timeout.""" + def __init__(self, *, command: list[str], timeout: float = 10.0) -> None: + self._command = command + self._timeout = timeout + + def __call__(self, tool: str, arguments: dict) -> dict: + if not self._command: + raise WarplineError("warpline-mcp command is empty (WARPLINE_MCP_CMD blank?)") + msgs = ( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "legis", "version": "1"}}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}, ) + stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8") + try: + proc = subprocess.run(self._command, input=stdin, capture_output=True, + timeout=self._timeout, shell=False, check=False, text=False) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + raise WarplineError(f"warpline-mcp spawn/timeout failed: {exc}") from exc + if len(proc.stdout) > MAX_RESPONSE_BYTES: + raise WarplineError("warpline-mcp response too large") + err = (proc.stderr or b"")[:400].decode("utf-8", "replace") + try: + result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2) + if result.get("isError"): + raise WarplineError(f"warpline tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})") + sc = result.get("structuredContent") + if isinstance(sc, dict): + return sc + for block in result.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return json.loads(block["text"]) + raise WarplineError(f"warpline tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})") + except WarplineError: + raise + except Exception as exc: # ANY parse fault fails closed + raise WarplineError(f"warpline tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc diff --git a/tests/api/test_governance_read_route.py b/tests/api/test_governance_read_route.py new file mode 100644 index 0000000..7ea88f3 --- /dev/null +++ b/tests/api/test_governance_read_route.py @@ -0,0 +1,207 @@ +"""HTTP route ``GET /governance/sei/{sei:path}/governance-read`` tests. + +TDD for Task 4. Mirrors the MCP governance_read tool tests (test_governance_read_tool.py) +and the identity-gap/lineage-integrity route tests (test_sei_api.py). + +Cases: + (a) wired gate+verifier + a clearance -> 200 {status:checked, records:[…]} + (b) no gate/verifier -> 200 {status:unavailable, …} + (c) tampered trail -> 500 + (d) a SEI containing '/' round-trips correctly ({sei:path} capture) + +FAIL-CLOSED invariant: verified_governance_records() runs BOTH chain + signature +checks and maps tamper to HTTP 500; absent gate/verifier yields discriminated +``unavailable``, NEVER a silent ``checked``/``[]``. +""" +from __future__ import annotations + +import hashlib +import json + +import pytest +from fastapi.testclient import TestClient + +from legis.api.app import create_app +from legis.canonical import canonical_json, content_hash +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.identity.entity_key import EntityKey +from legis.policy.cells import PolicyCellRegistry, PolicyCellRule +from legis.posture.ledger import PostureLedger +from legis.store.audit_store import GENESIS, AuditStore, _chain + +pytestmark = pytest.mark.usefixtures("unsafe_dev_auth") + +_CLOCK_ISO = "2026-06-02T12:00:00+00:00" +_KEY = b"route-test-key" +_POLICY = "protected.governance" +_SEI = "loomweave:eid:governance-route-test" +_PROTECTED = frozenset({_POLICY}) + + +class _AdvisoryJudge: + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only") + + +def _genesis_ledger(tmp_path): + url = f"sqlite:///{tmp_path / 'posture.db'}" + ledger = PostureLedger(url, initialize=True) + fp = hashlib.sha256(b"k" * 32).hexdigest() + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + return ledger + + +def _registry(): + return PolicyCellRegistry( + default_cell="chill", + rules=(PolicyCellRule(pattern=_POLICY, cell="protected"),), + ) + + +def _simple_app(tmp_path): + """App WITHOUT protected_gate / trail_verifier — triggers the unavailable pre-gate.""" + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + eng = EnforcementEngine(store, FixedClock(_CLOCK_ISO)) + app = create_app( + enforcement=eng, + cell_registry=PolicyCellRegistry(default_cell="chill"), + posture_ledger=_genesis_ledger(tmp_path), + ) + return TestClient(app) + + +def _wired_app_with_clearance(tmp_path): + """App with BOTH gate + verifier wired, and a genuine operator_override clearance + for _SEI already written to the shared store (mirrors _wired_runtime_with_clearance + from test_governance_read_tool.py).""" + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + clock = FixedClock(_CLOCK_ISO) + + # Write a genuine OVERRIDDEN_BY_OPERATOR record directly (mirrors MCP fixture) + pg_write = ProtectedGate( + store, + clock, + judge=_AdvisoryJudge(), + key=_KEY, + protected_policies=_PROTECTED, + ) + pg_write.operator_override( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="approved", + operator_id="op-sec", + file_fingerprint="sha256:abc", + ast_path="Module/Call", + extensions={"loomweave": {"content_hash": "blake3:clearance-override"}}, + ) + + # Build the read-side app using the SAME store (so it sees the written record) + pg_read = ProtectedGate( + store, + clock, + judge=_AdvisoryJudge(), + key=_KEY, + protected_policies=_PROTECTED, + ) + app = create_app( + protected_gate=pg_read, + trail_verifier=TrailVerifier(_KEY, _PROTECTED), + cell_registry=_registry(), + posture_ledger=_genesis_ledger(tmp_path), + ) + return TestClient(app), store + + +# --- (a) verified trail with clearance -> 200 {status:checked, records:[…]} --- + +def test_governance_read_checked_with_clearance(tmp_path): + """Wired gate + verifier + a genuine clearance -> 200 checked with one record.""" + client, _store = _wired_app_with_clearance(tmp_path) + resp = client.get(f"/governance/sei/{_SEI}/governance-read") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "checked" + assert body["sei"] == _SEI + assert len(body["records"]) == 1 + rec = body["records"][0] + assert rec["disposition"] == "cleared" + assert rec["posture"] == "protected_override" + assert rec["authority"] == "operator" + assert rec["sei"] == _SEI + assert rec["reasons"] == ["operator_override"] + assert rec["content_hash"] == "blake3:clearance-override" + assert rec["as_of"] == _CLOCK_ISO + + +# --- (b) no gate/verifier -> 200 {status:unavailable, …} --- + +def test_governance_read_unavailable_when_no_gate(tmp_path): + """No protected_gate wired -> 200 unavailable (discriminated), NEVER checked/[].""" + client = _simple_app(tmp_path) + resp = client.get(f"/governance/sei/{_SEI}/governance-read") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "unavailable" + assert body["sei"] == _SEI + assert body["records"] == [] + assert body.get("unavailable") and "reason" in body["unavailable"][0] + + +# --- (c) tampered trail -> 500 --- + +def test_governance_read_500_on_tamper(tmp_path): + """A signature-tampered trail must propagate as HTTP 500 (fail closed).""" + client, store = _wired_app_with_clearance(tmp_path) + + # Confirm the clean trail reads 200 first + assert client.get(f"/governance/sei/{_SEI}/governance-read").status_code == 200 + + # Corrupt a signed field on the record (like judge_verdict) to break the + # HMAC signature — mirrors _tamper_first_record in test_complex_api.py. + import sqlite3 + + db = str(tmp_path / "gov.db") + con = sqlite3.connect(db) + con.execute("DROP TRIGGER IF EXISTS audit_log_no_update") + seq, payload = con.execute( + "SELECT seq, payload FROM audit_log ORDER BY seq ASC LIMIT 1" + ).fetchone() + p = json.loads(payload) + # Flip the signed judge_verdict field to break the HMAC signature + p.setdefault("extensions", {})["judge_verdict"] = "FORGED" + con.execute("UPDATE audit_log SET payload=? WHERE seq=?", (canonical_json(p), seq)) + # Re-chain so the unkeyed hash-chain check still passes (only HMAC is broken) + prev = GENESIS + for s, pl in con.execute( + "SELECT seq, payload FROM audit_log ORDER BY seq ASC" + ).fetchall(): + ch = content_hash(json.loads(pl)) + con.execute( + "UPDATE audit_log SET content_hash=?, prev_hash=?, chain_hash=? WHERE seq=?", + (ch, prev, _chain(prev, ch), s), + ) + prev = _chain(prev, ch) + con.commit() + con.close() + + # Now the HMAC signature check must fail -> AuditIntegrityError -> HTTP 500 + resp = client.get(f"/governance/sei/{_SEI}/governance-read") + assert resp.status_code == 500 + + +# --- (d) SEI containing '/' round-trips via {sei:path} capture --- + +def test_governance_read_slash_bearing_sei_round_trips(tmp_path): + """A SEI with embedded '/' is captured correctly by {sei:path} and echoed back.""" + slash_sei = "loomweave:eid:namespace/entity-id" + client = _simple_app(tmp_path) + # Use the unavailable (no-gate) path — it echoes sei without needing records. + resp = client.get(f"/governance/sei/{slash_sei}/governance-read") + assert resp.status_code == 200 + body = resp.json() + # The key invariant: {sei:path} round-tripped the slash-bearing SEI intact. + assert body["sei"] == slash_sei + assert body["status"] == "unavailable" diff --git a/tests/cli/test_governance_read_cli.py b/tests/cli/test_governance_read_cli.py new file mode 100644 index 0000000..95e661c --- /dev/null +++ b/tests/cli/test_governance_read_cli.py @@ -0,0 +1,209 @@ +"""Task 5 tests: legis governance-read CLI subcommand. + +TDD red→green. Cases: + (a) LEGIS_HMAC_KEY set + verifiable store with a clearance → exit 0, JSON {status:checked, records:[…]} + (b) NO LEGIS_HMAC_KEY → exit 0, JSON {status:unavailable,…} + (c) CHAIN-tampered store (delete a non-protected record, creating a seq gap) → + exit nonzero, "audit integrity" in stderr. + CRITICAL: this test must be RED if verify_integrity() is omitted. Only + verify_integrity() detects a seq gap; TrailVerifier.verify is a no-op + on non-protected records, so a CLI that skips verify_integrity() would + return exit 0 / {status:checked, records:[]} (false-green). + (d) Signature-tampered protected store (key mismatch) → exit nonzero, + "audit integrity" in stderr (same contract substring as all other tamper cases). + (e) Missing/relocated DB (key set) → exit 0, JSON {status:unavailable,…}, + NOT a silent {status:checked, records:[]} from an auto-created empty DB. +""" + +from __future__ import annotations + +import json +import sqlite3 + +from legis.cli import main +from legis.clock import FixedClock +from legis.enforcement.protected import ProtectedGate +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.identity.entity_key import EntityKey +from legis.store.audit_store import AuditStore + +_SEI = "loomweave:eid:cli-governance-read-test" +_CLOCK = FixedClock("2026-06-03T10:00:00+00:00") +_KEY = b"cli-governance-key" +_KEY_STR = "cli-governance-key" +_POLICY = "protected.cli-test" + + +class _AcceptJudge: + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(verdict=Verdict.ACCEPTED, model="judge@1", rationale="ok") + + +def _db_url(db_path) -> str: + return f"sqlite:///{db_path}" + + +def _write_clearance(db_path) -> None: + """Write a genuine protected operator_override clearance to the store.""" + store = AuditStore(_db_url(db_path)) + gate = ProtectedGate(store, _CLOCK, judge=_AcceptJudge(), key=_KEY) + gate.operator_override( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="operator clears", + operator_id="operator-1", + file_fingerprint="sha256:ff", + ast_path="Module.Call", + extensions={"loomweave": {"content_hash": "ch:test-content"}}, + ) + + +# --------------------------------------------------------------------------- # +# (a) LEGIS_HMAC_KEY set + verifiable store with clearance → checked # +# --------------------------------------------------------------------------- # + + +def test_cli_governance_read_checked_with_clearance(tmp_path, monkeypatch, capsys): + """Verifiable protected store + LEGIS_HMAC_KEY → exit 0, status:checked with records.""" + db_path = tmp_path / "gov.db" + _write_clearance(db_path) + monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR) + monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _POLICY) + + rc = main(["governance-read", "--db", _db_url(db_path), _SEI]) + assert rc == 0 + out, err = capsys.readouterr() + assert not err, f"unexpected stderr: {err!r}" + envelope = json.loads(out) + assert envelope["status"] == "checked" + assert envelope["sei"] == _SEI + assert len(envelope["records"]) == 1 + rec = envelope["records"][0] + assert rec["disposition"] == "cleared" + assert rec["posture"] == "protected_override" + assert rec["authority"] == "operator" + assert rec["content_hash"] == "ch:test-content" + assert rec["reasons"] == ["operator_override"] + assert rec["as_of"] == "2026-06-03T10:00:00+00:00" + + +# --------------------------------------------------------------------------- # +# (b) NO LEGIS_HMAC_KEY → unavailable (can't verify sigs) # +# --------------------------------------------------------------------------- # + + +def test_cli_governance_read_no_key_is_unavailable(tmp_path, monkeypatch, capsys): + """Without LEGIS_HMAC_KEY, signatures are unverifiable → unavailable (NOT silent checked/[]).""" + db_path = tmp_path / "gov.db" + _write_clearance(db_path) + monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) + + rc = main(["governance-read", "--db", _db_url(db_path), _SEI]) + assert rc == 0 + out, err = capsys.readouterr() + assert not err, f"unexpected stderr: {err!r}" + envelope = json.loads(out) + assert envelope["status"] == "unavailable" + assert envelope["sei"] == _SEI + assert envelope["records"] == [] + reasons = envelope.get("unavailable", []) + assert reasons and "reason" in reasons[0] + assert "LEGIS_HMAC_KEY" in reasons[0]["reason"] + + +# --------------------------------------------------------------------------- # +# (c) CHAIN-tampered store: seq gap in non-protected records → exit nonzero # +# CRITICAL: this test must be RED if verify_integrity() is omitted from the # +# CLI helper. TrailVerifier.verify is a no-op on non-protected records; # +# only verify_integrity() detects the seq contiguity gap. # +# --------------------------------------------------------------------------- # + + +def test_cli_governance_read_chain_tamper_exits_nonzero(tmp_path, monkeypatch, capsys): + """Seq-gap (non-protected records, trigger-bypassed delete) → exit 1, 'audit integrity' in stderr. + + If verify_integrity() is omitted, the CLI would return exit 0 / {status:checked, records:[]} + (false-green) because TrailVerifier.verify passes silently on non-protected records. + """ + db_path = tmp_path / "gov.db" + store = AuditStore(_db_url(db_path)) + # Plain (non-protected) records so TrailVerifier is a no-op on them. + store.append({"event": "PLAIN_1"}) + store.append({"event": "PLAIN_2"}) + store.append({"event": "PLAIN_3"}) + + # Bypass the append-only triggers and delete the middle record, creating + # a seq gap (seq: 1, 3) that verify_integrity() detects. + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("DROP TRIGGER IF EXISTS audit_log_no_delete") + conn.execute("DELETE FROM audit_log WHERE seq = 2") + conn.commit() + finally: + conn.close() + + monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR) + + rc = main(["governance-read", "--db", _db_url(db_path), _SEI]) + assert rc != 0 + out, err = capsys.readouterr() + # Must report the integrity failure on stderr with "audit integrity" + assert "audit integrity" in err.lower(), f"stderr was: {err!r}" + # Must NOT print a false-green JSON envelope on stdout + assert not out.strip() or "checked" not in out + + +# --------------------------------------------------------------------------- # +# (d) Signature-tampered protected store via key mismatch → exit nonzero # +# --------------------------------------------------------------------------- # + + +def test_cli_governance_read_sig_tamper_exits_nonzero(tmp_path, monkeypatch, capsys): + """Protected store signed with _KEY; CLI uses a different key. + + The SHA hash chain is consistent (verify_integrity passes); TrailVerifier with + the wrong key detects the HMAC mismatch → AuditIntegrityError → exit 1. + """ + db_path = tmp_path / "gov.db" + _write_clearance(db_path) # signed with _KEY = b"cli-governance-key" + + # Wrong key — signatures cannot verify + monkeypatch.setenv("LEGIS_HMAC_KEY", "wrong-key-does-not-match") + monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _POLICY) + + rc = main(["governance-read", "--db", _db_url(db_path), _SEI]) + assert rc != 0 + out, err = capsys.readouterr() + # All tamper cases (chain AND signature) must emit "audit integrity" on stderr — + # uniform contract substring used by tooling and tests across mcp.py / cli.py. + assert "audit integrity" in err.lower(), f"stderr was: {err!r}" + + +# --------------------------------------------------------------------------- # +# (e) Missing/relocated DB (with key set) → unavailable, NOT a false-green # +# --------------------------------------------------------------------------- # + + +def test_cli_governance_read_missing_db_is_unavailable(tmp_path, monkeypatch, capsys): + """Missing DB → {status:unavailable}, exit 0. + + Must NOT auto-create an empty DB and return {status:checked, records:[]}. + LEGIS_HMAC_KEY must be set so the no-key early-return (case b) does not + fire before the missing-DB check is reached. + """ + nonexistent = tmp_path / "not_there.db" + assert not nonexistent.exists() + monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR) + + rc = main(["governance-read", "--db", _db_url(nonexistent), _SEI]) + assert rc == 0 + out, err = capsys.readouterr() + assert not err, f"unexpected stderr: {err!r}" + envelope = json.loads(out) + assert envelope["status"] == "unavailable" + assert envelope["sei"] == _SEI + assert envelope["records"] == [] + reasons = envelope.get("unavailable", []) + assert reasons and "governance store not found" in reasons[0]["reason"] + # DB must not have been auto-created + assert not nonexistent.exists() diff --git a/tests/conformance/fixtures/legis-governance-read.golden.json b/tests/conformance/fixtures/legis-governance-read.golden.json new file mode 100644 index 0000000..898fd1a --- /dev/null +++ b/tests/conformance/fixtures/legis-governance-read.golden.json @@ -0,0 +1,28 @@ +{ + "records": [ + { + "as_of": "2026-06-02T12:00:00+00:00", + "authority": "operator", + "content_hash": "blake3:signoff-cleared", + "disposition": "cleared", + "posture": "operator_signoff", + "reasons": [ + "signoff_cleared" + ], + "sei": "loomweave:eid:0000000000000000000000000000aaaa" + }, + { + "as_of": "2026-06-02T12:00:00+00:00", + "authority": "operator", + "content_hash": "blake3:operator-override", + "disposition": "cleared", + "posture": "protected_override", + "reasons": [ + "operator_override" + ], + "sei": "loomweave:eid:0000000000000000000000000000aaaa" + } + ], + "sei": "loomweave:eid:0000000000000000000000000000aaaa", + "status": "checked" +} diff --git a/tests/conformance/test_governance_read_oracle.py b/tests/conformance/test_governance_read_oracle.py new file mode 100644 index 0000000..1e2598f --- /dev/null +++ b/tests/conformance/test_governance_read_oracle.py @@ -0,0 +1,216 @@ +"""Weft seam conformance oracle — legis governance_read (FROZEN GOLDEN). + +SEAM: legis produces per-SEI governance CLEARANCES via the ``governance_read`` +MCP tool (``_tool_governance_read`` -> ``legis.service.governance.read_governance_for_sei``). +This module freezes a GOLDEN of legis's governance_read response and rechecks +the REAL serializer + the REAL MCP wire against it, so a legis-side field rename +or projection change reds here. + +GOLDEN PROVENANCE (non-circular): the golden was frozen ONCE from the real wire +(ProtectedGate + SignoffGate writing genuine signed records into a real AuditStore, +then call_tool("governance_read")). The rechecks below load the golden from the +FILE and compare LIVE serializer/wire output to it — they never regenerate it. + +Mirrors test_warpline_attestation_oracle.py exactly. Key differences: + - tool is ``governance_read`` (not ``attestation_get``) + - golden field set is the clearance_record (sei/disposition/posture/authority/ + as_of/reasons/content_hash), NOT attestation fields (kind/content_hash/seq) + - uses the same _build_attested_store / _SEI / _CLOCK_ISO / _KEY as the + attestation oracle so content_hashes are cross-checkable + +CONTENT_HASH CROSS-CHECK (test_content_hashes_match_attestation_golden): the +clearance records' content_hash values must match the attestation golden's +attestations' content_hash values (same records, different projection). + +Layered freshness defence: + * Layer-1 byte-pin (``test_governance_read_golden_byte_pin``): UNMARKED, recomputes + the git blob sha1 and fails CLOSED on any byte drift. + * Producer recheck (``test_mcp_wire_reproduces_golden``): drives real call_tool. + * Kind-coverage guard (``test_both_clearance_kinds_are_pinned``): golden must + carry both posture values. +""" +from __future__ import annotations + +import hashlib +import json +from functools import lru_cache +from pathlib import Path + +import pytest + +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.signoff import SignoffGate +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.identity.entity_key import EntityKey +from legis.mcp import McpRuntime, call_tool + +# Reuse the SAME SEI, clock, key, and policy as the attestation oracle so that +# content_hashes are identical between the two goldens (cross-check in test below). +# If this oracle is run against the attestation oracle's store, the projection +# must produce the SAME content_hash values. +_CLOCK_ISO = "2026-06-02T12:00:00+00:00" +_KEY = b"weft-seam-conformance-key" +_POLICY = "protected.attestation" +_SEI = "loomweave:eid:0000000000000000000000000000aaaa" + +GOLDEN_PATH = ( + Path(__file__).parent / "fixtures" / "legis-governance-read.golden.json" +) + +# git blob sha1 of the committed golden. Layer-1 fail-closed pin (UNMARKED). +GOLDEN_BLOB_SHA = "898fd1a8c159cd717b0b976a5488b5c0c65a86a6" + + +@lru_cache(maxsize=1) +def _load_golden() -> dict: + return json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + + +def _git_blob_sha1(data: bytes) -> str: + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +class _AdvisoryJudge: + """Protected-cell judge is advisory; operator_override bypasses it entirely.""" + + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only") + + +def _build_attested_store(tmp_path) -> "AuditStore": # noqa: F821 + """Write a genuine signoff_cleared + operator_override record, both keyed on _SEI. + + IDENTICAL to test_warpline_attestation_oracle._build_attested_store (same + parameters, same key, same clock, same policy, same SEI) so the content_hash + values in the governance_read golden match the attestation golden. + """ + from legis.store.audit_store import AuditStore + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + clock = FixedClock(_CLOCK_ISO) + + signoff = SignoffGate(store, clock, signer=True, key=_KEY) + req = signoff.request( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="review", + agent_id="agent-1", + extensions={"loomweave": {"content_hash": "blake3:signoff-cleared"}}, + ) + signoff.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok") + + protected = ProtectedGate( + store, + clock, + judge=_AdvisoryJudge(), + key=_KEY, + protected_policies=frozenset({_POLICY}), + ) + protected.operator_override( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="release exception approved by security lead", + operator_id="op-sec-lead", + file_fingerprint="sha256:abc", + ast_path="Module/Call[eval]", + extensions={"loomweave": {"content_hash": "blake3:operator-override"}}, + ) + return store + + +def _wired_runtime(store) -> McpRuntime: + """A runtime with BOTH a protected gate and a trail verifier wired.""" + from legis.store.audit_store import AuditStore # noqa: F401 + + engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO)) + gate = ProtectedGate( + store, + FixedClock(_CLOCK_ISO), + judge=_AdvisoryJudge(), + key=_KEY, + protected_policies=frozenset({_POLICY}), + ) + return McpRuntime( + agent_id="agent-launch", + initialized=True, + engine=engine, + protected_gate=gate, + trail_verifier=TrailVerifier(_KEY, frozenset({_POLICY})), + ) + + +# --- Layer-1: byte-pin (UNMARKED, default suite) --- + +def test_governance_read_golden_byte_pin(): + data = GOLDEN_PATH.read_bytes() + assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, ( + "legis governance_read golden drifted from its pinned bytes; " + "re-freeze it from the real governance_read wire and update " + "GOLDEN_BLOB_SHA only after confirming the change is intended." + ) + + +# --- Producer recheck: real wire reproduces golden --- + +def test_mcp_wire_reproduces_golden(tmp_path): + """Drive the REAL call_tool wire. A rename of any clearance_record field + (sei/disposition/posture/authority/as_of/reasons/content_hash/status) reds here.""" + store = _build_attested_store(tmp_path) + runtime = _wired_runtime(store) + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert not result.get("isError"), result + assert result["structuredContent"] == _load_golden() + + +# --- Kind-coverage guard --- + +def test_both_clearance_kinds_are_pinned(): + """The golden MUST carry both clearance postures (operator_signoff + + protected_override). A one-kind golden leaves the other projection branch + un-pinned and a rename there would not red here.""" + postures = {r["posture"] for r in _load_golden()["records"]} + assert postures == {"operator_signoff", "protected_override"} + + +# --- Content-hash cross-check with the attestation golden --- + +def test_content_hashes_match_attestation_golden(): + """The governance_read golden's content_hash values must match the attestation + golden's content_hash values (same signed records, different projection). + If either golden is refreshed independently, this cross-check catches drift.""" + att_golden_path = Path(__file__).parent / "fixtures" / "legis-warpline-attestation-get.golden.json" + att_golden = json.loads(att_golden_path.read_text(encoding="utf-8")) + att_hashes = {a["content_hash"] for a in att_golden["attestations"]} + + gov_golden = _load_golden() + gov_hashes = {r["content_hash"] for r in gov_golden["records"]} + + assert gov_hashes == att_hashes, ( + "governance_read golden content_hashes don't match attestation golden — " + "they are built from the same store, so they must agree" + ) + + +# --- Rename-stability --- + +def test_governance_read_golden_is_keyed_on_sei(): + golden = _load_golden() + assert golden["status"] == "checked" + assert golden["sei"] == _SEI + assert golden["sei"].startswith("loomweave:eid:") + + +# --- Unavailable discriminant --- + +def test_unavailable_discriminant_is_distinct_from_empty_checked(tmp_path): + """Unwired gateway yields 'unavailable', not 'checked'/[] — the consumer + MUST distinguish these two statuses.""" + store = _build_attested_store(tmp_path) + engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO)) + unwired = McpRuntime(agent_id="x", initialized=True, engine=engine) + sc = call_tool(unwired, "governance_read", {"sei": _SEI})["structuredContent"] + assert sc["status"] == "unavailable" + assert sc["records"] == [] + assert sc["status"] != _load_golden()["status"] diff --git a/tests/contract/test_governance_read_v1_schema.py b/tests/contract/test_governance_read_v1_schema.py new file mode 100644 index 0000000..acf0e8f --- /dev/null +++ b/tests/contract/test_governance_read_v1_schema.py @@ -0,0 +1,393 @@ +"""Contract freeze test for governance_read.v1. + +These tests pin the JSON schema at contracts/governance_read.v1.schema.json and the +warpline hand-off prompt at docs/contracts/warpline-governance-read.v1-prompt.md. +.v1 is a ONE-WAY DOOR — any change to the validation logic requires a .v2 schema and +a new plan, never an in-place edit. + +TDD note: the format test (test_non_rfc3339_as_of_rejected) is the RED→GREEN signal for +this task — it ONLY passes when jsonschema[format] (rfc3339-validator backend) is installed. + +Cross-transport schema agreement: the ``test_cross_transport_*`` tests each capture a REAL +output (MCP golden / HTTP body / CLI stdout) from the respective transport and validate it +against the committed frozen schema with the rfc3339 format checker wired. This ensures that +all three adapters produce output that conforms to the same frozen contract. +""" +import json +import pathlib +import re + +import pytest +from jsonschema import Draft202012Validator + +# ── paths ────────────────────────────────────────────────────────────────────── +_REPO = pathlib.Path(__file__).parent.parent.parent # legis repo root +_SCHEMA_PATH = _REPO / "contracts" / "governance_read.v1.schema.json" +_PROMPT_PATH = _REPO / "docs" / "contracts" / "warpline-governance-read.v1-prompt.md" + +# ── fixtures ─────────────────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def schema(): + with open(_SCHEMA_PATH) as f: + return json.load(f) + + +@pytest.fixture(scope="module") +def validator(schema): + """Draft202012Validator WITH format checking (rfc3339-validator backend required).""" + return Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER) + + +# Sample data ────────────────────────────────────────────────────────────────── +_SEI = "loomweave:eid:7Q3f...c1" + +_RECORD_OVERRIDE = { + "sei": _SEI, + "disposition": "cleared", + "posture": "protected_override", + "authority": "operator", + "as_of": "2026-06-27T14:02:11Z", + "reasons": ["operator_override"], + "content_hash": "b3:9f2c...e7", +} +_RECORD_SIGNOFF = { + "sei": _SEI, + "disposition": "cleared", + "posture": "operator_signoff", + "authority": "operator", + "as_of": "2026-06-26T09:41:55Z", + "reasons": ["signoff_cleared"], + "content_hash": "b3:5a10...92", +} + + +# ── POSITIVE TESTS ───────────────────────────────────────────────────────────── + +def test_checked_two_clearances_valid(validator): + """Sample 1 from the warpline prompt: checked + two clearance records.""" + doc = {"status": "checked", "sei": _SEI, "records": [_RECORD_OVERRIDE, _RECORD_SIGNOFF]} + validator.validate(doc) + + +def test_checked_empty_records_valid(validator): + """Sample 2: checked + empty records (honest absence, not ungoverned).""" + doc = {"status": "checked", "sei": "loomweave:eid:unknown...", "records": []} + validator.validate(doc) + + +def test_unavailable_with_reason_valid(validator): + """Sample 3: unavailable + reason array.""" + doc = { + "status": "unavailable", + "sei": _SEI, + "records": [], + "unavailable": [{"reason": "trail not signature-verifiable (no protected gate / verifier)"}], + } + validator.validate(doc) + + +# ── DISCRIMINATOR NEGATIVES ──────────────────────────────────────────────────── +# The schema enforces the discriminated union: status=unavailable REQUIRES the +# `unavailable` key (non-empty) AND records:[] (maxItems 0); status=checked FORBIDS it. + +def test_unavailable_without_reason_rejected(validator): + """status:unavailable without the `unavailable` key is REJECTED (must-fix #5).""" + bad = {"status": "unavailable", "sei": _SEI, "records": []} + errors = list(validator.iter_errors(bad)) + assert errors, "schema should have rejected unavailable without `unavailable` key" + + +def test_unavailable_with_records_rejected(validator): + """status:unavailable with non-empty records is REJECTED (maxItems:0 enforced).""" + bad = { + "status": "unavailable", + "sei": _SEI, + "records": [_RECORD_OVERRIDE], + "unavailable": [{"reason": "some-reason"}], + } + errors = list(validator.iter_errors(bad)) + assert errors, "schema should have rejected unavailable with non-empty records" + + +def test_checked_with_unavailable_key_rejected(validator): + """status:checked with the `unavailable` key is REJECTED (not: required[unavailable]).""" + bad = { + "status": "checked", + "sei": _SEI, + "records": [], + "unavailable": [{"reason": "this should not be here"}], + } + errors = list(validator.iter_errors(bad)) + assert errors, "schema should have rejected checked with `unavailable` key present" + + +# ── CONSTRAINT NEGATIVES ─────────────────────────────────────────────────────── + +def test_unknown_status_rejected(validator): + bad = {"status": "unknown", "sei": _SEI, "records": []} + assert list(validator.iter_errors(bad)) + + +def test_record_extra_field_rejected(validator): + """clearance_record has additionalProperties:false.""" + rec = {**_RECORD_OVERRIDE, "extra_field": "not-allowed"} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + assert list(validator.iter_errors(bad)) + + +def test_envelope_extra_field_rejected(validator): + """Envelope has additionalProperties:false.""" + bad = {"status": "checked", "sei": _SEI, "records": [], "unexpected": "value"} + assert list(validator.iter_errors(bad)) + + +def test_empty_content_hash_rejected(validator): + """content_hash has minLength:1 — empty string is rejected.""" + rec = {**_RECORD_OVERRIDE, "content_hash": ""} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + assert list(validator.iter_errors(bad)) + + +def test_empty_envelope_sei_rejected(validator): + """Envelope sei has minLength:1 — empty string is rejected.""" + bad = {"status": "checked", "sei": "", "records": []} + assert list(validator.iter_errors(bad)) + + +def test_empty_record_sei_rejected(validator): + """clearance_record sei has minLength:1 — empty string is rejected.""" + rec = {**_RECORD_OVERRIDE, "sei": ""} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + assert list(validator.iter_errors(bad)) + + +def test_empty_reasons_rejected(validator): + """reasons has minItems:1 — empty array is rejected.""" + rec = {**_RECORD_OVERRIDE, "reasons": []} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + assert list(validator.iter_errors(bad)) + + +def test_invalid_posture_rejected(validator): + """posture is a closed enum; unknown value is rejected.""" + rec = {**_RECORD_OVERRIDE, "posture": "chill"} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + assert list(validator.iter_errors(bad)) + + +# ── FORMAT TEST ──────────────────────────────────────────────────────────────── + +def test_non_rfc3339_as_of_rejected(schema, validator): + """as_of: "not-a-date" must be rejected WHEN format_checker is wired. + + The WITH-checker assertion is the RED→GREEN signal for jsonschema[format]. + The WITHOUT-checker assertion proves the checker is load-bearing + (the format keyword alone does not validate without a backend). + """ + rec = {**_RECORD_OVERRIDE, "as_of": "not-a-date"} + bad = {"status": "checked", "sei": _SEI, "records": [rec]} + + # WITHOUT format_checker: format assertions are silently skipped → valid + validator_no_checker = Draft202012Validator(schema) + errors_without = list(validator_no_checker.iter_errors(bad)) + assert not errors_without, ( + "Without format_checker the schema should still accept 'not-a-date' (format not enforced); " + f"got errors: {errors_without}" + ) + + # WITH format_checker (rfc3339-validator backend required): must REJECT "not-a-date" + errors_with = list(validator.iter_errors(bad)) + assert errors_with, ( + "With format_checker the schema should reject as_of:'not-a-date' — " + "this assertion fails if rfc3339-validator is not installed (jsonschema[format] missing)" + ) + + +# ── DRIFT GUARD ──────────────────────────────────────────────────────────────── + +def _strip_descriptions(obj): + """Recursively remove 'description' keys (non-validating annotations). + + The committed schema carries descriptions on every property for human readers; + the warpline prompt carries an equivalent condensed block without them (the + prose provides the semantics). The drift guard compares STRUCTURAL content only. + Strip only 'description' — 'title' and all validating keywords are preserved so + that enum, type, minLength, format, allOf, etc. drift is still caught. + """ + if isinstance(obj, dict): + return {k: _strip_descriptions(v) for k, v in obj.items() if k != "description"} + if isinstance(obj, list): + return [_strip_descriptions(item) for item in obj] + return obj + + +def test_prompt_schema_block_matches_committed_file(): + """The JSON block embedded in the warpline prompt must match the committed schema. + + Structural (non-description) equality is asserted. If this test fails, the prompt + and the committed schema have diverged on a validating keyword — a genuine drift + that must be resolved (never buried by altering the comparison). + + Note: the prompt header claims 'byte-equal' and the plan says 'JSON-equal'; the + only difference between the files is description annotations (non-validating). + Stripping descriptions before comparing keeps the guard strict on every keyword + that affects validation behaviour. + """ + with open(_SCHEMA_PATH) as f: + committed = json.load(f) + + with open(_PROMPT_PATH) as f: + content = f.read() + + # Extract the first JSON fenced block under the "## The contract" heading + m = re.search( + r"## The contract.*?\n```json\n(.*?)\n```", + content, + re.DOTALL, + ) + assert m, "Could not find a JSON fenced block under '## The contract' in the warpline prompt" + prompt_block = json.loads(m.group(1)) + + committed_stripped = _strip_descriptions(committed) + prompt_stripped = _strip_descriptions(prompt_block) + assert committed_stripped == prompt_stripped, ( + "Structural drift detected between the committed schema and the prompt's embedded block. " + "Diff (committed keys only in descriptions are expected and ignored): resolve any " + "difference in validating keywords (type, enum, minLength, format, allOf, etc.) before " + "committing. A .v1 change is never allowed — use .v2." + ) + + +# ── CROSS-TRANSPORT SCHEMA AGREEMENT ────────────────────────────────────────── +# Each test below captures a REAL output from one transport (MCP golden / HTTP / +# CLI) and validates it against the committed frozen schema WITH the rfc3339 +# format checker wired. These are the only tests that couple the three adapters +# to the single frozen contract file — not hand-authored shapes, not embedded +# _one_of outputSchemas, but actual transport outputs. + +_GOLDEN_PATH = _REPO / "tests" / "conformance" / "fixtures" / "legis-governance-read.golden.json" + +# SEI / key / policy / clock mirroring test_governance_read_route.py for HTTP + CLI fixtures. +_XPORT_CLOCK_ISO = "2026-06-02T12:00:00+00:00" +_XPORT_KEY = b"xport-schema-key" +_XPORT_KEY_STR = "xport-schema-key" +_XPORT_POLICY = "protected.xport" +_XPORT_SEI = "loomweave:eid:xport-schema-test" +_XPORT_PROTECTED = frozenset({_XPORT_POLICY}) + + +def test_cross_transport_mcp_golden_validates_against_frozen_schema(validator): + """The Task 3 FROZEN GOLDEN (a real captured MCP wire output) must validate + against the frozen v1 schema WITH the rfc3339 format checker. This is the + canonical cross-transport check for the MCP adapter — it operates on an actual + captured output, not a hand-authored shape. + """ + golden = json.loads(_GOLDEN_PATH.read_text(encoding="utf-8")) + # Must not raise — any schema violation here means the MCP adapter drifted from v1. + validator.validate(golden) + + +def test_cross_transport_http_body_validates_against_frozen_schema(tmp_path, validator): + """A REAL HTTP response body from the FastAPI route must validate against the + frozen v1 schema. Drives the actual app (not a mock), so a route-level field + rename or missing key fails here immediately. + """ + import hashlib + + from fastapi.testclient import TestClient + + from legis.api.app import create_app + from legis.clock import FixedClock + from legis.enforcement.engine import EnforcementEngine + from legis.enforcement.protected import ProtectedGate, TrailVerifier + from legis.enforcement.verdict import JudgeOpinion, Verdict + from legis.identity.entity_key import EntityKey + from legis.policy.cells import PolicyCellRegistry, PolicyCellRule + from legis.posture.ledger import PostureLedger + from legis.store.audit_store import AuditStore + + class _Judge: + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(Verdict.BLOCKED, "j@1", "advisory") + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + clock = FixedClock(_XPORT_CLOCK_ISO) + pg_write = ProtectedGate(store, clock, judge=_Judge(), key=_XPORT_KEY, + protected_policies=_XPORT_PROTECTED) + pg_write.operator_override( + policy=_XPORT_POLICY, + entity_key=EntityKey(value=_XPORT_SEI, identity_stable=True), + rationale="approved", + operator_id="op-xport", + file_fingerprint="sha256:ff", + ast_path="Module/Call", + extensions={"loomweave": {"content_hash": "blake3:xport-test"}}, + ) + + ledger = PostureLedger(f"sqlite:///{tmp_path / 'posture.db'}", initialize=True) + fp = hashlib.sha256(b"k" * 32).hexdigest() + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + + app = create_app( + enforcement=EnforcementEngine(store, clock), + protected_gate=ProtectedGate(store, clock, judge=_Judge(), key=_XPORT_KEY, + protected_policies=_XPORT_PROTECTED), + trail_verifier=TrailVerifier(_XPORT_KEY, _XPORT_PROTECTED), + cell_registry=PolicyCellRegistry( + default_cell="chill", + rules=(PolicyCellRule(pattern=_XPORT_POLICY, cell="protected"),), + ), + posture_ledger=ledger, + ) + import pytest as _pytest + _pytest.importorskip("fastapi") # defensive — already a dep + client = TestClient(app, headers={"Authorization": "Bearer dev-token"}) + resp = client.get(f"/governance/sei/{_XPORT_SEI}/governance-read") + assert resp.status_code == 200 + body = resp.json() + # The core assertion: the REAL HTTP output validates against the frozen contract. + validator.validate(body) + + +def test_cross_transport_cli_stdout_validates_against_frozen_schema( + tmp_path, monkeypatch, capsys, validator +): + """A REAL CLI stdout from `legis governance-read` must validate against the + frozen v1 schema WITH the rfc3339 format checker. Captures actual main() output + so any CLI serialization drift from the v1 envelope shape fails here. + """ + from legis.cli import main + from legis.clock import FixedClock + from legis.enforcement.protected import ProtectedGate + from legis.enforcement.verdict import JudgeOpinion, Verdict + from legis.identity.entity_key import EntityKey + from legis.store.audit_store import AuditStore + + class _AcceptJudge: + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(verdict=Verdict.ACCEPTED, model="j@1", rationale="ok") + + db_path = tmp_path / "gov.db" + store = AuditStore(f"sqlite:///{db_path}") + gate = ProtectedGate(store, FixedClock(_XPORT_CLOCK_ISO), judge=_AcceptJudge(), + key=_XPORT_KEY, protected_policies=_XPORT_PROTECTED) + gate.operator_override( + policy=_XPORT_POLICY, + entity_key=EntityKey(value=_XPORT_SEI, identity_stable=True), + rationale="operator clears", + operator_id="op-cli-xport", + file_fingerprint="sha256:ff", + ast_path="Module.Call", + extensions={"loomweave": {"content_hash": "ch:cli-xport-test"}}, + ) + + monkeypatch.setenv("LEGIS_HMAC_KEY", _XPORT_KEY_STR) + monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _XPORT_POLICY) + + rc = main(["governance-read", "--db", f"sqlite:///{db_path}", _XPORT_SEI]) + assert rc == 0 + out, err = capsys.readouterr() + assert not err, f"unexpected stderr: {err!r}" + envelope = json.loads(out) + # The core assertion: the REAL CLI output validates against the frozen contract. + validator.validate(envelope) diff --git a/tests/enforcement/test_protected_transaction.py b/tests/enforcement/test_protected_transaction.py new file mode 100644 index 0000000..f25f9ef --- /dev/null +++ b/tests/enforcement/test_protected_transaction.py @@ -0,0 +1,141 @@ +import sqlite3 + +import pytest + +from legis.clock import FixedClock +from legis.enforcement.protected import ProtectedGate +from legis.identity.entity_key import EntityKey +from legis.store.audit_store import AuditStore +from legis.store.head_anchor import AnchorError, HeadAnchor + +KEY = b"k" * 32 +CLOCK = "2026-06-26T12:00:00+00:00" + + +class _UnusedJudge: + """operator_override bypasses the judge; if it is consulted, fail loudly.""" + + def evaluate(self, record): # pragma: no cover - must never be called + raise AssertionError("judge must not be consulted on operator_override") + + +def _anchored_gate(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + anchor = HeadAnchor(str(tmp_path / "gov.anchor"), KEY) + gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY, anchor=anchor) + return store, anchor, gate + + +def _override(gate, rationale): + return gate.operator_override( + policy="protected/secrets", + entity_key=EntityKey.from_locator("m.f"), + rationale=rationale, + operator_id="op", + file_fingerprint="fp", + ast_path="m.f", + ) + + +def _truncate_to(db_path, keep_seq): + """Raw out-of-band tail truncation (drops the append-only triggers first). + + No survivor re-chain needed: AnchorError fires on the head_seq comparison + before chain_hash is checked. + """ + con = sqlite3.connect(db_path) + try: + con.execute("DROP TRIGGER IF EXISTS audit_log_no_update") + con.execute("DROP TRIGGER IF EXISTS audit_log_no_delete") + con.execute("DELETE FROM audit_log WHERE seq > ?", (keep_seq,)) + con.commit() + finally: + con.close() + + +def test_protected_transaction_advances_anchor_so_truncation_is_detected(tmp_path): + """A protected append batched through ProtectedGate.transaction() advances the + HeadAnchor after commit, so a later tail-truncation back to the pre-batch head + is DETECTED (parity with SignoffGate.transaction()). legis-0c310712a7. + """ + store, anchor, gate = _anchored_gate(tmp_path) + # A pre-batch protected append (outside any batch -> anchor advances normally). + _override(gate, "pre-batch") + pre_seq, _ = store.get_latest_sequence_and_hash() + + # Batch a protected append through the NEW owned transaction API. + with gate.transaction(): + _override(gate, "in-batch") + + # The transaction() advanced the anchor to the in-batch record's head. + head_seq, _ = store.get_latest_sequence_and_hash() + assert head_seq == pre_seq + 1 + + # Truncate the in-batch record out of band, back to the pre-batch head. + _truncate_to(str(tmp_path / "gov.db"), pre_seq) + + # The anchor remembers the higher head -> truncation is detected. + with pytest.raises(AnchorError): + anchor.check(store.read_all()) + + +def test_protected_transaction_is_safe_without_an_anchor(tmp_path): + """Production default: anchor=None. transaction() still batches atomically; + the if-anchor guard is a no-op, not a crash.""" + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY) # no anchor= + with gate.transaction(): + _override(gate, "in-batch") + assert len(store.read_all()) == 1 + assert store.verify_integrity() is True + + +def test_protected_transaction_no_overshoot_on_rollback(tmp_path): + """AUD-1 no-overshoot: an exception inside gate.transaction() rolls back the + append AND the anchor update, so the anchor never advances past a rolled-back + head. legis-0c310712a7.""" + store, anchor, gate = _anchored_gate(tmp_path) + # Pre-batch append: anchor lands at a known head. + _override(gate, "pre-rollback") + pre_batch_count = len(store.read_all()) + + with pytest.raises(RuntimeError): + with gate.transaction(): + _override(gate, "will-be-rolled-back") + raise RuntimeError("simulated failure inside batch") + + # Rollback dropped the in-batch row; anchor update never ran. + assert len(store.read_all()) == pre_batch_count + # Anchor still at the pre-batch head -> does not raise. + anchor.check(store.read_all()) + + +def test_raw_store_transaction_bypasses_anchor_advance_documented_residual(tmp_path): + """DOCUMENTS the parity limit (not a defense): a protected append inside a RAW + store.transaction() — bypassing gate.transaction() — defers the anchor advance + (in_batch() is true) and nothing re-advances it, so a truncation back to the + pre-batch head is NOT detected. The supported safe path is gate.transaction() + (Task 1); a caller that owns a raw batch must advance the anchor itself, + identically to SignoffGate. If a future change closes this (e.g. an after-commit + hook on AuditStore.transaction), update this test deliberately. legis-0c310712a7. + """ + store, anchor, gate = _anchored_gate(tmp_path) + _override(gate, "pre-batch") + pre_seq, _ = store.get_latest_sequence_and_hash() + + # RAW batch (NOT gate.transaction()): the append's anchor advance is deferred + # and never re-applied. + with store.transaction(): + _override(gate, "in-raw-batch") + + # LANDING ASSERTION: proves the in-raw-batch append actually landed (the + # in_batch()-deferred branch was taken). If this fails, the raw batch silently + # no-op'd and the residual test cannot be trusted. Do NOT silence by weakening + # the assertion. + assert store.get_latest_sequence_and_hash()[0] == pre_seq + 1 + + # The anchor is STALE at the pre-batch head (the residual). + _truncate_to(str(tmp_path / "gov.db"), pre_seq) + + # Stale anchor == truncated DB head -> truncation is NOT detected. + anchor.check(store.read_all()) # does not raise: asserts the DOCUMENTED residual diff --git a/tests/mcp/test_governance_read_tool.py b/tests/mcp/test_governance_read_tool.py new file mode 100644 index 0000000..593ef76 --- /dev/null +++ b/tests/mcp/test_governance_read_tool.py @@ -0,0 +1,189 @@ +"""MCP tool ``governance_read`` handler tests. + +Mirrors the attestation_get test structure (tests/mcp/test_server.py Task 5 section): + (a) verifiable trail with a clearance -> {status:checked, records:[…]} + (b) protected_gate is None or trail_verifier is None -> {status:unavailable, …} + (c) tampered trail -> AuditIntegrityError -> AUDIT_INTEGRITY_FAILURE error envelope + +The BOTH-objects fail-closed pre-gate (must-fix) is the key invariant: the tool +must gate on *both* runtime.protected_gate AND runtime.trail_verifier, not just one. +""" +from __future__ import annotations + +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.signoff import SignoffGate +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.identity.entity_key import EntityKey +from legis.mcp import McpRuntime, call_tool +from legis.store.audit_store import AuditStore + +_CLOCK_ISO = "2026-06-02T12:00:00+00:00" +_KEY = b"gov-read-key" +_POLICY = "protected.governance" +_SEI = "loomweave:eid:governance-read-test" + + +class _AdvisoryJudge: + def evaluate(self, record): # noqa: ANN001 + return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only") + + +def _runtime(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO)) + return McpRuntime(agent_id="agent-launch", initialized=True, engine=engine), store + + +def _wired_runtime_with_clearance(tmp_path): + """Build a runtime with BOTH objects wired and a genuine clearance (signoff_cleared + + operator_override) for _SEI.""" + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + clock = FixedClock(_CLOCK_ISO) + + # Write a signoff_cleared record + gate = SignoffGate(store, clock, signer=True, key=_KEY) + req = gate.request( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="review", + agent_id="agent-1", + extensions={"loomweave": {"content_hash": "blake3:clearance-signoff"}}, + ) + gate.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok") + + # Write an operator_override record + protected = ProtectedGate( + store, + clock, + judge=_AdvisoryJudge(), + key=_KEY, + protected_policies=frozenset({_POLICY}), + ) + protected.operator_override( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="approved", + operator_id="op-sec", + file_fingerprint="sha256:abc", + ast_path="Module/Call", + extensions={"loomweave": {"content_hash": "blake3:clearance-override"}}, + ) + + engine = EnforcementEngine(store, clock) + pg = ProtectedGate( + store, clock, judge=_AdvisoryJudge(), key=_KEY, + protected_policies=frozenset({_POLICY}), + ) + runtime = McpRuntime( + agent_id="agent-launch", + initialized=True, + engine=engine, + protected_gate=pg, + trail_verifier=TrailVerifier(_KEY, frozenset({_POLICY})), + ) + return runtime, store + + +# --- (a) verifiable trail with clearance -> checked with records --- + +def test_governance_read_checked_with_clearances(tmp_path): + runtime, _store = _wired_runtime_with_clearance(tmp_path) + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert not result.get("isError"), result + sc = result["structuredContent"] + assert sc["status"] == "checked" + assert sc["sei"] == _SEI + assert len(sc["records"]) == 2 + # Both clearance kinds must appear + postures = {r["posture"] for r in sc["records"]} + assert postures == {"operator_signoff", "protected_override"} + # Every record must have the required v1 clearance_record fields + for rec in sc["records"]: + assert rec["disposition"] == "cleared" + assert rec["authority"] == "operator" + assert rec["sei"] == _SEI + assert isinstance(rec["reasons"], list) and len(rec["reasons"]) == 1 + assert rec["content_hash"].startswith("blake3:") + assert rec["as_of"] == _CLOCK_ISO + + +def test_governance_read_empty_verified_trail_is_checked_empty(tmp_path): + """A wired runtime with no clearances returns status=checked, records=[] + (honest absence, NOT unavailable — trail was checked).""" + runtime, _store = _runtime(tmp_path) + + class _OkVerifier: + def verify(self, records): + return None + + class _FakeProtectedGate: + def records(self): + return [] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _OkVerifier() + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert not result.get("isError"), result + sc = result["structuredContent"] + assert sc["status"] == "checked" + assert sc["records"] == [] + assert "unavailable" not in sc + + +# --- (b) both-objects fail-closed pre-gate --- + +def test_governance_read_unavailable_when_no_protected_gate(tmp_path): + """No protected_gate (engine-only deployment) -> unavailable, NEVER checked/[].""" + runtime, _store = _runtime(tmp_path) + assert runtime.protected_gate is None + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert not result.get("isError"), result + sc = result["structuredContent"] + assert sc["status"] == "unavailable" + assert sc["sei"] == _SEI + assert sc["records"] == [] + assert sc["unavailable"] and "reason" in sc["unavailable"][0] + + +def test_governance_read_unavailable_when_no_trail_verifier(tmp_path): + """protected_gate present but trail_verifier absent -> unavailable. + The BOTH-objects gate must reject on either missing half.""" + runtime, _store = _runtime(tmp_path) + + class _FakeProtectedGate: + def records(self): + return [] + + runtime.protected_gate = _FakeProtectedGate() + # trail_verifier deliberately left None + assert runtime.trail_verifier is None + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert not result.get("isError"), result + sc = result["structuredContent"] + assert sc["status"] == "unavailable" + assert sc["records"] == [] + assert sc["unavailable"] and "reason" in sc["unavailable"][0] + + +# --- (c) tampered trail -> AUDIT_INTEGRITY_FAILURE --- + +def test_governance_read_tamper_yields_audit_integrity_failure(tmp_path): + """A tampered trail -> AuditIntegrityError -> AUDIT_INTEGRITY_FAILURE error envelope.""" + runtime, _store = _runtime(tmp_path) + + class _TamperVerifier: + def verify(self, records): + from legis.enforcement.protected import TamperError + raise TamperError("record 2 hash mismatch") + + class _FakeProtectedGate: + def records(self): + return ["bad-record"] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _TamperVerifier() + result = call_tool(runtime, "governance_read", {"sei": _SEI}) + assert result.get("isError") + assert result["structuredContent"]["error_code"] == "AUDIT_INTEGRITY_FAILURE" diff --git a/tests/mcp/test_output_schema_conformance.py b/tests/mcp/test_output_schema_conformance.py index 457d7a5..65a0dda 100644 --- a/tests/mcp/test_output_schema_conformance.py +++ b/tests/mcp/test_output_schema_conformance.py @@ -632,11 +632,27 @@ def test_warpline_preflight_get_unavailable_conforms(tmp_path): def test_warpline_preflight_get_checked_conforms(tmp_path): class _FakeWarpline: + """Returns real-shaped envelopes with a GV-LG-3-valid meta. + + A meta-violating envelope would be refused → unavailable, which would + make the ``status == 'checked'`` assertion below fail silently. + """ + def impact_radius(self, base, head): - return {"affected": [], "count": 0} + return { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": {"completeness": "FULL", "affected": []}, + "meta": {"local_only": True, "peer_side_effects": []}, + } def reverify_worklist(self, base, head): - return {"entries": [], "count": 0} + return { + "schema": "warpline.reverify_worklist.v1", + "ok": True, + "data": {"completeness": "FULL", "items": []}, + "meta": {"local_only": True, "peer_side_effects": []}, + } runtime, _store = _runtime(tmp_path) runtime.warpline = _FakeWarpline() @@ -644,6 +660,43 @@ def reverify_worklist(self, base, head): assert payload["status"] == "checked" +def test_plainweave_preflight_get_unavailable_conforms(tmp_path): + runtime, _store = _runtime(tmp_path) # plainweave None + payload = _conformant(runtime, "plainweave_preflight_get", {"base": "aaa"}) + assert payload["status"] == "unavailable" + + +def test_plainweave_preflight_get_checked_conforms(tmp_path): + class _FakePlainweave: + """Returns a real-shaped envelope with a GV-LG-3-valid authority_boundary. + + A boundary-violating envelope would be refused → unavailable, which would + make the ``status == 'checked'`` assertion below fail silently. + """ + + def preflight_facts(self, base, head): + return { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": True, + "data": { + "freshness": "partial", + "facts": [], + "authority_boundary": { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": False, + }, + }, + "warnings": [], + "meta": {}, + } + + runtime, _store = _runtime(tmp_path) + runtime.plainweave = _FakePlainweave() + payload = _conformant(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"}) + assert payload["status"] == "checked" + + def test_attestation_get_unavailable_conforms(tmp_path): runtime, _store = _runtime(tmp_path) # no protected gate payload = _conformant(runtime, "attestation_get", {"sei": "mod.fn#1"}) @@ -671,3 +724,59 @@ def records(self): result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) assert result.get("isError") Draft202012Validator(ERROR_ENVELOPE_SCHEMA).validate(result["structuredContent"]) + + +# --- governance_read output schema conformance --- + + +def test_governance_read_checked_variant_conforms(tmp_path): + """The 'checked' variant of governance_read with actual clearance records + must validate against the declared _one_of outputSchema.""" + from legis.clock import FixedClock + from legis.enforcement.protected import ProtectedGate, TrailVerifier + from legis.enforcement.signoff import SignoffGate + from legis.identity.entity_key import EntityKey + + _KEY = b"schema-conf-key" + _POLICY = "protected.schema_test" + _SEI = "loomweave:eid:schema-conformance" + + store = AuditStore(f"sqlite:///{tmp_path / 'gov-conf.db'}") + clock = FixedClock("2026-06-02T12:00:00+00:00") + + # Write a genuine signoff_cleared record + gate = SignoffGate(store, clock, signer=True, key=_KEY) + req = gate.request( + policy=_POLICY, + entity_key=EntityKey(value=_SEI, identity_stable=True), + rationale="review", + agent_id="agent-1", + extensions={"loomweave": {"content_hash": "blake3:schema-conf"}}, + ) + gate.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok") + + runtime, _ = _runtime(tmp_path) + runtime.protected_gate = ProtectedGate( + store, clock, judge=_ScriptedJudge(), key=_KEY, + protected_policies=frozenset({_POLICY}), + ) + runtime.trail_verifier = TrailVerifier(_KEY, frozenset({_POLICY})) + runtime.engine._store = store # point engine at the store with clearances + + payload = _conformant(runtime, "governance_read", {"sei": _SEI}) + assert payload["status"] == "checked" + assert len(payload["records"]) >= 1 + rec = payload["records"][0] + assert rec["disposition"] == "cleared" + assert rec["posture"] == "operator_signoff" + assert rec["authority"] == "operator" + + +def test_governance_read_unavailable_variant_conforms(tmp_path): + """The 'unavailable' variant (no protected gate wired) must validate against + the declared _one_of outputSchema.""" + runtime, _store = _runtime(tmp_path) # no protected gate + payload = _conformant(runtime, "governance_read", {"sei": "loomweave:eid:any"}) + assert payload["status"] == "unavailable" + assert payload["records"] == [] + assert payload["unavailable"] and "reason" in payload["unavailable"][0] diff --git a/tests/mcp/test_plainweave_advisory_boundary.py b/tests/mcp/test_plainweave_advisory_boundary.py new file mode 100644 index 0000000..fc29cfe --- /dev/null +++ b/tests/mcp/test_plainweave_advisory_boundary.py @@ -0,0 +1,230 @@ +"""Byte-identical advisory-boundary acceptance spine — plainweave sibling. + +Proves the governance-verdict invariant for the plainweave advisory read: verdicts +are byte-identical whether ``runtime.plainweave`` is None or points to a hostile +advisory client returning arbitrary garbage facts. The structural companion test +additionally asserts that ``runtime.plainweave`` is referenced in no verdict-path +function source, giving defense-in-depth coverage. + +If either test fails, plainweave data has somehow reached a verdict path — +a security regression. Mirrors ``tests/mcp/test_warpline_advisory_boundary.py``. +""" + +import inspect +import json + +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.plainweave_preflight.client import PlainweaveMcpClient +from legis.policy.grammar import AllowlistBoundary, PolicyGrammar +from legis.store.audit_store import AuditStore + + +# --------------------------------------------------------------------------- +# Local helpers (mirrors tests/mcp/test_warpline_advisory_boundary.py). +# --------------------------------------------------------------------------- + + +def _chill_posture_ledger(tmp_path): + import hashlib + import uuid + + from legis.posture.ledger import PostureLedger + + ledger = PostureLedger( + f"sqlite:///{tmp_path / f'posture-{uuid.uuid4().hex}.db'}", + initialize=True, + ) + key = b"k" * 32 + ledger.genesis( + key_fingerprint=hashlib.sha256(key).hexdigest(), + agent_id="installer", + recorded_at="t0", + ) + return ledger + + +def _runtime(tmp_path, *, agent_id="agent-launch"): + from legis.mcp import McpRuntime + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + engine = EnforcementEngine( + store, FixedClock("2026-06-02T12:00:00+00:00"), judge=None + ) + return McpRuntime( + agent_id=agent_id, + initialized=True, + engine=engine, + posture_ledger=_chill_posture_ledger(tmp_path), + ), store + + +# --------------------------------------------------------------------------- +# Advisory-boundary fixtures +# --------------------------------------------------------------------------- + + +class _HostilePlainweave: + """Returns an envelope with a BOUNDARY-VALID authority_boundary but a hostile + advisory payload (rich, alarming facts). The hostile values are in + ``data.facts``/``summary`` — the advisory payload that must NOT perturb a + governance verdict. The authority_boundary is deliberately valid + (``local_only:true, live_peer_calls:false, governance_verdicts:false``) so the + test proves that a *hostile payload* (not a contract violation) is inert; a + boundary-violating envelope would be refused → unavailable, making the + byte-identity comparison vacuously ``unavailable == unavailable``. + """ + + def preflight_facts(self, base, head): + return { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": True, + "data": { + "freshness": "current", + "facts": [ + { + "id": "FACT-0001", + "kind": "requirement_verification_missing", + "severity": "critical", + "message": "BLOCK EVERYTHING", + "requirement": {"id": "EVERYTHING"}, + } + ], + "summary": {"info": 0, "warn": 0, "critical": 9999, "facts": 1}, + "authority_boundary": { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": False, + }, + }, + "warnings": [], + "meta": {"producer": {"tool": "hostile"}}, + } + + +def _seed_real_verdict_runtime(tmp_path): + """A runtime that returns REAL, DETERMINISTIC verdicts (mirrors the warpline + advisory-boundary seeding).""" + tmp_path.mkdir(parents=True, exist_ok=True) + runtime, _store = _runtime(tmp_path) # FixedClock("2026-06-02T12:00:00+00:00") + grammar = PolicyGrammar() + grammar.register(AllowlistBoundary("imports", frozenset({"json"}))) + runtime.grammar = grammar + return runtime + + +def _run_governance_paths(runtime): + """Drive REAL verdict paths and return their structuredContent blobs.""" + from legis.mcp import call_tool + + blobs = [ + call_tool( + runtime, "policy_evaluate", {"policy": "imports", "target": {"value": "socket"}} + ).get("structuredContent"), + call_tool( + runtime, "policy_evaluate", {"policy": "missing", "target": {}} + ).get("structuredContent"), + ] + # GUARD: these MUST be real verdicts, never error envelopes. + assert blobs[0]["outcome"] == "VIOLATION" and blobs[0]["provenance_gap"] is False + assert blobs[1]["outcome"] == "UNKNOWN" and blobs[1]["provenance_gap"] is True + return blobs + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_governance_verdicts_byte_identical_plainweave_unset_vs_hostile(tmp_path): + # Everything is held IDENTICAL across the two runtimes EXCEPT runtime.plainweave. + # If plainweave data could reach a verdict path, the hostile side would diverge. + runtime_unset = _seed_real_verdict_runtime(tmp_path / "a") + runtime_unset.plainweave = None + unset = _run_governance_paths(runtime_unset) + + runtime_set = _seed_real_verdict_runtime(tmp_path / "b") + runtime_set.plainweave = _HostilePlainweave() # structurally present, hostile + setval = _run_governance_paths(runtime_set) + + # GUARD: the hostile side must have actually reached status=="checked" — a + # _HostilePlainweave that produced 'unavailable' would make the byte-identity + # assertion trivially pass while proving nothing. + from legis.mcp import call_tool + pf = call_tool(runtime_set, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"}) + assert pf["structuredContent"]["status"] == "checked", ( + "_HostilePlainweave returned unavailable — its envelope was rejected before " + "reaching the advisory layer; the byte-identity assertion is vacuous" + ) + + assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True) + + +def test_gv_lg_3_governance_verdicts_true_yields_unavailable(tmp_path): + """Positive GV-LG-3 pin: an envelope whose authority_boundary claims to emit + governance verdicts is refused by PlainweaveMcpClient._call (raises + PlainweaveError), which propagates through read_plainweave_preflight as + status='unavailable'. ADR-006's whole point is that plainweave emits facts + only — a producer claiming verdicts is a contract violation, refused.""" + from legis.mcp import call_tool + + verdict_claiming_envelope = { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": True, + "data": { + "freshness": "partial", + "facts": [], + "authority_boundary": { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": True, # GV-LG-3 VIOLATION + }, + }, + "warnings": [], + "meta": {}, + } + runtime, _ = _runtime(tmp_path) + runtime.plainweave = PlainweaveMcpClient( + invoke=lambda t, a: verdict_claiming_envelope, repo="/r" + ) + pf = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"}) + assert pf["structuredContent"]["status"] == "unavailable", ( + "GV-LG-3: a producer claiming governance_verdicts must yield unavailable, not checked" + ) + + +def test_runtime_plainweave_referenced_in_no_verdict_path_function(): + # STRUCTURAL (defense-in-depth): runtime.plainweave must appear in NO + # verdict-path / honesty-read source. Tool-handler coverage is DERIVED from + # _TOOL_HANDLERS so any future handler is covered by construction; the single + # legitimate advisory handler (_tool_plainweave_preflight_get) is excluded by + # name — any other handler that starts reading .plainweave fails immediately. + import legis.mcp as mcp + from legis.service.governance import read_sei_attestations + + _PLAINWEAVE_HANDLER = "_tool_plainweave_preflight_get" + for name, handler in mcp._TOOL_HANDLERS.items(): + if handler.__name__ == _PLAINWEAVE_HANDLER: + continue + src = inspect.getsource(handler) + assert ".plainweave" not in src, ( + f"tool handler {handler.__name__!r} (tool={name!r}) references plainweave" + ) + + from legis.service.governance import ( + governance_read_unavailable, + read_governance_for_sei, + read_governance_for_sei_gate, + ) + + for fn in [ + mcp._engine, + mcp._coached_engine, + mcp._governance_trail_records, + read_sei_attestations, + read_governance_for_sei, + read_governance_for_sei_gate, + governance_read_unavailable, + ]: + src = inspect.getsource(fn) + assert ".plainweave" not in src, f"{fn.__name__} references plainweave" diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 581430a..e703051 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -303,7 +303,9 @@ def test_initialize_and_tools_list_exposes_full_agent_surface(tmp_path): "policy_boundary_check", "posture_get", "warpline_preflight_get", + "plainweave_preflight_get", "attestation_get", + "governance_read", } # posture_get is the dedicated read-only posture surface (Phase 8); the # change gate (posture set) stays operator/CLI only — no posture_set tool. @@ -2131,6 +2133,7 @@ def test_warpline_tools_introduce_no_new_error_codes(tmp_path): runtime, _store = _runtime(tmp_path) assert not call_tool(runtime, "warpline_preflight_get", {"base": "x"}).get("isError") + assert not call_tool(runtime, "plainweave_preflight_get", {"base": "x"}).get("isError") assert not call_tool(runtime, "attestation_get", {"sei": "x#1"}).get("isError") @@ -3385,33 +3388,33 @@ def test_check_list_target_type_schema_declares_enum_matching_handler(tmp_path): def test_build_runtime_wires_warpline_from_env(monkeypatch, tmp_path): from legis.mcp import build_runtime - from legis.warpline_preflight.client import HttpWarplineClient + from legis.warpline_preflight.client import WarplineMcpClient - monkeypatch.setenv("WARPLINE_API_URL", "http://localhost:9100") + monkeypatch.setenv("WARPLINE_MCP_CMD", "echo") monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) # engine-only: no protected gate # NOTE: build_runtime(agent_id) takes ONLY agent_id (mcp.py:200); source root # and DBs are env-driven (LEGIS_SOURCE_ROOT, mcp.py:275). There is NO # source_root= kwarg — passing one raises TypeError before any assertion. runtime = build_runtime("agent-x") - assert isinstance(runtime.warpline, HttpWarplineClient) + assert isinstance(runtime.warpline, WarplineMcpClient) def test_build_runtime_leaves_warpline_unwired_without_env(monkeypatch, tmp_path): from legis.mcp import build_runtime - monkeypatch.delenv("WARPLINE_API_URL", raising=False) + monkeypatch.delenv("WARPLINE_MCP_CMD", raising=False) monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) runtime = build_runtime("agent-x") assert runtime.warpline is None -def test_build_runtime_degrades_warpline_to_none_on_bad_url(monkeypatch, tmp_path): - # A misconfigured ADVISORY url must NOT crash the sole governance authority +def test_build_runtime_degrades_warpline_to_none_on_bad_cmd(monkeypatch, tmp_path): + # A misconfigured ADVISORY command must NOT crash the sole governance authority # at startup; it degrades to no advisory context (governance unaffected). from legis.mcp import build_runtime - monkeypatch.setenv("WARPLINE_API_URL", "not-a-valid-url") + monkeypatch.setenv("WARPLINE_MCP_CMD", " ") # blank after shlex.split -> fail-safe None monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) runtime = build_runtime("agent-x") @@ -3433,12 +3436,25 @@ def test_warpline_preflight_get_unavailable_when_unwired(tmp_path): def test_warpline_preflight_get_checked_with_injected_client(tmp_path): from legis.mcp import call_tool + _impact = { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": {"completeness": "FULL", "affected": [{"sei": "S1", "depth": 1}]}, + "meta": {"local_only": True, "peer_side_effects": []}, + } + _reverify = { + "schema": "warpline.reverify_worklist.v1", + "ok": True, + "data": {"completeness": "FULL", "items": []}, + "meta": {"local_only": True, "peer_side_effects": []}, + } + class _FakeWarpline: def impact_radius(self, base, head): - return {"affected": [{"sei": "S1"}], "count": 1} + return _impact def reverify_worklist(self, base, head): - return {"entries": [], "count": 0} + return _reverify runtime, _store = _runtime(tmp_path) runtime.warpline = _FakeWarpline() @@ -3446,8 +3462,88 @@ def reverify_worklist(self, base, head): assert not result.get("isError") sc = result["structuredContent"] assert sc["status"] == "checked" - assert sc["impact_radius"] == {"affected": [{"sei": "S1"}], "count": 1} - assert sc["reverify_worklist"] == {"entries": [], "count": 0} + assert sc["impact_radius"] == _impact + assert sc["reverify_worklist"] == _reverify + + +# plainweave_preflight_get advisory sibling tool (mirrors warpline above) +# --------------------------------------------------------------------------- + + +def test_build_runtime_wires_plainweave_from_env(monkeypatch, tmp_path): + from legis.mcp import build_runtime + from legis.plainweave_preflight.client import PlainweaveMcpClient + + monkeypatch.setenv("PLAINWEAVE_MCP_CMD", "echo") + monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) + monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) + runtime = build_runtime("agent-x") + assert isinstance(runtime.plainweave, PlainweaveMcpClient) + + +def test_build_runtime_leaves_plainweave_unwired_without_env(monkeypatch, tmp_path): + from legis.mcp import build_runtime + + monkeypatch.delenv("PLAINWEAVE_MCP_CMD", raising=False) + monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) + runtime = build_runtime("agent-x") + assert runtime.plainweave is None + + +def test_build_runtime_degrades_plainweave_to_none_on_bad_cmd(monkeypatch, tmp_path): + # A misconfigured ADVISORY command must NOT crash the sole governance authority + # at startup; it degrades to no advisory context (governance unaffected). + from legis.mcp import build_runtime + + monkeypatch.setenv("PLAINWEAVE_MCP_CMD", " ") # blank after shlex.split -> fail-safe None + monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) + monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) + runtime = build_runtime("agent-x") + assert runtime.plainweave is None + + +def test_plainweave_preflight_get_unavailable_when_unwired(tmp_path): + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) # plainweave defaults to None + result = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa"}) + assert not result.get("isError") + assert result["structuredContent"] == { + "status": "unavailable", + "unavailable": [{"reason": "plainweave client not configured"}], + } + + +def test_plainweave_preflight_get_checked_with_injected_client(tmp_path): + from legis.mcp import call_tool + + _envelope = { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": True, + "data": { + "freshness": "partial", + "facts": [], + "authority_boundary": { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": False, + }, + }, + "warnings": [], + "meta": {}, + } + + class _FakePlainweave: + def preflight_facts(self, base, head): + return _envelope + + runtime, _store = _runtime(tmp_path) + runtime.plainweave = _FakePlainweave() + result = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"}) + assert not result.get("isError") + sc = result["structuredContent"] + assert sc["status"] == "checked" + assert sc["preflight_facts"] == _envelope # Task 5: attestation_get fail-closed scaffolding diff --git a/tests/mcp/test_warpline_advisory_boundary.py b/tests/mcp/test_warpline_advisory_boundary.py index 2864c63..de92cbe 100644 --- a/tests/mcp/test_warpline_advisory_boundary.py +++ b/tests/mcp/test_warpline_advisory_boundary.py @@ -17,6 +17,7 @@ from legis.enforcement.engine import EnforcementEngine from legis.policy.grammar import AllowlistBoundary, PolicyGrammar from legis.store.audit_store import AuditStore +from legis.warpline_preflight.client import WarplineMcpClient # --------------------------------------------------------------------------- @@ -72,13 +73,38 @@ def _runtime( class _HostileWarpline: - """Returns arbitrary/garbage advisory data to prove it cannot perturb a verdict.""" + """Returns envelopes with a GV-LG-3-VALID meta but hostile advisory payload. + + The hostile values are in ``data.affected``/``data.items`` — the advisory + payload that must NOT perturb a governance verdict. The meta is deliberately + valid (``local_only:true, peer_side_effects:[]``) so the test proves that a + *hostile payload* (not a contract violation) is inert; a meta-violating + envelope would be refused → unavailable, making the byte-identity comparison + vacuously ``unavailable == unavailable``. + """ def impact_radius(self, base, head): - return {"affected": [{"sei": "EVERYTHING"}], "count": 9999, "block": True} + return { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": { + "completeness": "FULL", + "affected": [{"sei": "EVERYTHING", "depth": 9999}], + "changed": [], + }, + "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "hostile"}}, + } def reverify_worklist(self, base, head): - return {"entries": [{"sei": "EVERYTHING", "reason": "force"}], "count": 9999} + return { + "schema": "warpline.reverify_worklist.v1", + "ok": True, + "data": { + "completeness": "FULL", + "items": [{"sei": "EVERYTHING", "reason": "force"}], + }, + "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "hostile"}}, + } def _seed_real_verdict_runtime(tmp_path): @@ -137,9 +163,47 @@ def test_governance_verdicts_byte_identical_warpline_unset_vs_hostile(tmp_path): runtime_set.warpline = _HostileWarpline() # structurally present, hostile setval = _run_governance_paths(runtime_set) + # GUARD: the hostile side must have actually reached status=="checked" — a + # _HostileWarpline that raises exceptions would produce "unavailable" on both + # sides and make the byte-identity assertion trivially pass while proving + # nothing. This side-assertion is NOT part of setval. + from legis.mcp import call_tool + pf = call_tool(runtime_set, "warpline_preflight_get", {"base": "aaa", "head": "bbb"}) + assert pf["structuredContent"]["status"] == "checked", ( + "_HostileWarpline returned unavailable — its envelope was rejected before " + "reaching the advisory layer; the byte-identity assertion is vacuous" + ) + assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True) +def test_gv_lg_3_invalid_meta_peer_side_effects_yields_unavailable(tmp_path): + """Positive GV-LG-3 pin: an envelope with non-empty peer_side_effects is + refused by WarplineMcpClient._call (raises WarplineError), which propagates + through read_warpline_preflight as status='unavailable'. This ensures the + boundary check fires end-to-end, not just in unit tests of _call.""" + from legis.mcp import call_tool + + invalid_meta_envelope = { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": {"completeness": "FULL", "affected": []}, + "meta": { + "local_only": True, + "peer_side_effects": ["some-peer"], # GV-LG-3 VIOLATION + "producer": {"tool": "warpline"}, + }, + } + runtime, _ = _runtime(tmp_path) + runtime.warpline = WarplineMcpClient( + invoke=lambda t, a: invalid_meta_envelope, repo="/r" + ) + pf = call_tool(runtime, "warpline_preflight_get", {"base": "aaa", "head": "bbb"}) + assert pf["structuredContent"]["status"] == "unavailable", ( + "GV-LG-3: a non-empty peer_side_effects must yield unavailable, not checked" + ) + + def test_runtime_warpline_referenced_in_no_verdict_path_function(): # STRUCTURAL (defense-in-depth): runtime.warpline must appear in NO # verdict-path / honesty-read source. NOTE inspect.getsource is a SHALLOW text @@ -164,11 +228,20 @@ def test_runtime_warpline_referenced_in_no_verdict_path_function(): ) # --- explicit: non-handler verdict internals not in _TOOL_HANDLERS --- + from legis.service.governance import ( + governance_read_unavailable, + read_governance_for_sei, + read_governance_for_sei_gate, + ) + for fn in [ mcp._engine, mcp._coached_engine, mcp._governance_trail_records, read_sei_attestations, + read_governance_for_sei, + read_governance_for_sei_gate, + governance_read_unavailable, ]: src = inspect.getsource(fn) assert ".warpline" not in src, f"{fn.__name__} references warpline" diff --git a/tests/plainweave_preflight/__init__.py b/tests/plainweave_preflight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plainweave_preflight/fixtures/PROVENANCE.md b/tests/plainweave_preflight/fixtures/PROVENANCE.md new file mode 100644 index 0000000..f6d0915 --- /dev/null +++ b/tests/plainweave_preflight/fixtures/PROVENANCE.md @@ -0,0 +1,62 @@ +# Plainweave preflight golden — provenance + +`plainweave-preflight-golden.json` freezes the one envelope shape Plainweave +emits from its ONLY implemented producer: + + * `plainweave_preflight_facts_get` → envelope schema `weft.plainweave.preflight_facts.v1` + (Plainweave ADR-006, `~/plainweave/docs/architecture/decisions/ADR-006-legis-preflight-fact-envelope.md`). + +The frozen envelope is the `commit_range` scope output over `HEAD~1..HEAD` with no +explicit requirement / entity / baseline subjects — i.e. the **representative** +output of the consumer's actual call (`read_plainweave_preflight` always invokes +`scope_kind="commit_range"` with `base`/`head` and no subjects). That call resolves +no facts locally (`facts: []`, `freshness: "partial"`) and carries the producer's +standard capability-gap warnings (`live_diff_resolution_unavailable`, +`linked_work_facts_unavailable`, `finding_facts_unavailable`). This mirrors the +warpline golden precedent, which was likewise empty (`affected: []`, `NO_SNAPSHOT`) +because the consumer passes the envelope through verbatim and never reads fact +contents — a rich-fact fixture would misrepresent the call and test nothing extra. + +The producer's boundary assertions live in `data.authority_boundary` +(`local_only: true, live_peer_calls: false, governance_verdicts: false`), NOT in +`meta` (Plainweave's `meta` has no `local_only`/`peer_side_effects` — those are +warpline's). Legis's `PlainweaveMcpClient._call` validates THOSE fields as the +GV-LG-3 fail-closed gate, plus the mandatory `data.freshness` / `data.facts`. + +## This golden is CONSTRUCTED, not live-captured — live capture is a pending follow-up + +`_provenance.source` is `"constructed-from-frozen-producer-contract"`: the golden +was built field-by-field from the frozen producer contract read in +`~/plainweave/src/plainweave/mcp_surface.py` +(`plainweave_preflight_facts_get`, `_preflight_scope`, `_preflight_warnings`, +`_preflight_summary`, `_preflight_freshness`, the `authority_boundary` block), +`~/plainweave/src/plainweave/envelopes.py` (`success_envelope`), and ADR-006 — +NOT from a live `plainweave-mcp` session. + +This is deliberate and honest: this consumer was built from the **hub** session, +whose MCP wiring is the hub's, not legis's plainweave wiring. A live MCP call from +here would misroute and yield a false verdict, so it was not attempted. The golden +does **not** claim `"live-captured"`; `test_golden_provenance_is_constructed_not_live` +pins that honesty marker. + +**Required follow-up (flagged to the hub):** in a legis-rooted session, run a live +`plainweave-mcp` capture of `plainweave_preflight_facts_get` +(`scope_kind="commit_range"`, real `base`/`head`), confirm the bytes match this +constructed golden field-for-field, and only then re-mark `_provenance.source` to +`"live-captured"` and re-pin `GOLDEN_BLOB_SHA`. + +## Legis conforms to Plainweave's frozen producer — additive, enrich-only + +Legis is a CONSUMER of Plainweave's already-frozen `weft.plainweave.preflight_facts.v1` +producer (ADR-006, registered + contract-tested on the Plainweave side). Legis's +`PlainweaveMcpClient._call` validates the envelope and passes it through verbatim; +it does not interpret, re-shape, or act on the facts. This creates **no Plainweave +obligation** — it ships solo per the federation change discipline (additive, +enrich-only). The read is a purely advisory SIBLING of the warpline advisory read +and the governance honesty reads; it never perturbs a Legis governance verdict. + +## Re-pinning the golden + +After a deliberate re-construction (or a live recapture), re-pin `GOLDEN_BLOB_SHA` +in `test_plainweave_preflight_oracle.py` to +`git hash-object tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json`. diff --git a/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json b/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json new file mode 100644 index 0000000..ed8b855 --- /dev/null +++ b/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json @@ -0,0 +1,91 @@ +{ + "preflight_facts": { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": true, + "data": { + "producer": { + "tool": "plainweave", + "version": "1.1.0", + "project": "legis" + }, + "scope": { + "kind": "commit_range", + "base": "HEAD~1", + "head": "HEAD", + "requirement_ids": [], + "entity_refs": [], + "baseline_id": null + }, + "generated_at": "2026-06-27T00:00:00+00:00", + "freshness": "partial", + "facts": [], + "summary": { + "info": 0, + "warn": 0, + "critical": 0, + "facts": 0, + "by_kind": {}, + "by_freshness": {} + }, + "warnings": [ + { + "code": "live_diff_resolution_unavailable", + "severity": "info", + "message": "Plainweave did not call Legis or Loomweave to resolve the live diff.", + "freshness": "unavailable", + "provenance": { + "producer": "plainweave", + "inputs": [] + } + }, + { + "code": "linked_work_facts_unavailable", + "severity": "info", + "message": "Filigree linked-work facts are not joined by this local-only producer.", + "freshness": "unavailable", + "provenance": { + "producer": "plainweave", + "inputs": [] + } + }, + { + "code": "finding_facts_unavailable", + "severity": "info", + "message": "Wardline finding facts are not joined by this local-only producer.", + "freshness": "unavailable", + "provenance": { + "producer": "plainweave", + "inputs": [] + } + } + ], + "provenance": { + "producer": "plainweave", + "inputs": [] + }, + "authority_boundary": { + "local_only": true, + "live_peer_calls": false, + "governance_verdicts": false, + "legis_policy_cells": "external" + } + }, + "warnings": [], + "meta": { + "producer": { + "tool": "plainweave", + "version": "1.1.0" + }, + "generated_at": "2026-06-27T00:00:00+00:00", + "project": "legis" + } + }, + "_provenance": { + "source": "constructed-from-frozen-producer-contract", + "constructed": "2026-06-27", + "plainweave_version": "1.1.0", + "producer_contract": "weft.plainweave.preflight_facts.v1 (ADR-006)", + "scope": "commit_range HEAD~1..HEAD with no requirement/entity/baseline subjects -> empty facts (the representative output of the consumer's actual call)", + "live_capture_status": "PENDING: a live end-to-end capture against a real plainweave-mcp is a required follow-up in a legis-rooted session (the hub session's MCP wiring misroutes plainweave calls)." + } +} diff --git a/tests/plainweave_preflight/test_client.py b/tests/plainweave_preflight/test_client.py new file mode 100644 index 0000000..760c98a --- /dev/null +++ b/tests/plainweave_preflight/test_client.py @@ -0,0 +1,116 @@ +import pytest +from legis.plainweave_preflight.client import ( + PlainweaveClient, + PlainweaveError, + PlainweaveMcpClient, +) + +_VALID_BOUNDARY = { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": False, + "legis_policy_cells": "external", +} +_KEEP = object() # sentinel: "use the valid default boundary" — DISTINCT from None (None IS a test case) + + +def _env(*, boundary=_KEEP, facts=_KEEP, freshness="partial", schema="weft.plainweave.preflight_facts.v1", ok=True): + data = { + "producer": {"tool": "plainweave", "version": "1.1.0", "project": "legis"}, + "scope": {"kind": "commit_range", "base": "aaa", "head": "bbb"}, + "generated_at": "2026-06-27T00:00:00+00:00", + "summary": {"info": 0, "warn": 0, "critical": 0, "facts": 0}, + "warnings": [], + "provenance": {"producer": "plainweave", "inputs": []}, + "authority_boundary": dict(_VALID_BOUNDARY) if boundary is _KEEP else boundary, + } + if freshness is not None: + data["freshness"] = freshness + if facts is not _KEEP: + if facts is not None: + data["facts"] = facts + else: + data["facts"] = [] + return {"schema": schema, "ok": ok, "data": data, "warnings": [], "meta": {}} + + +def _recorder(responses): + calls = [] + + def invoke(tool, arguments): + calls.append((tool, arguments)) + return responses.pop(0) + + invoke.calls = calls + return invoke + + +def test_protocol_is_runtime_checkable(): + assert isinstance(PlainweaveMcpClient(invoke=_recorder([{}]), repo="/tmp/r"), PlainweaveClient) + + +def test_preflight_calls_tool_with_commit_range_and_passes_envelope_through(): + e = _env(facts=[]) + inv = _recorder([e]) + out = PlainweaveMcpClient(invoke=inv, repo="/tmp/r").preflight_facts("aaa", "bbb") + assert out == e + # NO 'repo' arg — the producer signature is scope_kind/base/head/... + assert inv.calls[0] == ( + "plainweave_preflight_facts_get", + {"scope_kind": "commit_range", "base": "aaa", "head": "bbb"}, + ) + + +@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None]) +def test_non_dict_envelope_is_plainweave_error(bad): + with pytest.raises(PlainweaveError): + PlainweaveMcpClient(invoke=_recorder([bad]), repo="/tmp/r").preflight_facts("a", "b") + + +def test_wrong_schema_or_not_ok_is_plainweave_error(): + wrong = _env(schema="weft.plainweave.error.v1") # error envelope schema + with pytest.raises(PlainweaveError, match="schema"): + PlainweaveMcpClient(invoke=_recorder([wrong]), repo="/tmp/r").preflight_facts("a", "b") + notok = _env(ok=False) + with pytest.raises(PlainweaveError, match="ok"): + PlainweaveMcpClient(invoke=_recorder([notok]), repo="/tmp/r").preflight_facts("a", "b") + + +def test_gv_lg_3_hostile_or_malformed_boundary_is_refused_fail_closed(): + # A claimed live peer call (a side effect) is refused. + peer = _env(boundary={"local_only": True, "live_peer_calls": True, "governance_verdicts": False}) + with pytest.raises(PlainweaveError, match="live_peer_calls"): + PlainweaveMcpClient(invoke=_recorder([peer]), repo="/tmp/r").preflight_facts("a", "b") + # A producer that claims to emit governance verdicts is refused — the whole + # point of ADR-006 is that plainweave emits facts only. + verdict = _env(boundary={"local_only": True, "live_peer_calls": False, "governance_verdicts": True}) + with pytest.raises(PlainweaveError, match="governance_verdicts"): + PlainweaveMcpClient(invoke=_recorder([verdict]), repo="/tmp/r").preflight_facts("a", "b") + # local_only false / missing / non-dict boundaries all refuse. + for bad_boundary in ( + {"local_only": False, "live_peer_calls": False, "governance_verdicts": False}, + {"live_peer_calls": False, "governance_verdicts": False}, # missing local_only + "not-a-dict", + None, + 5, + ): + em = _env(boundary=bad_boundary) + with pytest.raises(PlainweaveError): + PlainweaveMcpClient(invoke=_recorder([em]), repo="/tmp/r").preflight_facts("a", "b") + + +def test_degraded_envelope_missing_facts_or_freshness_is_plainweave_error(): + # facts omitted -> degraded -> unavailable, not a bare empty 'checked'. + e = _env(facts=None) + with pytest.raises(PlainweaveError, match="freshness|facts"): + PlainweaveMcpClient(invoke=_recorder([e]), repo="/tmp/r").preflight_facts("a", "b") + # freshness omitted -> degraded. + e2 = _env(freshness=None) + with pytest.raises(PlainweaveError, match="freshness|facts"): + PlainweaveMcpClient(invoke=_recorder([e2]), repo="/tmp/r").preflight_facts("a", "b") + + +def test_non_dict_data_is_plainweave_error(): + bad = {"schema": "weft.plainweave.preflight_facts.v1", "ok": True, "data": "not-a-dict"} + with pytest.raises(PlainweaveError, match="data"): + PlainweaveMcpClient(invoke=_recorder([bad]), repo="/tmp/r").preflight_facts("a", "b") diff --git a/tests/plainweave_preflight/test_plainweave_preflight_oracle.py b/tests/plainweave_preflight/test_plainweave_preflight_oracle.py new file mode 100644 index 0000000..d442072 --- /dev/null +++ b/tests/plainweave_preflight/test_plainweave_preflight_oracle.py @@ -0,0 +1,237 @@ +"""Weft plainweave-preflight conformance oracle — Legis as consumer. + +Legis reads plainweave's ADVISORY preflight surface via +``legis.plainweave_preflight.client.PlainweaveMcpClient`` and +``legis.service.preflight.read_plainweave_preflight``. This oracle freezes the real +envelope shape plainweave emits (``weft.plainweave.preflight_facts.v1``, ADR-006) +and drives legis's REAL parse path over the frozen bytes, so a shape change fails +CI until legis updates the consumer. + +Mirrors ``tests/warpline_preflight/test_warpline_preflight_oracle.py``: + + * Layer-1 byte-pin (``test_golden_byte_pin``): UNMARKED, default-suite, + recomputes the git blob sha1 in-process and fails CLOSED on any byte drift. + * Non-circular consumer oracle (``test_golden_flows_through_the_real_parser``): + the frozen golden BYTES flow through legis's real ``PlainweaveMcpClient._call`` + (schema/ok/authority_boundary/freshness/facts validation) via a fake invoke + replaying the golden; assertions are HARDCODED literals, NEVER a re-parse. + * Layer-2 source recheck (``test_golden_matches_plainweave_source``): compares + the frozen golden to plainweave's contract fixture; SKIPS CLEAN when absent. + +PROVENANCE: unlike the warpline golden, this golden is CONSTRUCTED from the frozen +producer contract (ADR-006 + mcp_surface.py), NOT live-captured — the consumer was +built from the hub session whose MCP wiring misroutes plainweave. A live capture is +a flagged, required follow-up in a legis-rooted session. See ``fixtures/PROVENANCE.md``. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from legis.plainweave_preflight.client import PlainweaveMcpClient +from legis.service.preflight import read_plainweave_preflight + +FIX = Path(__file__).parent / "fixtures" +GOLDEN_PATH = FIX / "plainweave-preflight-golden.json" + +# git blob sha1 of tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json. +# Frozen to the constructed weft.plainweave.preflight_facts.v1 envelope (ADR-006). +# Update ONLY after a deliberate re-construction or a live re-capture from a real +# plainweave-mcp run; re-pinning is the trigger for a consumer-contract review. +# See fixtures/PROVENANCE.md. +GOLDEN_BLOB_SHA = "ed8b8559cafc9bc954b9748777bdd31711acd369" + +PREFLIGHT_SCHEMA = "weft.plainweave.preflight_facts.v1" + + +# --------------------------------------------------------------------------- +# Layer-1: fail-closed byte-pin (UNMARKED — runs in the default suite). +# --------------------------------------------------------------------------- +def _git_blob_sha1(data: bytes) -> str: + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def test_golden_byte_pin(): + data = GOLDEN_PATH.read_bytes() + assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, ( + "plainweave-preflight golden has drifted from its pinned bytes; update " + "GOLDEN_BLOB_SHA only after a deliberate re-construction / re-capture " + "(re-check PROVENANCE.md and re-run `git hash-object` on the new golden)." + ) + + +# --------------------------------------------------------------------------- +# Provenance honesty guard: this golden is CONSTRUCTED, not live-captured, and +# must say so — it must NOT falsely claim a live capture it never had. The live +# capture is the flagged follow-up, not a thing we fake (the inverse of the +# warpline oracle, which can force a real capture because warpline routes here). +# --------------------------------------------------------------------------- +def test_golden_provenance_is_constructed_not_live(): + golden = json.loads(GOLDEN_PATH.read_bytes()) + source = golden.get("_provenance", {}).get("source") + assert source == "constructed-from-frozen-producer-contract", ( + "this golden was built from the frozen ADR-006 producer contract, not a " + "live plainweave-mcp capture; its provenance must say so honestly." + ) + assert source != "live-captured", "must not falsely claim a live capture" + # The follow-up must stay visible until a real legis-rooted capture lands. + assert "PENDING" in golden["_provenance"]["live_capture_status"] + + +# --------------------------------------------------------------------------- +# Non-circular consumer oracle: golden BYTES -> PlainweaveMcpClient._call +# (schema/ok/authority_boundary/freshness/facts validated). Assertions are +# HARDCODED literals from the frozen golden — NEVER a re-parse of the golden. +# --------------------------------------------------------------------------- +def _dispatch_invoke(golden: dict): + """Return an invoke that replays the frozen preflight_facts envelope.""" + def invoke(tool: str, args: dict): + if tool == "plainweave_preflight_facts_get": + return golden["preflight_facts"] + raise AssertionError(f"unexpected tool in oracle: {tool!r}") + return invoke + + +def test_consumer_sends_commit_range_scope_with_no_repo_arg(): + """The producer tool takes NO 'repo' arg — its signature is scope_kind/base/ + head/... A blind warpline copy that sent {'repo':..,'rev_range':..} would be + rejected by the producer. Pin the exact arguments the consumer sends.""" + golden = json.loads(GOLDEN_PATH.read_bytes()) + seen: list[tuple] = [] + + def invoke(tool, args): + seen.append((tool, args)) + return golden["preflight_facts"] + + PlainweaveMcpClient(invoke=invoke, repo="/r").preflight_facts("base-sha", "head-sha") + assert seen[0] == ( + "plainweave_preflight_facts_get", + {"scope_kind": "commit_range", "base": "base-sha", "head": "head-sha"}, + ) + assert "repo" not in seen[0][1] + assert "rev_range" not in seen[0][1] + + +def test_golden_flows_through_the_real_parser_with_hardcoded_assertions(): + """Drive the FROZEN golden bytes through PlainweaveMcpClient._call (the real + schema/ok/authority_boundary/freshness/facts validation), via a fake invoke. + Assert HARDCODED values from the golden — NEVER a re-parse of the golden.""" + golden = json.loads(GOLDEN_PATH.read_bytes()) + + env = PlainweaveMcpClient( + invoke=lambda t, a: golden["preflight_facts"], repo="/r" + ).preflight_facts("b", "h") + + # Hardcoded schema — the real parse gate; any envelope rename breaks here. + assert env["schema"] == "weft.plainweave.preflight_facts.v1" + assert env["ok"] is True + # Hardcoded authority_boundary fields validated by _call (GV-LG-3 boundary). + boundary = env["data"]["authority_boundary"] + assert boundary["local_only"] is True + assert boundary["live_peer_calls"] is False + assert boundary["governance_verdicts"] is False + # Hardcoded data shape — empty facts and the partial freshness from commit_range. + assert env["data"]["freshness"] == "partial" + assert env["data"]["facts"] == [] + assert env["data"]["producer"]["tool"] == "plainweave" + assert env["data"]["scope"]["kind"] == "commit_range" + + +def test_read_plainweave_preflight_over_golden_is_checked(): + """The full service read: discriminated 'checked' with the facts envelope, + parsed through legis's real PlainweaveMcpClient._call over the frozen golden. + Assertions are hardcoded — not a re-parse of the golden.""" + golden = json.loads(GOLDEN_PATH.read_bytes()) + client = PlainweaveMcpClient(invoke=_dispatch_invoke(golden), repo="/r") + result = read_plainweave_preflight(client, "base-sha", "head-sha") + + # Hardcoded status discriminant. + assert result["status"] == "checked" + # Hardcoded sub-response schema and boundary — the validation was real. + assert result["preflight_facts"]["schema"] == "weft.plainweave.preflight_facts.v1" + assert result["preflight_facts"]["data"]["authority_boundary"]["local_only"] is True + assert result["preflight_facts"]["data"]["authority_boundary"]["governance_verdicts"] is False + assert result["preflight_facts"]["data"]["freshness"] == "partial" + assert result["preflight_facts"]["data"]["facts"] == [] + + +# --------------------------------------------------------------------------- +# Layer-2: cross-repo CONSUMER conformance vs plainweave's OWN producer golden +# (skip-clean when no sibling plainweave checkout). +# +# Plainweave vendors its own byte-pinned, live-producer-rechecked golden for the +# weft.plainweave.preflight_facts.v1 envelope at +# ``tests/fixtures/contracts/legis/preflight-facts.json``. That golden is the +# producer's ``{"schema": ..., **data}`` payload (NOT the full MCP envelope: no +# ``ok``/``data``/``meta`` wrapper) and exercises the RICH pending_diff scenario +# — all nine fact kinds. This check wraps THAT payload into the full MCP envelope +# and drives it through legis's REAL ``PlainweaveMcpClient._call`` + +# ``read_plainweave_preflight``, asserting the consumer parses plainweave's own +# contract-tested output. If plainweave's producer shape drifts (a fact kind, +# section, or the authority_boundary triad changes) in a way that breaks the +# consumer parse, this reds when a sibling plainweave checkout is present. +# --------------------------------------------------------------------------- +def _plainweave_source_fixture() -> Path | None: + relative = Path("tests") / "fixtures" / "contracts" / "legis" / "preflight-facts.json" + candidates: list[Path] = [] + if env := os.environ.get("PLAINWEAVE_REPO"): + candidates.append(Path(env) / relative) + candidates.append(Path(__file__).resolve().parents[3] / "plainweave" / relative) + return next((path for path in candidates if path.exists()), None) + + +def _envelope_from_producer_payload(payload: dict) -> dict: + """Reconstruct the full MCP success-envelope from plainweave's vendored + ``{"schema": ..., **data}`` producer payload (the shape its wire golden pins). + Mirrors ``plainweave.envelopes.success_envelope`` so the consumer sees exactly + what the MCP tool would return over the wire.""" + data = {key: value for key, value in payload.items() if key != "schema"} + return { + "schema": payload["schema"], + "ok": True, + "data": data, + "warnings": [], + "meta": { + "producer": {"tool": "plainweave", "version": data["producer"]["version"]}, + "generated_at": data["generated_at"], + "project": data["producer"].get("project"), + }, + } + + +def test_consumer_parses_plainweave_own_producer_golden(): + source = _plainweave_source_fixture() + if source is None: + pytest.skip( + "no sibling plainweave checkout — plainweave's producer golden lives at " + "/tests/fixtures/contracts/legis/preflight-facts.json (its own " + "byte-pinned, live-producer-rechecked fixture). Set PLAINWEAVE_REPO or place " + "a sibling plainweave checkout to enable this cross-repo consumer-conformance " + "check. See fixtures/PROVENANCE.md." + ) + payload = json.loads(source.read_text(encoding="utf-8")) + # Sanity: this IS the preflight-facts producer payload (schema+data shape). + assert payload["schema"] == PREFLIGHT_SCHEMA + assert "ok" not in payload and "data" not in payload # flattened, not the full envelope + + envelope = _envelope_from_producer_payload(payload) + client = PlainweaveMcpClient(invoke=lambda t, a: envelope, repo="/r") + result = read_plainweave_preflight(client, "main", "WORKTREE") + + # The consumer's REAL parser ACCEPTS plainweave's own contract-tested output: + # status checked, boundary validated, the rich 9-fact payload passed through. + assert result["status"] == "checked", ( + "legis's consumer parser rejected plainweave's own producer golden — the " + "weft.plainweave.preflight_facts.v1 producer shape drifted from what the " + "consumer validates (schema/ok/authority_boundary/freshness/facts)." + ) + parsed = result["preflight_facts"]["data"] + assert parsed["authority_boundary"]["local_only"] is True + assert parsed["authority_boundary"]["governance_verdicts"] is False + assert len(parsed["facts"]) == 9 # the rich pending_diff scenario, verbatim + kinds = {fact["kind"] for fact in parsed["facts"]} + assert "untraced_change" in kinds and "baseline_drift" in kinds diff --git a/tests/plainweave_preflight/test_stdio_invoke.py b/tests/plainweave_preflight/test_stdio_invoke.py new file mode 100644 index 0000000..a2a0d33 --- /dev/null +++ b/tests/plainweave_preflight/test_stdio_invoke.py @@ -0,0 +1,132 @@ +"""Tests for StdioMcpInvoke — the production stdio JSON-RPC transport. + +Fault paths: every transport/parse fault must raise PlainweaveError (fail closed). +The total exception conversion here is what lets read_plainweave_preflight catch +ONLY PlainweaveError and still guarantee "never escapes as INTERNAL_ERROR". + +NOTE: unlike the warpline transport test, there is no real captured plainweave-mcp +session fixture — a live capture is a flagged follow-up in a legis-rooted session +(the hub session's MCP wiring misroutes plainweave). These tests exercise the real +message order + result shape against a fake server, which is sufficient for the +transport contract; the live capture validates the end-to-end seam. +""" +import sys +import textwrap + +import pytest + +from legis.plainweave_preflight.client import StdioMcpInvoke, PlainweaveError + +_PREFLIGHT_ENV = ( + '{"schema":"weft.plainweave.preflight_facts.v1","ok":true,' + '"data":{"freshness":"partial","facts":[],' + '"authority_boundary":{"local_only":true,"live_peer_calls":false,"governance_verdicts":false}},' + '"warnings":[],"meta":{}}' +) + + +def _script(tmp_path, body): + p = tmp_path / "fake.py" + p.write_text(textwrap.dedent(body)) + return [sys.executable, str(p)] + + +_OK = f''' + import sys, json + for line in sys.stdin: + m = json.loads(line); mid = m.get("id") + if m.get("method") == "initialize": + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True) + elif m.get("method") == "tools/call": + env = json.loads({_PREFLIGHT_ENV!r}) + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":"{{}}"}}],"structuredContent":env,"isError":False}}}}), flush=True) +''' + + +def test_round_trips_against_fake_server(tmp_path): + env = StdioMcpInvoke(command=_script(tmp_path, _OK))( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + assert env["schema"] == "weft.plainweave.preflight_facts.v1" + assert env["data"]["facts"] == [] + assert env["data"]["authority_boundary"]["local_only"] is True + + +def test_content_text_fallback_parse(tmp_path): + """When structuredContent is absent, StdioMcpInvoke must parse the envelope + from content[0].text.""" + env_file = tmp_path / "envelope.json" + env_file.write_text(_PREFLIGHT_ENV, encoding="utf-8") + body = f''' + import sys, json + env_text = open({str(env_file)!r}, encoding="utf-8").read() + for line in sys.stdin: + m = json.loads(line); mid = m.get("id") + if m.get("method") == "initialize": + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True) + elif m.get("method") == "tools/call": + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":env_text}}],"isError":False}}}}), flush=True) + ''' + env = StdioMcpInvoke(command=_script(tmp_path, body))( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + assert env["schema"] == "weft.plainweave.preflight_facts.v1" + assert env["data"]["freshness"] == "partial" + + +@pytest.mark.parametrize("body,match", [ + ('import sys\n', "no JSON-RPC response"), # empty stdout + ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error +]) +def test_fault_paths_all_raise_plainweave_error(tmp_path, body, match): + with pytest.raises(PlainweaveError, match=match): + StdioMcpInvoke(command=_script(tmp_path, body))( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + + +def test_no_usable_envelope_is_plainweave_error(tmp_path): + # result has neither structuredContent nor a content[].text block. + body = ( + 'import json,sys\n' + 'for line in sys.stdin:\n' + ' m=json.loads(line); mid=m.get("id")\n' + ' if m.get("method")=="tools/call":\n' + ' print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[],"isError":False}}), flush=True)\n' + ) + with pytest.raises(PlainweaveError, match="no usable envelope"): + StdioMcpInvoke(command=_script(tmp_path, body))( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + + +def test_missing_executable_is_plainweave_error(tmp_path): + with pytest.raises(PlainweaveError): + StdioMcpInvoke(command=[str(tmp_path / "nope")])( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + + +def test_empty_command_is_plainweave_error(): + with pytest.raises(PlainweaveError, match="empty"): + StdioMcpInvoke(command=[])( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + + +def test_timeout_is_plainweave_error(tmp_path): + with pytest.raises(PlainweaveError): + StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) + + +def test_oversize_stdout_is_plainweave_error(tmp_path): + body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n' + with pytest.raises(PlainweaveError, match="too large"): + StdioMcpInvoke(command=_script(tmp_path, body))( + "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"} + ) diff --git a/tests/posture/test_ledger.py b/tests/posture/test_ledger.py index c29d82c..cd8721a 100644 --- a/tests/posture/test_ledger.py +++ b/tests/posture/test_ledger.py @@ -125,14 +125,16 @@ def test_read_floor_ignores_metadata_floor_field(tmp_path): assert ledger.read_floor() == "protected" -def test_read_floor_uses_tail_read(tmp_path, monkeypatch): +def test_read_floor_verifies_integrity_before_returning_floor(tmp_path): + """read_floor() gates on verify_integrity() before the tail scan: on a valid + chain the gate passes and the floor is returned. Supersedes the old + 'read_floor must not call read_all' guard — the integrity-before-trust gate + DELIBERATELY calls read_all via verify_integrity; that property is RETIRED, + not regressed. (legis-476ab6f125.) + """ ledger = PostureLedger(_url(tmp_path), initialize=True) ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0") - - def _boom(): - raise AssertionError("read_floor must not call read_all (hot path)") - - monkeypatch.setattr(ledger.store, "read_all", _boom) + assert ledger.store.verify_integrity() is True assert ledger.read_floor() == "chill" diff --git a/tests/posture/test_security_honesty.py b/tests/posture/test_security_honesty.py index ae844e6..a374a6c 100644 --- a/tests/posture/test_security_honesty.py +++ b/tests/posture/test_security_honesty.py @@ -19,7 +19,9 @@ from __future__ import annotations import hashlib +import json import logging +import sqlite3 import pytest @@ -29,6 +31,7 @@ from legis.posture.ledger import ( REFUSED_NO_SESSION, PostureLedger, + _sqlite_file, set_floor, ) from legis.posture.records import KIND_KEY_RESET, KIND_TRANSITION @@ -390,3 +393,120 @@ def test_operator_key_never_in_logs(caplog): assert key_hex not in caplog.text, backend_id finally: del os.environ[_OPERATOR_KEY_ENV] + + +# -- test_read_floor_fails_closed_on_integrity_break -------------------------- + + +def test_read_floor_fails_closed_on_integrity_break(tmp_path): + """A raw-DB tail record that lowers the floor but breaks the keyless hash + chain must NOT be trusted: read_floor() fails closed (returns None -> + structured), never the forged floor. (legis-476ab6f125; PRD-0005 crit 1.) + + Uses the canonical raw-file-write attacker model (sqlite3.connect, as in + tests/store/test_audit_store.py:22,78), not the ORM. In-place-edit / reorder + / seq-gap tamper is already pinned for the same gate at + tests/store/test_audit_store.py:78 and :246; this test exercises the + tail-append vector through read_floor specifically. + """ + key_hex = mint_key() + key_bytes = bytes.fromhex(key_hex) + ledger, _ = _genesis(tmp_path, key_hex=key_hex) # GENESIS @ chill + + # Elevate to protected via a signed transition so a downgrade is visible. + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) + set_floor( + "protected", ledger=ledger, signer=_MemSigner(key_bytes), + agent_id="op", rationale="tighten", clock=FixedClock("t1"), + ) + assert ledger.read_floor() == "protected" + + # Simulate a raw-file-write attacker: append a tail row claiming + # floor="chill" with a BROKEN chain (garbage hashes). INSERT is allowed: + # the append-only triggers block only UPDATE/DELETE. + head_seq, _ = ledger.store.get_latest_sequence_and_hash() + forged = { + "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": None, + "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9", + "rationale": "forged", "session_id": None, + } + conn = sqlite3.connect(str(_sqlite_file(ledger._url))) + try: + conn.execute( + "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, " + "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)", + { + "seq": head_seq + 1, "payload": json.dumps(forged), + "ch": "0" * 64, # does NOT match payload -> verify_integrity fails + "ph": "0" * 64, "xh": "0" * 64, + }, + ) + conn.commit() + finally: + conn.close() + + # Pre-fix: the descending scan returns the forged "chill". Post-fix: the + # broken chain fails verify_integrity, so read_floor fails closed. + assert ledger.read_floor() is None + + +# -- test_read_floor_recomputed_chain_forgery_is_conceded_residual ------------ + + +def test_read_floor_recomputed_chain_forgery_is_conceded_residual(tmp_path): + """DOCUMENTS the limit so it is visible in the suite, not implied-closed. + + A file-write attacker who *recomputes* the KEYLESS chain (valid content_hash, + prev_hash, chain_hash) on a forged floor-lowering TRANSITION passes + verify_integrity() — the keyless hot read CANNOT detect this. On a + non-rekeyed ledger it is caught by NEITHER this hot read NOR `doctor`: + doctor's operator_sig verification (_transition_acknowledges) runs ONLY on + the KEY_RESET-acknowledgment path, and there is no KEY_RESET here. It is the + conceded raw-file-write residual (README "Known security limitations", + README.md:137). A general per-transition operator_sig audit in doctor would + close it operator-side (separate follow-up). If a future change makes + read_floor reject this, that is a STRENGTHENING: update this test + deliberately — do not let it silently flip for the wrong reason. + """ + from legis.canonical import canonical_json, content_hash + # Recompute the chain with the PRODUCTION primitive so the residual is not + # undermined by a divergent re-implementation (precedent: test_audit_store.py:10). + from legis.store.audit_store import _chain + + key_hex = mint_key() + key_bytes = bytes.fromhex(key_hex) + ledger, _ = _genesis(tmp_path, key_hex=key_hex) + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) + set_floor( + "protected", ledger=ledger, signer=_MemSigner(key_bytes), + agent_id="op", rationale="tighten", clock=FixedClock("t1"), + ) + assert ledger.read_floor() == "protected" + + # Forge a floor-lowering TRANSITION with a CORRECTLY recomputed keyless chain. + head_seq, head_chain = ledger.store.get_latest_sequence_and_hash() + forged = { + "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": "junk", + "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9", + "rationale": "forged", "session_id": None, + } + c_hash = content_hash(forged) # correct keyless content hash + chain_hash = _chain(head_chain, c_hash) # correct keyless chain link + conn = sqlite3.connect(str(_sqlite_file(ledger._url))) + try: + conn.execute( + "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, " + "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)", + { + "seq": head_seq + 1, "payload": canonical_json(forged), + "ch": c_hash, "ph": head_chain, "xh": chain_hash, + }, + ) + conn.commit() + finally: + conn.close() + + # Integrity holds (keyless chain valid) -> the keyless read is fooled. + # This asserts the DOCUMENTED RESIDUAL, not a desired guarantee. + assert ledger.store.verify_integrity() is True + assert ledger.read_floor() == "chill" diff --git a/tests/service/test_governance_read.py b/tests/service/test_governance_read.py new file mode 100644 index 0000000..5eada81 --- /dev/null +++ b/tests/service/test_governance_read.py @@ -0,0 +1,284 @@ +"""Task 2 tests: read_governance_for_sei, read_governance_for_sei_gate, +governance_read_unavailable. + +TDD red→green. All imports of the new names happen inside the test bodies or +at module level — either way the ImportError on a missing symbol is "the right +failure reason" for Step 2 of the TDD cycle. +""" + +import pytest + +from legis.clock import FixedClock +from legis.enforcement.protected import ProtectedGate +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.identity.entity_key import EntityKey +from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError +from legis.service.governance import ( + governance_read_unavailable, + read_governance_for_sei, + read_governance_for_sei_gate, +) +from legis.store.audit_store import AuditStore + +# ── shared fixtures ────────────────────────────────────────────────────────── + +_SEI = "loomweave:eid:test-sei" +_CLOCK = FixedClock("2026-06-02T12:00:00+00:00") + + +class _AcceptJudge: + def evaluate(self, record): + return JudgeOpinion(verdict=Verdict.ACCEPTED, model="judge@1", rationale="ok") + + +def _stable_key(sei=_SEI): + return EntityKey(value=sei, identity_stable=True) + + +def _operator_override_gate(tmp_path, *, sei=_SEI, content_hash="ch:override-content"): + """Build a genuine ProtectedGate, submit an operator_override, return (gate, store, result).""" + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + gate = ProtectedGate(store, _CLOCK, judge=_AcceptJudge(), key=b"protected-key") + result = gate.operator_override( + policy="protected.x", + entity_key=_stable_key(sei), + rationale="operator clears", + operator_id="operator-1", + file_fingerprint="sha256:ff", + ast_path="ap", + extensions={"loomweave": {"content_hash": content_hash}}, + ) + return gate, store, result + + +# ── positive projection: operator_override ─────────────────────────────────── + + +def test_projects_override_to_clearance_record(tmp_path): + """A genuine signed operator_override maps to the 7-field clearance record. + + posture must be "protected_override" (the provable mechanism). + Coupling note: read_sei_attestations admits the record whose + judge_verdict == Verdict.OVERRIDDEN_BY_OPERATOR.value; the posture map + key is "operator_override" (the kind string, not the enum constant). + """ + _, store, _ = _operator_override_gate(tmp_path, content_hash="ch:override-content") + out = read_governance_for_sei(store.read_all(), _SEI) + assert out["status"] == "checked" + assert out["sei"] == _SEI + assert len(out["records"]) == 1 + rec = out["records"][0] + assert rec == { + "sei": _SEI, + "disposition": "cleared", + "posture": "protected_override", + "authority": "operator", + "as_of": "2026-06-02T12:00:00+00:00", + "reasons": ["operator_override"], + "content_hash": "ch:override-content", + } + + +# ── positive projection: signoff_cleared (monkeypatched) ──────────────────── + + +def test_signoff_projection_full_dict(monkeypatch): + """Monkeypatch read_sei_attestations to a signoff_cleared attestation; assert + all 7 clearance record fields (disposition/posture/authority/as_of/reasons/content_hash). + Patching legis.service.governance.read_sei_attestations — the call-site namespace. + """ + fake_attestations = { + "status": "checked", + "sei": _SEI, + "attestations": [ + { + "kind": "signoff_cleared", + "content_hash": "ch:signoff-content", + "recorded_at": "2026-06-02T12:00:00+00:00", + "seq": 2, + "signoff_seq": 1, + } + ], + } + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake_attestations, + ) + out = read_governance_for_sei([], _SEI) + assert out["status"] == "checked" + assert len(out["records"]) == 1 + rec = out["records"][0] + assert rec == { + "sei": _SEI, + "disposition": "cleared", + "posture": "operator_signoff", + "authority": "operator", + "as_of": "2026-06-02T12:00:00+00:00", + "reasons": ["signoff_cleared"], + "content_hash": "ch:signoff-content", + } + + +# ── honest absence: verified trail, no clearance ──────────────────────────── + + +def test_verified_but_no_clearance_is_checked_empty(): + """An empty (or non-matching) verified trail → status:'checked', records:[] (NOT unavailable).""" + out = read_governance_for_sei([], _SEI) + assert out == {"status": "checked", "sei": _SEI, "records": []} + + +# ── as_of guard: omit records with absent or non-RFC3339 timestamp ─────────── + + +def test_as_of_none_is_omitted(monkeypatch): + """An attestation with recorded_at: None must be OMITTED, not emitted with as_of:null.""" + fake = { + "status": "checked", + "sei": _SEI, + "attestations": [ + { + "kind": "operator_override", + "content_hash": "ch:x", + "recorded_at": None, + "seq": 1, + } + ], + } + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake, + ) + out = read_governance_for_sei([], _SEI) + assert out["records"] == [] + + +def test_as_of_non_rfc3339_is_omitted(monkeypatch): + """An attestation with a garbage recorded_at string must be OMITTED.""" + fake = { + "status": "checked", + "sei": _SEI, + "attestations": [ + { + "kind": "operator_override", + "content_hash": "ch:x", + "recorded_at": "garbage", + "seq": 1, + } + ], + } + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake, + ) + out = read_governance_for_sei([], _SEI) + assert out["records"] == [] + + +# ── unknown kind: omit, never crash ───────────────────────────────────────── + + +def test_unknown_kind_is_omitted(monkeypatch): + """An attestation with an unrecognised kind must be OMITTED, not raise KeyError.""" + fake = { + "status": "checked", + "sei": _SEI, + "attestations": [ + { + "kind": "future_kind", # _POSTURE_BY_KIND.get returns None → omit + "content_hash": "ch:x", + "recorded_at": "2026-06-02T12:00:00+00:00", + "seq": 1, + } + ], + } + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake, + ) + out = read_governance_for_sei([], _SEI) + assert out["records"] == [] + + +# ── status propagation guard ───────────────────────────────────────────────── + + +def test_status_propagated_not_hardcoded(monkeypatch): + """Happy path: read_sei_attestations status='checked' propagates through. + Guard path: a non-'checked' status raises AuditIntegrityError rather than + being silently relabelled 'checked'. Both branches must be exercised so the + guard is actually covered. + """ + # Happy path: checked propagates + fake_checked = {"status": "checked", "sei": _SEI, "attestations": []} + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake_checked, + ) + out = read_governance_for_sei([], _SEI) + assert out["status"] == "checked" + + # Guard path: a future non-checked status must not be silently relabelled + fake_other = {"status": "unavailable", "sei": _SEI, "attestations": []} + monkeypatch.setattr( + "legis.service.governance.read_sei_attestations", + lambda records, sei: fake_other, + ) + with pytest.raises(AuditIntegrityError, match="unexpected status"): + read_governance_for_sei([], _SEI) + + +# ── gate: signature tamper → AuditIntegrityError ──────────────────────────── + + +def test_gate_raises_on_signature_tamper(tmp_path): + """FORGE-A: mutate a signed field (judge_verdict) after the gate writes the + signature → TrailVerifier.verify raises TamperError → service gate wraps it + in AuditIntegrityError. Tests the service-gate wrapper, NOT a hand-rolled + TrailVerifier call (Constraint 6). + """ + _, store, _ = _operator_override_gate(tmp_path) + records = store.read_all() + # Corrupt the signed verdict field (FORGE-A pattern from test_governance.py) + records[0].payload["extensions"]["judge_verdict"] = "BLOCKED" + + with pytest.raises(AuditIntegrityError): + read_governance_for_sei_gate( + records, + _SEI, + hmac_key="protected-key", + protected_policies=frozenset({"protected.x"}), + ) + + +# ── gate: no key for protected records → ProtectedKeyRequiredError ─────────── + + +def test_gate_requires_key_for_protected(tmp_path): + """A protected record (protected_cell:True + judge_metadata_signature) present + without hmac_key → ProtectedKeyRequiredError, even when protected_policies is + empty (the discriminator fires on the signature marker, not the policy name). + """ + _, store, _ = _operator_override_gate(tmp_path) + records = store.read_all() + with pytest.raises(ProtectedKeyRequiredError): + read_governance_for_sei_gate( + records, + _SEI, + hmac_key=None, + protected_policies=frozenset(), + ) + + +# ── unavailable helper shape ───────────────────────────────────────────────── + + +def test_unavailable_helper_shape(): + """governance_read_unavailable builds the discriminated unavailable envelope.""" + out = governance_read_unavailable(_SEI, "trail not verifiable") + assert out == { + "status": "unavailable", + "sei": _SEI, + "records": [], + "unavailable": [{"reason": "trail not verifiable"}], + } diff --git a/tests/service/test_preflight.py b/tests/service/test_preflight.py index a7538c4..3de86e7 100644 --- a/tests/service/test_preflight.py +++ b/tests/service/test_preflight.py @@ -1,13 +1,42 @@ -from legis.service.preflight import read_warpline_preflight +from legis.plainweave_preflight.client import PlainweaveError +from legis.service.preflight import read_plainweave_preflight, read_warpline_preflight from legis.warpline_preflight.client import WarplineError +_IMPACT_ENVELOPE = { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": {"completeness": "FULL", "affected": [{"sei": "S1", "depth": 1}]}, + "meta": {"local_only": True, "peer_side_effects": []}, +} + +_REVERIFY_ENVELOPE = { + "schema": "warpline.reverify_worklist.v1", + "ok": True, + "data": {"completeness": "FULL", "items": [{"sei": "S1", "reason": "edited"}]}, + "meta": {"local_only": True, "peer_side_effects": []}, +} + +_EMPTY_REVERIFY = { + "schema": "warpline.reverify_worklist.v1", + "ok": True, + "data": {"completeness": "FULL", "items": []}, + "meta": {"local_only": True, "peer_side_effects": []}, +} + +_EMPTY_IMPACT = { + "schema": "warpline.impact_radius.v1", + "ok": True, + "data": {"completeness": "FULL", "affected": []}, + "meta": {"local_only": True, "peer_side_effects": []}, +} + class _OkWarpline: def impact_radius(self, base, head): - return {"affected": [{"sei": "S1"}], "count": 1} + return _IMPACT_ENVELOPE def reverify_worklist(self, base, head): - return {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1} + return _REVERIFY_ENVELOPE class _ImpactRaisesWarpline: @@ -15,12 +44,12 @@ def impact_radius(self, base, head): raise WarplineError("boom") def reverify_worklist(self, base, head): - return {"entries": [], "count": 0} + return _EMPTY_REVERIFY class _WorklistRaisesWarpline: def impact_radius(self, base, head): - return {"affected": [], "count": 0} + return _EMPTY_IMPACT def reverify_worklist(self, base, head): raise WarplineError("timeout") @@ -30,8 +59,8 @@ def test_checked_when_both_methods_succeed(): out = read_warpline_preflight(_OkWarpline(), "aaa", "bbb") assert out == { "status": "checked", - "impact_radius": {"affected": [{"sei": "S1"}], "count": 1}, - "reverify_worklist": {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1}, + "impact_radius": _IMPACT_ENVELOPE, + "reverify_worklist": _REVERIFY_ENVELOPE, } @@ -61,3 +90,82 @@ def test_warpline_error_never_escapes_as_internal_error(): # The transport error is caught and converted, never re-raised. out = read_warpline_preflight(_ImpactRaisesWarpline(), "aaa", "bbb") assert out["status"] == "unavailable" # no exception propagated + + +# --------------------------------------------------------------------------- +# read_plainweave_preflight — advisory SIBLING of read_warpline_preflight. +# --------------------------------------------------------------------------- + +_PLAINWEAVE_ENVELOPE = { + "schema": "weft.plainweave.preflight_facts.v1", + "ok": True, + "data": { + "freshness": "partial", + "facts": [], + "authority_boundary": { + "local_only": True, + "live_peer_calls": False, + "governance_verdicts": False, + }, + }, + "warnings": [], + "meta": {}, +} + + +class _OkPlainweave: + def preflight_facts(self, base, head): + return _PLAINWEAVE_ENVELOPE + + +class _RaisesPlainweave: + def preflight_facts(self, base, head): + raise PlainweaveError("boom") + + +def test_plainweave_checked_when_method_succeeds(): + out = read_plainweave_preflight(_OkPlainweave(), "aaa", "bbb") + assert out == { + "status": "checked", + "preflight_facts": _PLAINWEAVE_ENVELOPE, + } + + +def test_plainweave_unavailable_when_client_is_none_not_a_silent_empty(): + out = read_plainweave_preflight(None, "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"] == [{"reason": "plainweave client not configured"}] + # ASYMMETRIC: never an empty fact-set that reads as "nothing impacted". + assert "preflight_facts" not in out + + +def test_plainweave_unavailable_when_preflight_raises_plainweave_error(): + out = read_plainweave_preflight(_RaisesPlainweave(), "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"][0]["reason"].startswith("plainweave check failed:") + + +def test_plainweave_error_never_escapes_as_internal_error(): + # The transport/contract error is caught and converted, never re-raised. + out = read_plainweave_preflight(_RaisesPlainweave(), "aaa", "bbb") + assert out["status"] == "unavailable" # no exception propagated + + +def test_plainweave_honest_degrade_on_error_envelope_from_uninitialized_project(): + # The most likely real-world degrade: an uninitialized plainweave returns an + # ok:false error envelope (schema weft.plainweave.error.v1). The consumer's + # PlainweaveMcpClient._call rejects it -> PlainweaveError -> 'unavailable', + # NOT 'checked' and NOT a raised INTERNAL_ERROR. + from legis.plainweave_preflight.client import PlainweaveMcpClient + + error_envelope = { + "schema": "weft.plainweave.error.v1", + "ok": False, + "error": {"code": "not_found", "message": "Plainweave project is not initialized"}, + "warnings": [], + "meta": {}, + } + client = PlainweaveMcpClient(invoke=lambda t, a: error_envelope, repo="/r") + out = read_plainweave_preflight(client, "aaa", "bbb") + assert out["status"] == "unavailable" + assert "preflight_facts" not in out diff --git a/tests/warpline_preflight/fixtures/PROVENANCE.md b/tests/warpline_preflight/fixtures/PROVENANCE.md index 1d590ec..f85edd7 100644 --- a/tests/warpline_preflight/fixtures/PROVENANCE.md +++ b/tests/warpline_preflight/fixtures/PROVENANCE.md @@ -1,43 +1,49 @@ # Warpline preflight golden — provenance -`warpline-preflight-golden.json` freezes the two response shapes legis's real -warpline preflight consumer parses: +`warpline-preflight-golden.json` freezes the two envelope shapes warpline emits +via its MCP surface, live-captured from `warpline-mcp` (version 1.2.0): - * `GET /api/impact-radius` -> `{"affected": [{"sei": ...}, ...], "count": N}` - * `GET /api/reverify-worklist` -> `{"entries": [{"sei": ...}, ...], "count": N}` + * `warpline_impact_radius_get` → envelope schema `warpline.impact_radius.v1` + * `warpline_reverify_worklist_get` → envelope schema `warpline.reverify_worklist.v1` -These are the shapes `legis.warpline_preflight.client.HttpWarplineClient` -(`impact_radius` / `reverify_worklist`) and `legis.service.preflight.read_warpline_preflight` -expect today. +Both captured against the legis repo, rev_range `HEAD~1..HEAD`, on 2026-06-27. +The capture produced `completeness: "NO_SNAPSHOT"` and empty `affected`/`items` +lists (no Loomweave snapshot in place at capture time). The meta fields +`local_only: true, peer_side_effects: []` are present and valid. -## NOT vendored from warpline — frozen to the legis-expected contract +## Live-capture transcripts -This golden is **frozen to the shape legis's client expects**, NOT vendored -byte-identical from a warpline source fixture. As of this writing warpline ships -**no producer** for these flat REST shapes: +Both halves of the golden are backed by committed raw MCP session transcripts +(three JSON-RPC lines each: id=1 initialize response, id=null +notifications/initialized error, id=2 tools/call response): - * warpline has **no HTTP server**; its surface is MCP/CLI only. - * warpline's `impact_radius` / `reverify_worklist` commands return the rich - envelope schemas `warpline.impact_radius.v1` / `warpline.reverify_worklist.v1` - (`{schema, ok, query, data: {... affected / items ...}, enrichment, ...}`), - where the affected set is nested under `data` and the reverify list is - `data.items` — NOT the top-level flat `{"affected"/"entries", "count"}` legis - parses. There is no top-level `count`. - * warpline's only legis-facing seam (`federation.py`) runs the OPPOSITE - direction: warpline CONSULTS legis governance as a federation peer. + * `warpline-mcp-live-session.jsonl` — impact_radius session, captured 2026-06-26 + * `warpline-mcp-reverify-session.jsonl` — reverify_worklist session, captured 2026-06-27 -## WARPLINE PRODUCER-SIDE OBLIGATION +Replay tests in `test_stdio_invoke.py` echo these raw bytes through `StdioMcpInvoke` +and assert known fields from the capture, so the real message order + result shape +(structuredContent vs content[].text + protocolVersion) are exercised, not just +legis-shaped assumptions. -For this seam to reach a shared, byte-identical golden, warpline must ship a -producer (an HTTP `GET /api/impact-radius` + `GET /api/reverify-worklist`, or an -equivalent flat-shape projection) emitting exactly: +## Legis conforms to warpline's extant envelope — warpline ships no producer - impact-radius : {"affected": [{"sei": "loomweave:eid:<32hex>", ...}], "count": N} - reverify-worklist: {"entries": [{"sei": "loomweave:eid:<32hex>", ...}], "count": N} +Per SEAM 4 §4A and GV-LG-3: legis is a CONSUMER of warpline's extant MCP +envelope. Legis's `WarplineMcpClient._call` validates the envelope +(schema/ok/meta/completeness) and passes it through verbatim. Warpline owns the +`warpline.impact_radius.v1` / `warpline.reverify_worklist.v1` schemas; legis does +not interpret or re-shape them. -and vendor a contract fixture for it under -`warpline/tests/fixtures/contracts/warpline/`. The Layer-2 recheck in +Warpline ships no producer-side contract fixture for its MCP envelopes (no +`mcp-preflight-golden.json`). The Layer-2 recheck in `test_warpline_preflight_oracle.py` (`test_golden_matches_warpline_source`) -points at that path and `pytest.skip`s until it exists; it activates -automatically and will then enforce byte-equality (re-vendor + update the -byte-pin once the producer ships). +skips cleanly and will activate automatically if warpline vendors that fixture. + +## Re-capturing the golden + +Run `warpline-mcp` with stdio JSON-RPC calls for `warpline_impact_radius_get` and +`warpline_reverify_worklist_get` (see `warpline-mcp-live-session.jsonl` and +`warpline-mcp-reverify-session.jsonl` for the exact protocol), extract the +`structuredContent` from each `id=2` response, build the golden as +`{"impact_radius": <...>, "reverify_worklist": <...>, "_provenance": +{"source": "live-captured", ...}}`, then re-pin `GOLDEN_BLOB_SHA` in the oracle +to `git hash-object tests/warpline_preflight/fixtures/warpline-preflight-golden.json`. diff --git a/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl b/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl new file mode 100644 index 0000000..15b5ede --- /dev/null +++ b/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "warpline", "version": "1.2.0"}, "capabilities": {"tools": {}}, "instructions": "Use tools/list, then tools/call. Endorsed names and short shims return identical schema+data. Tool errors are structured in JSON-RPC error.data with schema warpline.error.v1 and a closed error_code/retryability vocab."}} +{"jsonrpc": "2.0", "id": null, "error": {"code": -32601, "message": "notifications/initialized", "data": {"schema": "warpline.error.v1", "error_code": "missing_required_field", "retryability": "retry_with_changes", "hint": "Supply the required argument and retry the same tool.", "details": {"message": "unknown method"}, "rejected_field": "method"}}} +{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": {"schema": "warpline.impact_radius.v1", "ok": true, "query": {"repo": "/home/john/legis", "tool": "warpline_impact_radius_get", "arguments": {"rev_range": "HEAD~1..HEAD", "changed_entity_key_ids": [], "depth": 2}, "filters": {}, "sort": {"by": "depth", "order": "asc"}, "page": {"limit": 100, "cursor": null}}, "data": {"completeness": "NO_SNAPSHOT", "staleness": {"snapshot_commit": null, "commits_behind": null}, "resolved": [], "unresolved": [], "changed": [], "affected": [], "overflow": {"total": 0, "returned": 0, "dumped_to": null, "reason_class": "clean"}, "page": {"limit": 100, "next_cursor": null, "has_more": false, "reason_class": "clean"}}, "warnings": ["NO_SNAPSHOT: downstream traversal unavailable; changed set only"], "next_actions": {}, "enrichment": {"sei": "absent", "edges": "absent", "work": "unavailable", "risk": "unavailable", "governance": "unavailable", "requirements": "unavailable"}, "enrichment_reasons": {"requirements": {"reason_class": "disabled", "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"}}, "meta": {"producer": {"tool": "warpline", "version": "1.2.0"}, "local_only": true, "peer_side_effects": []}}, "content": [{"type": "text", "text": "{\"data\": {\"affected\": [], \"changed\": [], \"completeness\": \"NO_SNAPSHOT\", \"overflow\": {\"dumped_to\": null, \"reason_class\": \"clean\", \"returned\": 0, \"total\": 0}, \"page\": {\"has_more\": false, \"limit\": 100, \"next_cursor\": null, \"reason_class\": \"clean\"}, \"resolved\": [], \"staleness\": {\"commits_behind\": null, \"snapshot_commit\": null}, \"unresolved\": []}, \"enrichment\": {\"edges\": \"absent\", \"governance\": \"unavailable\", \"requirements\": \"unavailable\", \"risk\": \"unavailable\", \"sei\": \"absent\", \"work\": \"unavailable\"}, \"enrichment_reasons\": {\"requirements\": {\"cause\": \"the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet\", \"fix\": \"wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty\", \"reason_class\": \"disabled\"}}, \"meta\": {\"local_only\": true, \"peer_side_effects\": [], \"producer\": {\"tool\": \"warpline\", \"version\": \"1.2.0\"}}, \"next_actions\": {}, \"ok\": true, \"query\": {\"arguments\": {\"changed_entity_key_ids\": [], \"depth\": 2, \"rev_range\": \"HEAD~1..HEAD\"}, \"filters\": {}, \"page\": {\"cursor\": null, \"limit\": 100}, \"repo\": \"/home/john/legis\", \"sort\": {\"by\": \"depth\", \"order\": \"asc\"}, \"tool\": \"warpline_impact_radius_get\"}, \"schema\": \"warpline.impact_radius.v1\", \"warnings\": [\"NO_SNAPSHOT: downstream traversal unavailable; changed set only\"]}"}]}} diff --git a/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl b/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl new file mode 100644 index 0000000..8d38f79 --- /dev/null +++ b/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "warpline", "version": "1.2.0"}, "capabilities": {"tools": {}}, "instructions": "Use tools/list, then tools/call. Endorsed names and short shims return identical schema+data. Tool errors are structured in JSON-RPC error.data with schema warpline.error.v1 and a closed error_code/retryability vocab."}} +{"jsonrpc": "2.0", "id": null, "error": {"code": -32601, "message": "notifications/initialized", "data": {"schema": "warpline.error.v1", "error_code": "missing_required_field", "retryability": "retry_with_changes", "hint": "Supply the required argument and retry the same tool.", "details": {"message": "unknown method"}, "rejected_field": "method"}}} +{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": {"schema": "warpline.reverify_worklist.v1", "ok": true, "query": {"repo": "/home/john/legis", "tool": "warpline_reverify_worklist_get", "arguments": {"rev_range": "HEAD~1..HEAD", "changed_entity_key_ids": [], "depth": 2}, "filters": {}, "sort": {"by": "priority", "order": "asc"}, "group_by": "none", "include_federation": false, "page": {"limit": 100, "cursor": null}}, "data": {"completeness": "NO_SNAPSHOT", "staleness": {"snapshot_commit": null, "commits_behind": null}, "verification_summary": {"fresh": 0, "stale": 0, "unverified": 0, "unavailable": 0, "local_source_configured": false}, "resolved": [], "unresolved": [], "items": [], "grouped": null, "next_actions": {"filigree": []}, "overflow": {"total": 0, "returned": 0, "dumped_to": null, "reason_class": "clean"}, "page": {"limit": 100, "next_cursor": null, "has_more": false, "reason_class": "clean"}}, "warnings": ["NO_SNAPSHOT: downstream traversal unavailable; changed set only"], "next_actions": {"filigree": []}, "enrichment": {"sei": "absent", "edges": "absent", "work": "unavailable", "risk": "unavailable", "governance": "unavailable", "requirements": "unavailable"}, "enrichment_reasons": {"requirements": {"reason_class": "disabled", "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"}}, "meta": {"producer": {"tool": "warpline", "version": "1.2.0"}, "local_only": true, "peer_side_effects": []}}, "content": [{"type": "text", "text": "{\"data\": {\"completeness\": \"NO_SNAPSHOT\", \"grouped\": null, \"items\": [], \"next_actions\": {\"filigree\": []}, \"overflow\": {\"dumped_to\": null, \"reason_class\": \"clean\", \"returned\": 0, \"total\": 0}, \"page\": {\"has_more\": false, \"limit\": 100, \"next_cursor\": null, \"reason_class\": \"clean\"}, \"resolved\": [], \"staleness\": {\"commits_behind\": null, \"snapshot_commit\": null}, \"unresolved\": [], \"verification_summary\": {\"fresh\": 0, \"local_source_configured\": false, \"stale\": 0, \"unavailable\": 0, \"unverified\": 0}}, \"enrichment\": {\"edges\": \"absent\", \"governance\": \"unavailable\", \"requirements\": \"unavailable\", \"risk\": \"unavailable\", \"sei\": \"absent\", \"work\": \"unavailable\"}, \"enrichment_reasons\": {\"requirements\": {\"cause\": \"the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet\", \"fix\": \"wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty\", \"reason_class\": \"disabled\"}}, \"meta\": {\"local_only\": true, \"peer_side_effects\": [], \"producer\": {\"tool\": \"warpline\", \"version\": \"1.2.0\"}}, \"next_actions\": {\"filigree\": []}, \"ok\": true, \"query\": {\"arguments\": {\"changed_entity_key_ids\": [], \"depth\": 2, \"rev_range\": \"HEAD~1..HEAD\"}, \"filters\": {}, \"group_by\": \"none\", \"include_federation\": false, \"page\": {\"cursor\": null, \"limit\": 100}, \"repo\": \"/home/john/legis\", \"sort\": {\"by\": \"priority\", \"order\": \"asc\"}, \"tool\": \"warpline_reverify_worklist_get\"}, \"schema\": \"warpline.reverify_worklist.v1\", \"warnings\": [\"NO_SNAPSHOT: downstream traversal unavailable; changed set only\"]}"}]}} diff --git a/tests/warpline_preflight/fixtures/warpline-preflight-golden.json b/tests/warpline_preflight/fixtures/warpline-preflight-golden.json index 44bb515..777b858 100644 --- a/tests/warpline_preflight/fixtures/warpline-preflight-golden.json +++ b/tests/warpline_preflight/fixtures/warpline-preflight-golden.json @@ -1,28 +1,167 @@ { "impact_radius": { - "affected": [ - { - "sei": "loomweave:eid:0123456789abcdef0123456789abcdef", - "locator": "python:function:src/pkg/mod.py::fn", - "depth": 1 - }, - { - "sei": "loomweave:eid:fedcba9876543210fedcba9876543210", - "locator": "python:function:src/pkg/other.py::helper", + "schema": "warpline.impact_radius.v1", + "ok": true, + "query": { + "repo": "/home/john/legis", + "tool": "warpline_impact_radius_get", + "arguments": { + "rev_range": "HEAD~1..HEAD", + "changed_entity_key_ids": [], "depth": 2 + }, + "filters": {}, + "sort": { + "by": "depth", + "order": "asc" + }, + "page": { + "limit": 100, + "cursor": null + } + }, + "data": { + "completeness": "NO_SNAPSHOT", + "staleness": { + "snapshot_commit": null, + "commits_behind": null + }, + "resolved": [], + "unresolved": [], + "changed": [], + "affected": [], + "overflow": { + "total": 0, + "returned": 0, + "dumped_to": null, + "reason_class": "clean" + }, + "page": { + "limit": 100, + "next_cursor": null, + "has_more": false, + "reason_class": "clean" } + }, + "warnings": [ + "NO_SNAPSHOT: downstream traversal unavailable; changed set only" ], - "count": 2 + "next_actions": {}, + "enrichment": { + "sei": "absent", + "edges": "absent", + "work": "unavailable", + "risk": "unavailable", + "governance": "unavailable", + "requirements": "unavailable" + }, + "enrichment_reasons": { + "requirements": { + "reason_class": "disabled", + "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", + "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty" + } + }, + "meta": { + "producer": { + "tool": "warpline", + "version": "1.2.0" + }, + "local_only": true, + "peer_side_effects": [] + } }, "reverify_worklist": { - "entries": [ - { - "sei": "loomweave:eid:0123456789abcdef0123456789abcdef", - "locator": "python:function:src/pkg/mod.py::fn", - "priority": "high", - "reason": "changed" + "schema": "warpline.reverify_worklist.v1", + "ok": true, + "query": { + "repo": "/home/john/legis", + "tool": "warpline_reverify_worklist_get", + "arguments": { + "rev_range": "HEAD~1..HEAD", + "changed_entity_key_ids": [], + "depth": 2 + }, + "filters": {}, + "sort": { + "by": "priority", + "order": "asc" + }, + "group_by": "none", + "include_federation": false, + "page": { + "limit": 100, + "cursor": null } + }, + "data": { + "completeness": "NO_SNAPSHOT", + "staleness": { + "snapshot_commit": null, + "commits_behind": null + }, + "verification_summary": { + "fresh": 0, + "stale": 0, + "unverified": 0, + "unavailable": 0, + "local_source_configured": false + }, + "resolved": [], + "unresolved": [], + "items": [], + "grouped": null, + "next_actions": { + "filigree": [] + }, + "overflow": { + "total": 0, + "returned": 0, + "dumped_to": null, + "reason_class": "clean" + }, + "page": { + "limit": 100, + "next_cursor": null, + "has_more": false, + "reason_class": "clean" + } + }, + "warnings": [ + "NO_SNAPSHOT: downstream traversal unavailable; changed set only" ], - "count": 1 + "next_actions": { + "filigree": [] + }, + "enrichment": { + "sei": "absent", + "edges": "absent", + "work": "unavailable", + "risk": "unavailable", + "governance": "unavailable", + "requirements": "unavailable" + }, + "enrichment_reasons": { + "requirements": { + "reason_class": "disabled", + "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", + "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty" + } + }, + "meta": { + "producer": { + "tool": "warpline", + "version": "1.2.0" + }, + "local_only": true, + "peer_side_effects": [] + } + }, + "_provenance": { + "source": "live-captured", + "captured": "2026-06-27", + "warpline_version": "1.2.0", + "repo": "/home/john/legis", + "rev_range": "HEAD~1..HEAD" } } diff --git a/tests/warpline_preflight/test_client.py b/tests/warpline_preflight/test_client.py index 7e04a88..9f6b46c 100644 --- a/tests/warpline_preflight/test_client.py +++ b/tests/warpline_preflight/test_client.py @@ -1,128 +1,73 @@ -import inspect -import json - import pytest +from legis.warpline_preflight.client import WarplineMcpClient, WarplineClient, WarplineError + +_VALID_META = {"local_only": True, "peer_side_effects": []} +_KEEP = object() # sentinel: "use the valid default meta" — DISTINCT from None (None IS a test case) + -import legis.filigree.client as fc -import legis.warpline_preflight.client as wc -from legis.warpline_preflight.client import ( - HttpWarplineClient, - WarplineClient, - WarplineError, - MAX_RESPONSE_BYTES, -) +def _env(schema, data_key, items, *, meta=_KEEP, completeness="FULL"): + data = {data_key: items, "staleness": {"commits_behind": 0}} + if completeness is not None: + data["completeness"] = completeness + return {"schema": schema, "ok": True, "query": {"rev_range": "aaa..bbb"}, "data": data, + "warnings": [], "next_actions": {}, "enrichment": {"sei": "present"}, + "meta": dict(_VALID_META) if meta is _KEEP else meta} # meta=None -> {"meta": None}, a real case def _recorder(responses): - """An injectable Fetch that returns queued dicts and records calls.""" calls = [] - def fetch(method, url, body): - calls.append((method, url, body)) + def invoke(tool, arguments): + calls.append((tool, arguments)) return responses.pop(0) - fetch.calls = calls - return fetch + invoke.calls = calls + return invoke def test_protocol_is_runtime_checkable(): - client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([{}])) - assert isinstance(client, WarplineClient) + assert isinstance(WarplineMcpClient(invoke=_recorder([{}]), repo="/tmp/r"), WarplineClient) -def test_impact_radius_is_a_get_with_base_head_query(): - fetch = _recorder([{"affected": [], "count": 0}]) - client = HttpWarplineClient("http://localhost:9100", fetch=fetch) - out = client.impact_radius("aaa", "bbb") - assert out == {"affected": [], "count": 0} - method, url, body = fetch.calls[0] - assert method == "GET" and body is None - assert url == "http://localhost:9100/api/impact-radius?base=aaa&head=bbb" +def test_impact_radius_calls_tool_with_rev_range_and_passes_envelope_through(): + e = _env("warpline.impact_radius.v1", "affected", [{"sei": "loomweave:eid:" + "a"*32}]) + inv = _recorder([e]); out = WarplineMcpClient(invoke=inv, repo="/tmp/r").impact_radius("aaa", "bbb") + assert out == e + assert inv.calls[0] == ("warpline_impact_radius_get", {"repo": "/tmp/r", "rev_range": "aaa..bbb"}) -def test_reverify_worklist_is_a_get_with_base_head_query(): - fetch = _recorder([{"entries": [], "count": 0}]) - client = HttpWarplineClient("http://localhost:9100", fetch=fetch) - out = client.reverify_worklist("aaa", "bbb") - assert out == {"entries": [], "count": 0} - method, url, body = fetch.calls[0] - assert method == "GET" and body is None - assert url == "http://localhost:9100/api/reverify-worklist?base=aaa&head=bbb" +def test_reverify_calls_reverify_tool(): + e = _env("warpline.reverify_worklist.v1", "items", []) + inv = _recorder([e]); WarplineMcpClient(invoke=inv, repo="/tmp/r").reverify_worklist("a", "b") + assert inv.calls[0][0] == "warpline_reverify_worklist_get" -def test_non_object_response_is_a_warpline_error(): - client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([["not", "a", "dict"]])) +@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None]) +def test_non_dict_envelope_is_warpline_error(bad): with pytest.raises(WarplineError): - client.impact_radius("a", "b") - - -def test_loopback_http_ok_remote_http_rejected_unless_optin(monkeypatch): - monkeypatch.delenv("LEGIS_ALLOW_INSECURE_REMOTE_HTTP", raising=False) - HttpWarplineClient("http://127.0.0.1:9100") # loopback IP ok - HttpWarplineClient("http://localhost:9100") # localhost ok - HttpWarplineClient("https://warpline.example.com") # https ok - with pytest.raises(WarplineError, match="HTTPS unless it is loopback"): - HttpWarplineClient("http://warpline.example.com") - monkeypatch.setenv("LEGIS_ALLOW_INSECURE_REMOTE_HTTP", "1") - HttpWarplineClient("http://warpline.example.com") # opt-in permits it - - -def test_base_url_must_be_http_with_host(): - with pytest.raises(WarplineError, match="http\\(s\\) URL with a host"): - HttpWarplineClient("ftp://warpline") - with pytest.raises(WarplineError, match="http\\(s\\) URL with a host"): - HttpWarplineClient("not-a-url") - - -def test_response_too_large_via_real_decode_path(): - # Exercise the real _decode_json_response size guard with a fake resp object. - from legis.warpline_preflight.client import _decode_json_response - - big = json.dumps({"x": "y" * (MAX_RESPONSE_BYTES + 10)}).encode("utf-8") - - class _Resp: - headers = {"Content-Type": "application/json"} - - def read(self, n): - return big[:n] - - with pytest.raises(WarplineError, match="response too large"): - _decode_json_response(_Resp(), "GET test") - - -def test_non_json_content_type_rejected(): - from legis.warpline_preflight.client import _decode_json_response - - class _Resp: - headers = {"Content-Type": "text/html"} - - def read(self, n): - return b"" - - with pytest.raises(WarplineError, match="non-JSON content type"): - _decode_json_response(_Resp(), "GET test") - + WarplineMcpClient(invoke=_recorder([bad]), repo="/tmp/r").impact_radius("a", "b") -def test_no_redirect_handler_returns_none(): - from legis.warpline_preflight.client import _NoRedirectHandler - h = _NoRedirectHandler() - assert h.redirect_request(None, None, 302, "Found", {}, "http://elsewhere") is None +def test_wrong_schema_or_not_ok_is_warpline_error(): + wrong = _env("warpline.reverify_worklist.v1", "items", []) # wrong schema for impact + with pytest.raises(WarplineError, match="schema"): + WarplineMcpClient(invoke=_recorder([wrong]), repo="/tmp/r").impact_radius("a", "b") + notok = _env("warpline.impact_radius.v1", "affected", []); notok["ok"] = False + with pytest.raises(WarplineError, match="ok"): + WarplineMcpClient(invoke=_recorder([notok]), repo="/tmp/r").impact_radius("a", "b") -def _normalize(src): - # The ONLY intended differences are the sibling name and its error class. - return src.replace("Filigree", "Warpline").replace("filigree", "warpline") +def test_gv_lg_3_hostile_or_malformed_meta_is_refused_fail_closed(): + e = _env("warpline.impact_radius.v1", "affected", []); e["meta"] = {"local_only": True, "peer_side_effects": ["did_a_thing"]} + with pytest.raises(WarplineError, match="side effect"): + WarplineMcpClient(invoke=_recorder([e]), repo="/tmp/r").impact_radius("a", "b") + for bad_meta in ({"local_only": False, "peer_side_effects": []}, {"peer_side_effects": []}, "not-a-dict", None, 5): + em = _env("warpline.impact_radius.v1", "affected", [], meta=bad_meta) + with pytest.raises(WarplineError): # non-dict / missing / False local_only all refuse + WarplineMcpClient(invoke=_recorder([em]), repo="/tmp/r").impact_radius("a", "b") -def test_security_primitives_are_faithful_clones_of_filigree(): - # If a future patch hardens filigree's SSRF/redirect/DoS handling this fails - # loudly so warpline is patched in lockstep. _urllib_fetch is EXCLUDED — it - # intentionally drops filigree's weft_signing body bytes (warpline is GET-only). - for name in ("_validate_base_url", "_is_loopback", "_open_no_redirect", "_decode_json_response"): - assert _normalize(inspect.getsource(getattr(fc, name))) == inspect.getsource( - getattr(wc, name) - ), f"{name} diverged from the filigree clone" - assert _normalize(inspect.getsource(fc._NoRedirectHandler)) == inspect.getsource( - wc._NoRedirectHandler - ) +def test_degraded_envelope_missing_completeness_is_warpline_error(): + e = _env("warpline.impact_radius.v1", "affected", [], completeness=None) # completeness omitted + with pytest.raises(WarplineError, match="completeness"): + WarplineMcpClient(invoke=_recorder([e]), repo="/tmp/r").impact_radius("a", "b") diff --git a/tests/warpline_preflight/test_stdio_invoke.py b/tests/warpline_preflight/test_stdio_invoke.py new file mode 100644 index 0000000..c72a310 --- /dev/null +++ b/tests/warpline_preflight/test_stdio_invoke.py @@ -0,0 +1,182 @@ +"""Tests for StdioMcpInvoke — the production stdio JSON-RPC transport. + +Fault paths: every transport/parse fault must raise WarplineError (fail closed). +DoD gate: test_replays_a_REAL_captured_session exercises bytes from a real +warpline-mcp 1.2.0 session (see fixtures/PROVENANCE.md + warpline-mcp-live-session.jsonl). +""" +import pathlib +import sys +import textwrap + +import pytest + +from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError + + +def _script(tmp_path, body): + p = tmp_path / "fake.py" + p.write_text(textwrap.dedent(body)) + return [sys.executable, str(p)] + + +_OK = ''' + import sys, json + for line in sys.stdin: + m = json.loads(line); mid = m.get("id") + if m.get("method") == "initialize": + print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"f","version":"0"}}}), flush=True) + elif m.get("method") == "tools/call": + env = {"schema":"warpline.impact_radius.v1","ok":True,"query":m["params"]["arguments"],"data":{"affected":[{"sei":"x"}],"completeness":"FULL","staleness":{"commits_behind":0}},"warnings":[],"next_actions":{},"enrichment":{},"meta":{"local_only":True,"peer_side_effects":[]}} + print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[{"type":"text","text":"{}"}],"structuredContent":env,"isError":False}}), flush=True) +''' + + +def test_round_trips_against_fake_server(tmp_path): + env = StdioMcpInvoke(command=_script(tmp_path, _OK))("warpline_impact_radius_get", {"rev_range": "a..b"}) + assert env["data"]["affected"] == [{"sei": "x"}] + + +def test_replays_a_REAL_captured_session(tmp_path): + """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session + (see PROVENANCE). A green here means the real message order + result shape + (structuredContent vs content[].text + protocolVersion) were exercised, not a + legis-shaped assumption. If no live capture exists, this test FAILS (xfail is + not allowed) and Task 2 is not done — escalate (see the gate note). + + PROVENANCE: captured from warpline-mcp 1.2.0 on 2026-06-26 in /home/john/legis. + Exit-on-EOF: YES (rc=0). Interleaved I/O: NOT required (batch accepted). + Real protocolVersion from server: "2025-03-26". + Result shape: structuredContent (dict) present; notifications/initialized + returns id:null error (valid JSON, id != 2 -> silently skipped by _read_jsonrpc_result). + NOTE: warpline_impact_radius_get requires a "repo" arg; WarplineMcpClient (Task 1) + does not yet supply it — flagged as a Task-1/Task-3 concern, not fixed here. + """ + fixture = pathlib.Path(__file__).parent / "fixtures" / "warpline-mcp-live-session.jsonl" + assert fixture.exists(), f"live session fixture missing: {fixture}" + stub = tmp_path / "replay.py" + stub.write_text(textwrap.dedent(f""" + import sys + sys.stdin.read() # consume all stdin to EOF before responding + with open({str(fixture)!r}) as fh: + for line in fh: + line = line.rstrip('\\n') + if line: + print(line, flush=True) + """)) + env = StdioMcpInvoke(command=[sys.executable, str(stub)])( + "warpline_impact_radius_get", {"rev_range": "HEAD~1..HEAD", "repo": "/test"} + ) + # assertions on known fields from the real captured envelope + assert env["schema"] == "warpline.impact_radius.v1" + assert env["ok"] is True + assert env["data"]["completeness"] == "NO_SNAPSHOT" + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + + +def test_replays_a_REAL_reverify_session(tmp_path): + """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session + for the `warpline_reverify_worklist_get` tool (see PROVENANCE). A green here means + the real message order + result shape were exercised for the reverify tool path. + + PROVENANCE: captured from warpline-mcp 1.2.0 on 2026-06-27 in /home/john/legis. + Exit-on-EOF: YES (rc=0). Interleaved I/O: NOT required (batch accepted). + Real protocolVersion from server: "2025-03-26". + Result shape: structuredContent (dict) present; notifications/initialized + returns id:null error (valid JSON, id != 2 -> silently skipped by _read_jsonrpc_result). + """ + fixture = pathlib.Path(__file__).parent / "fixtures" / "warpline-mcp-reverify-session.jsonl" + assert fixture.exists(), f"live reverify session fixture missing: {fixture}" + stub = tmp_path / "replay.py" + stub.write_text(textwrap.dedent(f""" + import sys + sys.stdin.read() # consume all stdin to EOF before responding + with open({str(fixture)!r}) as fh: + for line in fh: + line = line.rstrip('\\n') + if line: + print(line, flush=True) + """)) + env = StdioMcpInvoke(command=[sys.executable, str(stub)])( + "warpline_reverify_worklist_get", {"rev_range": "HEAD~1..HEAD", "repo": "/test"} + ) + # assertions on known fields from the real captured envelope + assert env["schema"] == "warpline.reverify_worklist.v1" + assert env["ok"] is True + assert env["data"]["completeness"] == "NO_SNAPSHOT" + assert env["data"]["items"] == [] + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + + +def test_content_text_fallback_parse(tmp_path): + """MINOR (c): test the content[0].text fallback parse path. + When structuredContent is absent, StdioMcpInvoke must parse the envelope + from content[0].text. Both existing replay stubs return structuredContent; + this test exercises the fallback branch with a fake server that returns ONLY + content:[{type:text,text:}] and NO structuredContent. + """ + import json as _json + envelope = { + "schema": "warpline.impact_radius.v1", + "ok": True, + "query": {}, + "data": {"completeness": "FULL", "affected": [{"sei": "test-sei"}], + "staleness": {"commits_behind": 0}}, + "warnings": [], + "next_actions": {}, + "enrichment": {}, + "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "warpline", "version": "0"}}, + } + # Write envelope to a file so the fake server can read it without quoting hell + env_file = tmp_path / "envelope.json" + env_file.write_text(_json.dumps(envelope), encoding="utf-8") + body = f''' + import sys, json + env_text = open({str(env_file)!r}, encoding="utf-8").read() + for line in sys.stdin: + m = json.loads(line); mid = m.get("id") + if m.get("method") == "initialize": + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True) + elif m.get("method") == "tools/call": + print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":env_text}}],"isError":False}}}}), flush=True) + ''' + env = StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"}) + assert env["schema"] == "warpline.impact_radius.v1" + assert env["data"]["affected"] == [{"sei": "test-sei"}] + assert env["data"]["completeness"] == "FULL" + + +@pytest.mark.parametrize("body,match", [ + ('import sys\n', "no JSON-RPC response"), # empty stdout + ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError + ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error +]) +def test_fault_paths_all_raise_warpline_error(tmp_path, body, match): + with pytest.raises(WarplineError, match=match): + StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"}) + + +def test_missing_executable_is_warpline_error(tmp_path): + with pytest.raises(WarplineError): + StdioMcpInvoke(command=[str(tmp_path / "nope")])("warpline_impact_radius_get", {"rev_range": "a..b"}) + + +def test_empty_command_is_warpline_error(): + with pytest.raises(WarplineError, match="empty"): + StdioMcpInvoke(command=[])("warpline_impact_radius_get", {"rev_range": "a..b"}) + + +def test_timeout_is_warpline_error(tmp_path): + with pytest.raises(WarplineError): + StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)( + "warpline_impact_radius_get", {"rev_range": "a..b"} + ) + + +def test_oversize_stdout_is_warpline_error(tmp_path): + body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n' + with pytest.raises(WarplineError, match="too large"): + StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"}) diff --git a/tests/warpline_preflight/test_warpline_preflight_oracle.py b/tests/warpline_preflight/test_warpline_preflight_oracle.py index 986dab7..b291659 100644 --- a/tests/warpline_preflight/test_warpline_preflight_oracle.py +++ b/tests/warpline_preflight/test_warpline_preflight_oracle.py @@ -1,28 +1,27 @@ """Weft warpline-preflight conformance oracle — Legis as consumer. Legis reads warpline's ADVISORY preflight surface via -``legis.warpline_preflight.client.HttpWarplineClient`` and +``legis.warpline_preflight.client.WarplineMcpClient`` and ``legis.service.preflight.read_warpline_preflight``. This oracle freezes the two -response shapes legis parses and drives legis's REAL parse path over the frozen -bytes, so a shape change fails CI until legis updates the consumer. +real envelope shapes warpline emits (``warpline.impact_radius.v1`` / +``warpline.reverify_worklist.v1``, live-captured via MCP) and drives legis's REAL +parse path over the frozen bytes, so a shape change fails CI until legis updates +the consumer. Three layers, mirroring ``tests/conformance/test_sei_oracle*``: * Layer-1 byte-pin (``test_golden_byte_pin``): UNMARKED, default-suite, recomputes the git blob sha1 in-process and fails CLOSED on any byte drift. - * Non-circular consumer oracle (``test_*_drives_real_legis_parse``): the frozen - golden BYTES flow through legis's real ``_decode_json_response`` (only the HTTP - transport is stubbed, never the parse logic) and through the real - ``read_warpline_preflight``; assertions are on HARDCODED SEIs/counts, never a - re-parse of the golden. - * Layer-2 source recheck (``test_golden_matches_warpline_source``): compares the - frozen golden to warpline's source contract fixture; SKIPS CLEAN when absent - and names the producer obligation. - -PROVENANCE: this golden is frozen to the shape legis's client expects, NOT -vendored from warpline. Warpline ships no producer for these flat REST shapes -today (no HTTP server; its surface is the rich ``warpline.impact_radius.v1`` / -``warpline.reverify_worklist.v1`` envelope). See ``fixtures/PROVENANCE.md``. + * Non-circular consumer oracle (``test_golden_flows_through_the_real_parser``): + the frozen golden BYTES flow through legis's real ``WarplineMcpClient._call`` + (schema/ok/meta/completeness validation) via a fake invoke replaying the + golden; assertions are HARDCODED literals, NEVER a re-parse of the golden. + * Layer-2 source recheck (``test_golden_matches_warpline_source``): compares + the frozen golden to warpline's MCP contract fixture; SKIPS CLEAN when absent + and notes the seam. + +PROVENANCE: this golden is live-captured from ``warpline-mcp`` (version 1.2.0, +legis repo, HEAD~1..HEAD). See ``fixtures/PROVENANCE.md``. """ from __future__ import annotations @@ -34,19 +33,17 @@ import pytest from legis.service.preflight import read_warpline_preflight -from legis.warpline_preflight.client import ( - HttpWarplineClient, - _decode_json_response, -) +from legis.warpline_preflight.client import WarplineMcpClient -GOLDEN_PATH = Path(__file__).parent / "fixtures" / "warpline-preflight-golden.json" +FIX = Path(__file__).parent / "fixtures" +GOLDEN_PATH = FIX / "warpline-preflight-golden.json" # git blob sha1 of tests/warpline_preflight/fixtures/warpline-preflight-golden.json. -# Frozen to the shape legis's warpline preflight client expects (NOT vendored -# from warpline — warpline ships no producer for these flat REST shapes). Update -# only after confirming the legis consumer contract intentionally changed; if -# warpline later ships a producer fixture, re-vendor byte-identical and re-pin. -GOLDEN_BLOB_SHA = "44bb515d528fdaca5b12703a896f55cd96c2483b" +# Frozen to the live-captured warpline.impact_radius.v1 / warpline.reverify_worklist.v1 +# envelopes. Update ONLY after a deliberate re-capture from a live warpline-mcp run; +# re-running warpline-mcp and updating this pin is the trigger for a consumer-contract +# review. See fixtures/PROVENANCE.md. +GOLDEN_BLOB_SHA = "777b85895076a622f5b0fbf734fa8265d8d49f36" # --------------------------------------------------------------------------- @@ -60,98 +57,109 @@ def test_golden_byte_pin(): data = GOLDEN_PATH.read_bytes() assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, ( "warpline-preflight golden has drifted from its pinned bytes; update " - "GOLDEN_BLOB_SHA only after confirming the legis consumer contract change " - "is intended (and re-check the warpline producer obligation in PROVENANCE.md)." + "GOLDEN_BLOB_SHA only after a deliberate re-capture from warpline-mcp " + "(re-check PROVENANCE.md and re-run `git hash-object` on the new golden)." ) # --------------------------------------------------------------------------- -# Non-circular consumer oracle: golden BYTES -> legis's real decode -> real -# read_warpline_preflight. Only the HTTP transport is stubbed. +# Machine-readable provenance guard: golden must not be 'pending-live-capture' +# in CI unless the explicit escape env var is set. # --------------------------------------------------------------------------- -class _GoldenResp: - """A minimal urllib-response stand-in carrying the golden bytes for one route.""" - - def __init__(self, raw: bytes) -> None: - self.headers = {"Content-Type": "application/json"} - self._raw = raw - - def read(self, n: int) -> bytes: - return self._raw[:n] - - -def _golden() -> dict: - # Used ONLY to slice the two route bodies out of the single golden file so - # each can be re-serialized to bytes and pushed through legis's real decode. - # Assertions below never read from this — they are hardcoded. - return json.loads(GOLDEN_PATH.read_bytes()) - - -def _bytes_fetch(): - """An injectable Fetch that serves the golden route bytes through legis's REAL - ``_decode_json_response`` (parse logic is NOT stubbed — only transport is).""" - golden = _golden() - bodies = { - "/api/impact-radius": json.dumps(golden["impact_radius"]).encode("utf-8"), - "/api/reverify-worklist": json.dumps(golden["reverify_worklist"]).encode("utf-8"), - } - - def fetch(method, url, body): - assert method == "GET" and body is None - for route, raw in bodies.items(): - if route in url: - return _decode_json_response(_GoldenResp(raw), f"{method} {url}") - raise AssertionError(f"unexpected warpline route in oracle: {url}") - - return fetch - - -def _client() -> HttpWarplineClient: - return HttpWarplineClient("http://localhost:9100", fetch=_bytes_fetch()) - - -def test_impact_radius_drives_real_legis_parse(): - out = _client().impact_radius("base-sha", "head-sha") - assert out["count"] == 2 - assert [a["sei"] for a in out["affected"]] == [ - "loomweave:eid:0123456789abcdef0123456789abcdef", - "loomweave:eid:fedcba9876543210fedcba9876543210", - ] - +def test_golden_provenance_is_live_captured(): + golden = json.loads(GOLDEN_PATH.read_bytes()) + source = golden.get("_provenance", {}).get("source") + if source == "pending-live-capture" and not os.environ.get("LEGIS_WARPLINE_GOLDEN_PENDING_OK"): + pytest.fail( + "warpline-preflight golden is marked 'pending-live-capture' — " + "run a live warpline-mcp capture to produce a real golden, or set " + "LEGIS_WARPLINE_GOLDEN_PENDING_OK=1 only as a temporary escape during " + "bootstrap. See fixtures/PROVENANCE.md." + ) -def test_reverify_worklist_drives_real_legis_parse(): - out = _client().reverify_worklist("base-sha", "head-sha") - assert out["count"] == 1 - assert [e["sei"] for e in out["entries"]] == [ - "loomweave:eid:0123456789abcdef0123456789abcdef" - ] +# --------------------------------------------------------------------------- +# Non-circular consumer oracle: golden BYTES -> WarplineMcpClient._call +# (schema/ok/meta/completeness validated). Assertions are HARDCODED literals +# from the frozen golden — NEVER a re-parse of the golden bytes. +# --------------------------------------------------------------------------- -def test_read_warpline_preflight_over_golden_is_checked_with_real_shapes(): - # The full service read: discriminated 'checked' with both sub-responses, - # parsed through legis's real client + decode over the frozen golden bytes. - result = read_warpline_preflight(_client(), "base-sha", "head-sha") +def _dispatch_invoke(golden: dict): + """Return an invoke that replays frozen envelopes keyed by tool name.""" + def invoke(tool: str, args: dict): + if tool == "warpline_impact_radius_get": + return golden["impact_radius"] + if tool == "warpline_reverify_worklist_get": + return golden["reverify_worklist"] + raise AssertionError(f"unexpected tool in oracle: {tool!r}") + return invoke + + +def test_golden_flows_through_the_real_parser_with_hardcoded_assertions(): + """Drive the FROZEN golden bytes through WarplineMcpClient._call (the real + schema/ok/meta/completeness validation), via a fake invoke replaying the bytes. + Assert HARDCODED values from the golden — NEVER a re-parse of the golden.""" + golden = json.loads(GOLDEN_PATH.read_bytes()) + + # --- impact_radius --- + impact = WarplineMcpClient( + invoke=lambda t, a: golden["impact_radius"], repo="/r" + ).impact_radius("b", "h") + # Hardcoded schema — the real parse gate; any envelope rename breaks here. + assert impact["schema"] == "warpline.impact_radius.v1" + assert impact["ok"] is True + # Hardcoded meta fields validated by _call (GV-LG-3 boundary). + assert impact["meta"]["local_only"] is True + assert impact["meta"]["peer_side_effects"] == [] + assert impact["meta"]["producer"]["tool"] == "warpline" + # Hardcoded data shape — completeness and empty affected from the live capture. + assert impact["data"]["completeness"] == "NO_SNAPSHOT" + assert impact["data"]["affected"] == [] + + # --- reverify_worklist --- + reverify = WarplineMcpClient( + invoke=lambda t, a: golden["reverify_worklist"], repo="/r" + ).reverify_worklist("b", "h") + # Hardcoded schema. + assert reverify["schema"] == "warpline.reverify_worklist.v1" + assert reverify["ok"] is True + # Hardcoded meta fields. + assert reverify["meta"]["local_only"] is True + assert reverify["meta"]["peer_side_effects"] == [] + assert reverify["meta"]["producer"]["tool"] == "warpline" + # Hardcoded data shape. + assert reverify["data"]["completeness"] == "NO_SNAPSHOT" + assert reverify["data"]["items"] == [] + + +def test_read_warpline_preflight_over_golden_is_checked(): + """The full service read: discriminated 'checked' with both sub-responses, + parsed through legis's real WarplineMcpClient._call over the frozen golden bytes. + Assertions are hardcoded — not a re-parse of the golden.""" + golden = json.loads(GOLDEN_PATH.read_bytes()) + client = WarplineMcpClient(invoke=_dispatch_invoke(golden), repo="/r") + result = read_warpline_preflight(client, "base-sha", "head-sha") + + # Hardcoded status discriminant. assert result["status"] == "checked" - assert result["impact_radius"]["count"] == 2 - assert [a["sei"] for a in result["impact_radius"]["affected"]] == [ - "loomweave:eid:0123456789abcdef0123456789abcdef", - "loomweave:eid:fedcba9876543210fedcba9876543210", - ] - assert result["reverify_worklist"]["count"] == 1 - assert [e["sei"] for e in result["reverify_worklist"]["entries"]] == [ - "loomweave:eid:0123456789abcdef0123456789abcdef" - ] + # Hardcoded sub-response schema and meta — the boundary validation was real. + assert result["impact_radius"]["schema"] == "warpline.impact_radius.v1" + assert result["impact_radius"]["meta"]["local_only"] is True + assert result["impact_radius"]["data"]["completeness"] == "NO_SNAPSHOT" + assert result["impact_radius"]["data"]["affected"] == [] + assert result["reverify_worklist"]["schema"] == "warpline.reverify_worklist.v1" + assert result["reverify_worklist"]["meta"]["local_only"] is True + assert result["reverify_worklist"]["data"]["completeness"] == "NO_SNAPSHOT" + assert result["reverify_worklist"]["data"]["items"] == [] # --------------------------------------------------------------------------- -# Layer-2: drift recheck vs warpline's source contract fixture (skip-clean). +# Layer-2: drift recheck vs warpline's source MCP contract fixture (skip-clean). # -# Warpline ships NO producer for these flat REST shapes today (no HTTP server; -# its real surface is the rich warpline.impact_radius.v1 / .reverify_worklist.v1 -# envelope, where the affected set is nested under data.affected / data.items and -# there is no top-level count). So this recheck SKIPS CLEAN and names the -# producer obligation. It activates byte-equality enforcement automatically the -# day warpline ships a flat-shape contract fixture at the path below. +# Warpline ships MCP tools with schema warpline.impact_radius.v1 / +# warpline.reverify_worklist.v1 — no flat REST projection. When warpline vendors +# a canonical contract fixture for these envelopes, point WARPLINE_REPO or place +# a sibling checkout so this check enforces byte-equality automatically. # --------------------------------------------------------------------------- def _warpline_source_fixture() -> Path | None: candidates: list[Path] = [] @@ -162,7 +170,7 @@ def _warpline_source_fixture() -> Path | None: / "fixtures" / "contracts" / "warpline" - / "preflight-rest-golden.json" + / "mcp-preflight-golden.json" ) candidates.append( Path(__file__).resolve().parents[3] @@ -171,7 +179,7 @@ def _warpline_source_fixture() -> Path | None: / "fixtures" / "contracts" / "warpline" - / "preflight-rest-golden.json" + / "mcp-preflight-golden.json" ) return next((path for path in candidates if path.exists()), None) @@ -180,15 +188,13 @@ def test_golden_matches_warpline_source(): source = _warpline_source_fixture() if source is None: pytest.skip( - "warpline ships no flat-REST preflight contract fixture " - "(preflight-rest-golden.json) — its surface is the rich " - "warpline.impact_radius.v1 / .reverify_worklist.v1 envelope, not the " - "flat {affected/entries, count} shape legis consumes. PRODUCER " - "OBLIGATION: warpline must ship GET /api/impact-radius + " - "GET /api/reverify-worklist (or an equivalent flat projection) and " - "vendor that fixture; set WARPLINE_REPO or place a sibling warpline " - "checkout to enable this drift check. See fixtures/PROVENANCE.md." + "warpline ships no MCP contract fixture (mcp-preflight-golden.json) " + "at the expected path — the legis golden is live-captured from " + "warpline-mcp and conforms to the warpline.impact_radius.v1 / " + "warpline.reverify_worklist.v1 envelope (SEAM 4 §4A + GV-LG-3). " + "Set WARPLINE_REPO or place a sibling warpline checkout to enable " + "this drift check. See fixtures/PROVENANCE.md." ) assert json.loads(GOLDEN_PATH.read_bytes()) == json.loads( source.read_text(encoding="utf-8") - ), "legis warpline-preflight golden drifted from warpline's source contract fixture" + ), "legis warpline-preflight golden drifted from warpline's source MCP contract fixture" diff --git a/uv.lock b/uv.lock index 7604c90..41b03aa 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -323,6 +336,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -469,6 +491,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -484,6 +527,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[package.optional-dependencies] +format = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3987" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -498,7 +553,7 @@ wheels = [ [[package]] name = "legis" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -512,7 +567,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx" }, - { name = "jsonschema" }, + { name = "jsonschema", extra = ["format"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -533,7 +588,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.27" }, - { name = "jsonschema", specifier = ">=4.21" }, + { name = "jsonschema", extras = ["format"], specifier = ">=4.21" }, { name = "mypy", specifier = ">=1.19" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, @@ -819,6 +874,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -888,6 +955,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3987" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700, upload-time = "2018-07-29T17:23:47.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" }, +] + [[package]] name = "rpds-py" version = "2026.5.1" @@ -1023,6 +1111,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.50" @@ -1107,6 +1204,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "uvicorn" version = "0.48.0" @@ -1249,6 +1364,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + [[package]] name = "websockets" version = "16.0"