diff --git a/CHANGELOG.md b/CHANGELOG.md index 09bbdea..a0de0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ 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.2.0] — 2026-06-25 + +Warpline federation interfaces: an advisory preflight consumer and a +forge-proof per-SEI attestation read. The agent MCP surface grows from 22 to +24 tools. + +### Added + +- **Advisory Warpline preflight consumer (`warpline_preflight_get`).** A + stdlib-only HTTP client (`HttpWarplineClient`) and a `read_warpline_preflight` + service read surface Warpline's impact-radius and reverify-worklist hints. The + consumer is purely advisory and structurally isolated from every governance + verdict path — an acceptance test asserts governance output is byte-identical + with and without advisory data present, and transport or configuration + failures degrade to a discriminated `unavailable` rather than affecting a + verdict or raising. The client reuses Legis's SSRF/redirect/size-cap gating + (loopback or HTTPS only unless `LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1`), with a + clone-parity guard that fails if those primitives drift from the Filigree + client. +- **Forge-proof per-SEI attestation read (`attestation_get`).** A + `read_sei_attestations` classifier surfaces the operator-override and + cleared-signoff attestations bound to a given SEI. Admission is gated on + cryptographic signature markers drawn from the verified-trail selection: a + sign-off's joined PENDING record is integrity-bound by recomputing and + comparing its content hash against the signed request-payload hash, every + surfaced field comes from the signed set, and any ambiguity resolves to + omission (a false "attested" is a security hole; a missed attestation only + costs wasted reverify work). The adversarial forge phase admitted zero forged + records. + ## [1.1.1] — 2026-06-23 Security and release-readiness hardening after the 1.1.0 dogfood release. diff --git a/docs/product/current-state.md b/docs/product/current-state.md new file mode 100644 index 0000000..869630a --- /dev/null +++ b/docs/product/current-state.md @@ -0,0 +1,22 @@ +# Current State — Legis Checkpoint: 2026-06-25 (2nd) · committed (PDR-0004) + +## 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. + +## 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. + +## 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.)* + +## 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. + +## 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. diff --git a/docs/product/decisions/0001-bootstrap-from-observed-state.md b/docs/product/decisions/0001-bootstrap-from-observed-state.md new file mode 100644 index 0000000..85b584e --- /dev/null +++ b/docs/product/decisions/0001-bootstrap-from-observed-state.md @@ -0,0 +1,21 @@ +# PDR-0001 — Bootstrap the Legis product workspace from observed state + +Date: 2026-06-24 Status: accepted Author: claude (opus, product-owner) Owner sign-off: partial (grant confirmed live; inferred vision/metrics not yet confirmed) +Supersedes: — Related: vision.md, roadmap.md, metrics.md, current-state.md + +## Context +Legis had no product-ownership workspace (`/product-checkpoint` halted on the missing precondition). Legis is a shipped gold product (v1.1.1) with a rich repo, README, CHANGELOG, a Weft member briefing, and a 145-issue filigree tracker. A workspace had to be constructed from that observed reality rather than fabricated from memory, so the next session can resume cold. + +## Options considered +1. **Bootstrap from observed reality (README + git log + tracker + member briefing), confirm only the authority grant live.** — pro: grounded in fact, fast, the one load-bearing item (the grant) is human-confirmed; con: purpose/audience/metric _numbers_ are inferred and may need correction. +2. **Interrogate the owner for vision/metrics before writing anything.** — pro: maximally accurate; con: slow, and the README + member briefing already state purpose and direction plainly — interrogation would mostly re-elicit what's already written. +3. **Do nothing / stay stateless.** — pro: no risk of a wrong inference; con: forfeits continuity entirely, which is the whole point of ownership. + +## The call +Option 1. Seeded `vision.md`, `roadmap.md`, `metrics.md`, `current-state.md` from the README, the Weft member briefing (`~/weft/members/legis.md`), git history, and the filigree tracker. The **authority grant was proposed and confirmed live** by the owner (Standard variant) during this `/own-product` run, so it is written as authoritative (not draft). Everything else is inferred-from-repo and marked as such. + +## Rationale +The repo states purpose, audience, anti-goals, and recent direction explicitly and consistently with the federation doctrine; inference here is reading, not guessing. The only item that genuinely required a human (the delegation boundary) was confirmed interactively. Metric _numbers_ that the repo doesn't expose are left as `(set)` placeholders rather than invented, keeping every target falsifiable-or-flagged. + +## Reversal trigger +Revisit on the **first owner review of this workspace**: if the owner corrects the purpose/audience framing in `vision.md` or sets real numbers against any `(set)` metric placeholder, supersede the inferred portions with a new PDR. Also revisit if the north-star ("open confirmed governance-honesty defects = 0") proves to be the wrong success measure once the P2 findings close. diff --git a/docs/product/decisions/0002-accept-warpline-bet-defer-classifier.md b/docs/product/decisions/0002-accept-warpline-bet-defer-classifier.md new file mode 100644 index 0000000..047e97a --- /dev/null +++ b/docs/product/decisions/0002-accept-warpline-bet-defer-classifier.md @@ -0,0 +1,23 @@ +# PDR-0002 — Accept the warpline interfaces bet (Tasks 1–7); defer the attestation classifier (Task 8) as BLOCKED + +Date: 2026-06-25 Status: accepted (build/accept within grant; MERGE is owner-gated — see flags) Author: claude (opus, product-owner) +Supersedes: — Related: [[0003-federation-read-doctrine]]; tracker legis-1734128d34; spec `docs/superpowers/specs/2026-06-24-legis-warpline-interfaces-design.md`; plan `docs/superpowers/plans/2026-06-24-legis-warpline-interfaces-plan.md` + +## Context +Warpline (Weft sibling; impact-radius / reverify-worklist analysis) requested two Legis interfaces: an advisory preflight consumer and a per-SEI attestation read (governance-as-verification, Rung 2). This was the Now federation-seam bet (legis-1734128d34). Load-bearing constraint: governance verdicts must be **byte-identical** whether warpline is present or absent; Legis stays the **only** governance/attestation authority. + +## Options considered +1. Build the whole feature including the attestation classifier in one pass. +2. Build the advisory boundary + the attestation **fail-closed scaffolding**, and DEFER the positive-admission classifier pending owner ratification of a forge-proof discriminator. +3. Don't build / defer the whole bet. + +## The call +Option 2. Built Tasks 1–7 via subagent-driven TDD from a grounded, adversarially-reviewed plan: the stdlib `HttpWarplineClient`, `warpline_preflight_get` (advisory), `attestation_get` fail-closed scaffolding, the byte-identical advisory-boundary acceptance spine, and a derived structural guard over all tool handlers. The positive-admission classifier (Task 8) is **BLOCKED** and ships an honest `unavailable` (no false-green) because grounding proved the obvious operator-override discriminator is **forgeable** (the chill engine writes caller `extensions` verbatim) — shipping it unratified risks a false-"attested" → warpline skips reverify on un-cleared code (a security hole). Accepted on all CI-equivalent gates green (pytest 1225 passed, mypy clean, coverage 92.14% ≥88% floor, ruff) + a whole-branch review. + +## Rationale +The advisory boundary is the whole point of the bet and is now proven (structural + behavioral byte-identity), not asserted. The classifier is the single piece that can introduce a false-green on the honesty surface, and its safe discriminator is a **spec-level security decision** that requires the owner. Deferring it (fail-closed, surfaces nothing) is honest and reversible; guessing it is a one-way security risk. Two review-caught defects — a stale structural guard and a false-green BLOCKED stub (`checked: []` in wired deployments) — were found and fixed before merge. + +## Reversal trigger +- If the byte-identical advisory-boundary invariant ever fails (a governance verdict diverges with warpline present vs absent) → **reopen immediately**; that invariant is the guardrail (metrics.md). +- If the owner ratifies the four Task-8 classifier questions → Task 8 unblocks as a **new** decision (new PDR), flipping `attestation_get` from `unavailable` to real attestations. +- If warpline's real wire format contradicts the inferred §6 parser → the client's URL/parse layer reopens (isolated; degrades to `unavailable` until then). diff --git a/docs/product/decisions/0003-federation-read-doctrine.md b/docs/product/decisions/0003-federation-read-doctrine.md new file mode 100644 index 0000000..f036443 --- /dev/null +++ b/docs/product/decisions/0003-federation-read-doctrine.md @@ -0,0 +1,22 @@ +# PDR-0003 — Federation-read doctrine: Legis exposes verified FACTS, never a verdict; advisory context is structurally isolated + +Date: 2026-06-25 Status: accepted (reinforces existing vision anti-goals — no new sign-off) Author: claude (opus, product-owner) +Supersedes: — Related: [[0002-accept-warpline-bet-defer-classifier]]; vision.md anti-goals ("a second judge of trust", "an identity owner") + +## Context +The warpline seam is Legis's first time being an HTTP **client** of a sibling on a path next to governance reads, and the first time it **exposes** a read a sibling treats as proof. This pattern recurs (Clarion SEI consume; future sibling fact-providers), so it needs a durable principle to stop future seams from eroding the authority boundary. + +## Options considered +1. Legis returns a `proven_good` / skip-reverify **verdict** the sibling acts on directly (Legis decides). +2. Legis returns verified **FACTS** (attested content_hash, kind, seq); the sibling does its own Rung-2 commit-match and skip decision (Legis attests, the sibling decides). +3. Embed advisory sibling reads **inside** the governance honesty reads. + +## The call +Option 2 for attestation; option 3 **rejected** — advisory context lives in a DEDICATED sibling tool structurally isolated from every verdict path. `attestation_get` returns facts; warpline makes the skip-reverify call. `warpline_preflight_get` is a sibling tool whose data is never an input to `policy_evaluate` / the gates / sign-off / the honesty reads, enforced by a derived structural test over all tool handlers (covers future tools by construction). + +## Rationale +"Wardline analyses, Legis governs." Legis must never become a second judge of trust, and advisory context must be **structurally incapable** of reaching a verdict — not merely "we didn't wire it." Facts-not-verdict keeps Legis the sole authority while still letting siblings build verification on top. This is the doctrine for every future federation read. + +## Reversal trigger +- If a sibling shows a need Legis cannot meet with facts-only (a genuine case where Legis must *decide*) → revisit as an explicit **vision / authority-grant escalation** (it would change the "not a second judge" anti-goal). +- If any future seam is found feeding sibling data into a verdict path → treat as a **Critical** regression against this doctrine. diff --git a/docs/product/decisions/0004-ratify-implement-forge-proof-attestation-classifier.md b/docs/product/decisions/0004-ratify-implement-forge-proof-attestation-classifier.md new file mode 100644 index 0000000..224db68 --- /dev/null +++ b/docs/product/decisions/0004-ratify-implement-forge-proof-attestation-classifier.md @@ -0,0 +1,25 @@ +# PDR-0004 — Ratify + implement the forge-proof attestation classifier (Task 8); correct spec §4 + +Date: 2026-06-25 Status: accepted (owner ratified the discriminator 2026-06-25; merge still owner-gated) Author: claude (opus, product-owner) +Unblocks the Task-8 deferral in [[0002-accept-warpline-bet-defer-classifier]] Related: [[0003-federation-read-doctrine]]; tracker legis-1734128d34; commits da85e16, 1e21418 + +## Context +PDR-0002 shipped `attestation_get` fail-closed and **deferred** its positive-admission classifier, because the obvious operator-override discriminator is forgeable. The owner **ratified** the four classifier resolutions (2026-06-25, "Done"): (a) operator-override = a *verifying* signature, never the bare field; (b) only *signed* sign-offs attest; (c) no-key deployments → `unavailable`; (d) absent `content_hash` → omit. + +## Options considered +1. Key off the (signed) verdict value, trusting that the record is in the verified set. +2. Key off the **signature MARKER presence** (`judge_metadata_signature` / `signoff_signature`) — a strict subset of `_requires_verification`, so "admitted ⟹ verified" holds — integrity-bind the sign-off `content_hash` join via the signed `request_payload_hash`, and read only the signed `entity_key` dict. +3. Keep it blocked (ship no classifier). + +## The call +Option 2. Grounding confirmed the load-bearing facts against the real code: `judge_verdict`, `protected_cell`, and the inline `content_hash` are inside `signing_fields` (**FORGE-A closed**); the signed `SIGNED_OFF` carries a signed `request_payload_hash` (**FORGE-B closed**). Both kinds (`operator_override`, `signoff_cleared`) shipped forge-proof. The *necessary-but-not-sufficient* trap (membership in the verified set ≠ the field is signed) is closed by gating admission on the signature marker (a subset of the verification predicate) and keying only on signed fields; the sign-off join recomputes the PENDING hash and compares it to the signed `request_payload_hash` rather than trusting the `request_seq` pointer. + +Verified by an **adversarial forge phase** (4 lenses, live-run probes): **zero forges admitted**, both genuine positives admit; full CI gate green (pytest 1237, mypy clean, coverage 92.13%, ruff). Spec §4.1/§4.2/§7 corrected (the false *unconditional* fail-closed claim → conditional on a signature-verifiable trail; lowercase `signed_off` → `SIGNED_OFF`; hand-wave → the confirmed discriminator). + +## Rationale +The forge-proof property is **structural**: admission requires the signature marker → the record was cryptographically verified → the keyed fields are authentic. A mutated unsigned field either breaks the signature (→ `AUDIT_INTEGRITY_FAILURE`) or isn't a field the classifier keys off. The sign-off join is integrity-checked, not pointer-trusted. This is the strongest "governed-good" signal warpline can safely skip reverification on, and it keeps the asymmetric rule (any ambiguity → omit). + +## Reversal trigger +- If any adversarial review or production incident finds a forged / non-human-cleared record **admitted** → reopen immediately (Critical; the asymmetric rule is breached). +- If the protected / sign-off **signing surface** changes (a keyed field moves out of `signing_fields` / `signoff_signing_fields`) → re-audit the discriminator against the new signed-field set. +- Broadening the admitted set beyond the two human-cleared kinds is additive and requires the same signing-coverage proof per new kind. diff --git a/docs/product/metrics.md b/docs/product/metrics.md new file mode 100644 index 0000000..ae9cca2 --- /dev/null +++ b/docs/product/metrics.md @@ -0,0 +1,27 @@ +# Metrics — Legis Last read: 2026-06-25 + +> 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 +> trust on a hot path), not usage. Targets below carry a number and a date so a +> bet can be accepted or a PDR reversal trigger can fire. BASELINE/TARGET +> placeholders marked `(set)` need a real number from the owner. + +## 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 | → | + +## 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 | + +## 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 | +| **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 | diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md new file mode 100644 index 0000000..65afd84 --- /dev/null +++ b/docs/product/roadmap.md @@ -0,0 +1,18 @@ +# Roadmap — Legis Updated: 2026-06-24 (PDR-0001) + +> 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) + +## 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 +- **Federation integration hardening (live-fire)** — run the cross-tool seams against real daemons, not stubs: real-Filigree bind/closure (G12), move-aware SEI backfill (G2), backfill failure guards (G8), two-way rename-parser conformance vectors (G16) · tracker: legis-356fe094dd, legis-bc9e5f3e60, legis-b7ce9fdc40, legis-c4cbf78fdb + +## Later (directional bets, no order, no dates) +- **Additional key-custody backends** — 1Password / Vault signer backends beyond the v1 OS-keychain + age-file + env escape hatch. +- **Broader provider seams** — Clarion SEI consume seam and any further sibling fact-provider contracts as the federation grows. +- **Audit-trail retention / compaction** — the honest lever for the O(N)-per-read trail-verification cost if trail size ever becomes latency-bound. diff --git a/docs/product/vision.md b/docs/product/vision.md new file mode 100644 index 0000000..990f5e5 --- /dev/null +++ b/docs/product/vision.md @@ -0,0 +1,41 @@ +# Vision — Legis + +## Purpose +Legis is the **governance surface of the Weft suite**: it turns "did a human authorize this change, and is that authorization still valid for the code as it stands now?" into a cited, durable, machine-readable fact. It records SEI-keyed governance verdicts, overrides, sign-offs, and audit lineage over the 2×2 enforcement cells (chill / coached / structured / protected), and exposes the git/CI provenance surface (branch / commit / PR / CI state) that those verdicts attach to. Its defining property is **governance _honesty_** — it never reports a green it cannot prove, and an unauthorized or unverifiable change is always _detectable_. It serves agents first: humans supervise, approve, and govern from **outside** the operating loop, not inside it. + +## Who it serves +- **Primary:** coding agents operating under governance — the agent-customers of the Legis MCP/HTTP surface that submit overrides, request sign-offs, read attestations, and route Wardline findings. +- **Secondary:** human operators who supervise and authorize from outside the loop (sign-offs, posture floor, key custody, release gates) — served, but never pulled _into_ the operating cycle. +- **Sibling tools** (Loomweave, Wardline, Filigree, Warpline) consuming Legis's git-rename feed and governance attestations across the federation contracts. +- **Explicitly not:** humans who want a config-driven CI dashboard; anyone wanting Legis to be a general-purpose CI runner, a trust/taint analyzer, or an identity authority. + +## Anti-goals (what it refuses to be) +- **A second judge of trust.** "Wardline analyses, Legis governs." Trust vocabulary passes through verbatim; Legis never re-adjudicates a taint/trust verdict. +- **An identity owner.** The SEI is opaque to Legis. It consumes Loomweave's `resolve_sei` / `lineage`; it never parses or mints identity. +- **Tamper-_proof_.** Legis is a governance-_honesty_ tool. The honest claim is "an unauthorized change is detectable," never "impossible." +- **A config-driven security boundary.** Config is not a security boundary; signing keys stay out of agent reach. Governance is promoted into signed records, not editable TOML. +- **Human-in-the-loop by default.** Zero _human_ config; the instruction layer is the configuration mechanism. Humans gate by exception, not by operating the tool. + +## Authority grant +Granted by: john (john@pgpl.net) Last reviewed: 2026-06-24 +Review cadence: monthly, or on any vision change + +Autonomous within strategy — the agent MAY, without asking: + prioritize the backlog, write PRDs, dispatch delivery, accept against + criteria, reprioritize, kill a failing bet per metrics.md. + +Escalate BEFORE acting — the agent MUST get owner sign-off for: + - changing this vision / strategy / authority grant + - a PyPI publish or a GitHub release (outward-facing) + - deprecating a feature siblings or users depend on + - changing a **federation contract that binds a sibling tool** (the + contracts-index seams: SEI consumption, git-rename provider, Filigree + sign-off binding, Wardline routing, Warpline preflight) — these bind + external parties (sibling maintainers), so a contract change escalates + - a pricing / commercial / licensing change + - deleting data or any irreversible data operation + - anything touching an external party (sibling maintainers, users, registries) + (Taxonomy + rationale: product-ownership-operating-model.md.) + +> Grant **confirmed live** by the owner on 2026-06-24 during bootstrap +> (`/own-product`). Status: confirmed, not draft. diff --git a/docs/superpowers/plans/2026-06-24-legis-warpline-interfaces-plan.md b/docs/superpowers/plans/2026-06-24-legis-warpline-interfaces-plan.md new file mode 100644 index 0000000..f83faaf --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-legis-warpline-interfaces-plan.md @@ -0,0 +1,1108 @@ +# Legis ↔ warpline interfaces — preflight consumer + per-SEI attestation read — TDD implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Legis side of warpline's two requested interfaces — an env-gated advisory HTTP preflight consumer (`warpline_preflight_get`) and a fail-closed per-SEI attestation read (`attestation_get`) — such that warpline data is structurally incapable of reaching any governance verdict path and governance verdicts are byte-identical whether `WARPLINE_API_URL` is set or unset. + +**Architecture:** `HttpWarplineClient` is a near-clone of `HttpFiligreeClient` (stdlib `urllib`, injectable `fetch`, loopback/HTTPS gating, response-size bound, no-redirect), held as a plain optional `warpline: Any | None` field on `McpRuntime` and read ONLY inside the new `warpline_preflight_get` handler. The preflight read lives in a new transport-agnostic `service/preflight.py` mirroring `read_identity_gaps`; the attestation read lives in `service/governance.py` next to the other discriminated honesty reads and consumes the existing fail-closed `verified_records` trail path. Both tools return the house discriminated `checked`/`unavailable` shape so no empty ever reads as "nothing impacted" / "never attested". + +**Tech Stack:** Python 3.13, stdlib only for the client (`urllib`, `http.client`, `ipaddress`, `json`, `logging`); `pytest` + `jsonschema` (`Draft202012Validator`) for tests; existing Legis MCP adapter (`src/legis/mcp.py`), service layer (`src/legis/service/`), and enforcement trail (`src/legis/enforcement/{protected,signoff}.py`). + +--- + +## Global Constraints + +These are load-bearing invariants. Copy them verbatim into every relevant task; do not soften. + +- **ADVISORY BOUNDARY.** Legis is the ONLY governance / sign-off / attestation authority. Warpline context is PURELY ADVISORY. `runtime.warpline` may be referenced ONLY inside the `warpline_preflight_get` handler (`_tool_warpline_preflight_get`) and the `read_warpline_preflight` service function. It must be STRUCTURALLY ABSENT from `policy_evaluate` / `_engine` / `_coached_engine` / the gates / sign-off / the honesty reads (`identity_gap_list`, `lineage_integrity_get`, `policy_boundary_check`) and from `read_sei_attestations` / `attestation_get`. +- **BYTE-IDENTICAL ACCEPTANCE.** Governance verdicts are BYTE-IDENTICAL whether `WARPLINE_API_URL` is set or unset. Warpline data must be structurally incapable of reaching a verdict path. +- **ASYMMETRIC ERROR RULE (`attestation_get` feeds warpline's skip-reverify decision).** A FALSE "attested" (surfacing a record a human never cleared) → warpline skips reverify on un-cleared code → SECURITY HOLE. An OMITTED real attestation → warpline reverifies something fine → wasted work, SAFE. Therefore EVERY ambiguous or failure case resolves toward "not attested": OMIT the record, or return the discriminated `status: "unavailable"`, NEVER toward surfacing it. An omission must still NOT read as a silent "never attested" empty — use the discriminated `unavailable` status with a `reason`, never a bare empty list under `status: "checked"` that did not actually check. +- **STDLIB-ONLY CLIENT.** `HttpWarplineClient` uses stdlib `urllib` with an injectable `Fetch = Callable[[str, str, "dict | None"], dict]` so tests run fully offline. No new dependency. +- **LOOPBACK / HTTPS GATING.** `http` to a non-loopback host is rejected (`WarplineError`) unless `LEGIS_ALLOW_INSECURE_REMOTE_HTTP == "1"` (string compare), which logs the same forgeable-responses warning Filigree emits. `localhost` and any `ipaddress.ip_address(host).is_loopback` host are allowed over `http`. +- **`MAX_RESPONSE_BYTES = 1_000_000`.** Responses are read with `resp.read(MAX_RESPONSE_BYTES + 1)` and rejected (`WarplineError`) if larger. +- **NO-REDIRECT.** `_NoRedirectHandler.redirect_request` returns `None`; a 3xx surfaces as `WarplineError("... redirect not allowed: ")`. +- **CLONE, do not refactor-share (ticket `legis-bc407a86ed`).** The URL-validation + fetch helpers are duplicated verbatim into `warpline_preflight`, NOT extracted to a shared module. Accepted duplication; do NOT plan a shared-helper refactor. + +--- + +## File Structure + +| File | New/Modified | Responsibility | +|---|---|---| +| `src/legis/warpline_preflight/__init__.py` | New | Package marker for the warpline preflight client. | +| `src/legis/warpline_preflight/client.py` | New | `HttpWarplineClient`, `@runtime_checkable WarplineClient` Protocol, `WarplineError`, the cloned `_validate_base_url`/`_urllib_fetch`/`_decode_json_response`/`_require_dict`/`_NoRedirectHandler`/`_open_no_redirect`/`_is_loopback` helpers, `MAX_RESPONSE_BYTES`, `Fetch`. Two read-only GET methods `impact_radius(base, head)` / `reverify_worklist(base, head)`. | +| `src/legis/service/preflight.py` | New | `read_warpline_preflight(warpline_client, base, head)` — transport-agnostic discriminated `checked`/`unavailable` read; catches `WarplineError`, never escapes as `INTERNAL_ERROR`. | +| `src/legis/service/__init__.py` | Modified | Export `read_warpline_preflight` (new `from legis.service.preflight import ...` line + `__all__` entry, per the `route_wardline_scan` precedent) and `read_sei_attestations` (existing governance import block + `__all__`). | +| `src/legis/service/governance.py` | Modified | `read_sei_attestations(verified_runtime_records, sei)` — fail-closed per-SEI attestation classifier (positive admission BLOCKED pending owner ratification; see Task 8). | +| `src/legis/mcp.py` | Modified | `McpRuntime.warpline` field (end of dataclass); `build_runtime` `WARPLINE_API_URL` gating + pass into ctor; `warpline_preflight_get` + `attestation_get` tool definitions, handlers, `_TOOL_HANDLERS` registration, `_AGENT_TOOLS` membership; `_governance_trail_records`-based attestation handler with the protected-gate fail-closed pre-gate. | +| `tests/warpline_preflight/__init__.py` | New | Test package marker. | +| `tests/warpline_preflight/test_client.py` | New | Client unit tests: URL validation, size bound, no-redirect, non-JSON content-type, offline-via-injected-fetch. | +| `tests/service/test_preflight.py` | New | `read_warpline_preflight` service tests. | +| `tests/service/test_governance.py` | Modified | `read_sei_attestations` classifier tests (verified-trail, tamper, SEI/`identity_stable` filters, omit rules). | +| `tests/mcp/test_warpline_advisory_boundary.py` | New | The byte-identical acceptance spine: governance path unset vs hostile-injected warpline. | +| `tests/mcp/test_server.py` | Modified | Dispatch + env-pairing + surface-set literal (22→24) + `runtime.warpline` structural-boundary tests. | +| `tests/mcp/test_output_schema_conformance.py` | Modified | outputSchema conformance vectors for both new tools. | + +--- + +## Task 1 — `HttpWarplineClient` + client unit tests + +**Files** +- Create: `src/legis/warpline_preflight/__init__.py`, `src/legis/warpline_preflight/client.py` +- Create test: `tests/warpline_preflight/__init__.py`, `tests/warpline_preflight/test_client.py` + +**Interfaces** +- Produces: `class WarplineError(RuntimeError)`; `Fetch = Callable[[str, str, "dict | None"], dict]`; `MAX_RESPONSE_BYTES = 1_000_000`; `@runtime_checkable class WarplineClient(Protocol)` with `impact_radius(self, base: str, head: str) -> dict[str, Any]` and `reverify_worklist(self, base: str, head: str) -> dict[str, Any]`; `class HttpWarplineClient.__init__(self, base_url: str, *, fetch: Fetch | None = None) -> None`. +- Consumes: stdlib only. + +Steps: + +- [ ] **Write failing test** `tests/warpline_preflight/test_client.py`: +```python +import json + +import pytest + +from legis.warpline_preflight.client import ( + HttpWarplineClient, + WarplineClient, + WarplineError, + MAX_RESPONSE_BYTES, +) + + +def _recorder(responses): + """An injectable Fetch that returns queued dicts and records calls.""" + calls = [] + + def fetch(method, url, body): + calls.append((method, url, body)) + return responses.pop(0) + + fetch.calls = calls + return fetch + + +def test_protocol_is_runtime_checkable(): + client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([{}])) + assert isinstance(client, 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_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_non_object_response_is_a_warpline_error(): + client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([["not", "a", "dict"]])) + 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") + + +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 +``` + +- [ ] **Run to fail:** `uv run pytest tests/warpline_preflight/test_client.py -q` — fails with `ModuleNotFoundError: legis.warpline_preflight`. + +- [ ] **Implement** `src/legis/warpline_preflight/__init__.py` (empty) and `src/legis/warpline_preflight/client.py`. CLONE `src/legis/filigree/client.py` exactly, substituting `Filigree`→`Warpline`, DROP the `weft_signing` import (`_json_body_bytes`) — both methods are GET with `body=None`, so use plain `json.dumps(body).encode("utf-8")` in `_urllib_fetch`. Reuse the env var `LEGIS_ALLOW_INSECURE_REMOTE_HTTP` verbatim. Full module: +```python +"""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 +(policy_evaluate, the gates, sign-off, or the honesty reads). Governance +verdicts are byte-identical whether WARPLINE_API_URL is set or unset. +""" + +from __future__ import annotations + +import json +import http.client +import ipaddress +import logging +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Callable, Protocol, runtime_checkable + +Fetch = Callable[[str, str, "dict | None"], dict] + +logger = logging.getLogger(__name__) + + +class WarplineError(RuntimeError): + """A Warpline call failed at the transport or decode layer.""" + + +MAX_RESPONSE_BYTES = 1_000_000 + + +@runtime_checkable +class WarplineClient(Protocol): + 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("/") + + +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 + + def _transport_fetch(self, method: str, url: str, body: dict | None) -> dict: + return _urllib_fetch(method, url, body, {}) + + 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", + ) + + 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", + ) +``` +> NOTE — INFERRED CONTRACT (spec §6, TO-CONFIRM): the route paths `/api/impact-radius`, `/api/reverify-worklist` and the `base`/`head` query-param names are inferred from the Filigree GET pattern, NOT grounded in a warpline spec. When warpline ships its real shape only this client's URL construction + one fixture change. The shape-validation is intentionally minimal: `_require_dict` rejects a non-object (→ `WarplineError`), fields pass through verbatim. + +- [ ] **Add a clone-parity guard.** The clone-not-share decision (ticket `legis-bc407a86ed`) means warpline's SSRF / redirect / DoS primitives can silently diverge from filigree's on a future security patch. Pin them — the repo precedent is the canonical-JSON golden mirror against Wardline. Append to `tests/warpline_preflight/test_client.py`: +```python +import inspect + +import legis.filigree.client as fc +import legis.warpline_preflight.client as wc + + +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_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 + ) +``` + +- [ ] **Run to pass:** `uv run pytest tests/warpline_preflight/test_client.py -q`. + +- [ ] **Commit:** `feat(warpline): add stdlib HttpWarplineClient advisory preflight client`. + +--- + +## Task 2 — `read_warpline_preflight` service function + tests + +**Files** +- Create: `src/legis/service/preflight.py` +- Modify: `src/legis/service/__init__.py` +- Create test: `tests/service/test_preflight.py` + +**Interfaces** +- Produces: `read_warpline_preflight(warpline_client: WarplineClient | None, base: str, head: str) -> dict[str, Any]` returning either `{"status": "checked", "impact_radius": {...}, "reverify_worklist": {...}}` or `{"status": "unavailable", "unavailable": [{"reason": }]}`. +- Consumes: `legis.warpline_preflight.client.WarplineError` (caught locally, lazy import — mirrors `read_identity_gaps` catching `LoomweaveError`). + +Steps: + +- [ ] **Write failing test** `tests/service/test_preflight.py`: +```python +from legis.service.preflight import read_warpline_preflight +from legis.warpline_preflight.client import WarplineError + + +class _OkWarpline: + def impact_radius(self, base, head): + return {"affected": [{"sei": "S1"}], "count": 1} + + def reverify_worklist(self, base, head): + return {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1} + + +class _ImpactRaisesWarpline: + def impact_radius(self, base, head): + raise WarplineError("boom") + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + +class _WorklistRaisesWarpline: + def impact_radius(self, base, head): + return {"affected": [], "count": 0} + + def reverify_worklist(self, base, head): + raise WarplineError("timeout") + + +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}, + } + + +def test_unavailable_when_client_is_none_not_a_silent_empty(): + out = read_warpline_preflight(None, "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"] == [{"reason": "warpline client not configured"}] + # ASYMMETRIC: never an empty affected-set that reads as "nothing impacted". + assert "impact_radius" not in out + + +def test_unavailable_when_impact_radius_raises_warpline_error(): + out = read_warpline_preflight(_ImpactRaisesWarpline(), "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"][0]["reason"].startswith("warpline check failed:") + + +def test_unavailable_when_worklist_raises_warpline_error(): + # Partial advisory context is NOT surfaced as checked — either method failing + # degrades the WHOLE read to unavailable. + out = read_warpline_preflight(_WorklistRaisesWarpline(), "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"][0]["reason"].startswith("warpline check failed:") + + +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 +``` + +- [ ] **Run to fail:** `uv run pytest tests/service/test_preflight.py -q` — fails: no `legis.service.preflight`. + +- [ ] **Implement** `src/legis/service/preflight.py`: +```python +"""The warpline advisory preflight read — 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 +``read_identity_gaps`` converts a ``LoomweaveError``. An unreachable/unconfigured +warpline → ``unavailable`` with a reason, never an empty affected-set that reads +as "nothing impacted". +""" + +from __future__ import annotations + +from typing import Any + + +def read_warpline_preflight( + warpline_client: Any | None, base: str, head: str +) -> dict[str, Any]: + from legis.warpline_preflight.client import WarplineError + + if warpline_client is None: + return { + "status": "unavailable", + "unavailable": [{"reason": "warpline client not configured"}], + } + try: + impact = warpline_client.impact_radius(base, head) + worklist = warpline_client.reverify_worklist(base, head) + except WarplineError as exc: + return { + "status": "unavailable", + "unavailable": [{"reason": f"warpline check failed: {exc}"}], + } + return { + "status": "checked", + "impact_radius": impact, + "reverify_worklist": worklist, + } +``` + +- [ ] **Export** in `src/legis/service/__init__.py`: add `from legis.service.preflight import read_warpline_preflight` (following the `from legis.service.wardline import route_wardline_scan` precedent) and add `"read_warpline_preflight"` to `__all__`. + +- [ ] **Run to pass:** `uv run pytest tests/service/test_preflight.py -q`. + +- [ ] **Commit:** `feat(warpline): add read_warpline_preflight discriminated service read`. + +--- + +## Task 3 — `warpline_preflight_get` tool wiring + `build_runtime` env gating + dispatch tests + +**Files** +- Modify: `src/legis/mcp.py` +- Modify test: `tests/mcp/test_server.py` + +**Interfaces** +- Produces: MCP tool `warpline_preflight_get` (input `{base: string (required), head?: string}`); handler `_tool_warpline_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]`; `McpRuntime.warpline: Any | None = None`; `build_runtime` wiring gated on `WARPLINE_API_URL`. +- Consumes: `read_warpline_preflight` (Task 2); `HttpWarplineClient` (Task 1); `_tool_result`, `_require`, `_schema`, `_one_of` (mcp.py). + +Steps: + +- [ ] **Write failing tests** appended to `tests/mcp/test_server.py` (reusing the in-file fixtures `_runtime` at lines 72-92 and `call_tool`): +```python +def test_build_runtime_wires_warpline_from_env(monkeypatch, tmp_path): + from legis.mcp import build_runtime + from legis.warpline_preflight.client import HttpWarplineClient + + monkeypatch.setenv("WARPLINE_API_URL", "http://localhost:9100") + 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) + + +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.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 + # 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("LEGIS_SOURCE_ROOT", str(tmp_path)) + monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) + runtime = build_runtime("agent-x") + assert runtime.warpline is None + + +def test_warpline_preflight_get_unavailable_when_unwired(tmp_path): + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) # warpline defaults to None + result = call_tool(runtime, "warpline_preflight_get", {"base": "aaa"}) + assert not result.get("isError") + assert result["structuredContent"] == { + "status": "unavailable", + "unavailable": [{"reason": "warpline client not configured"}], + } + + +def test_warpline_preflight_get_checked_with_injected_client(tmp_path): + from legis.mcp import call_tool + + class _FakeWarpline: + def impact_radius(self, base, head): + return {"affected": [{"sei": "S1"}], "count": 1} + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + runtime, _store = _runtime(tmp_path) + runtime.warpline = _FakeWarpline() + result = call_tool(runtime, "warpline_preflight_get", {"base": "aaa", "head": "bbb"}) + 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} +``` + +- [ ] **Run to fail:** `uv run pytest tests/mcp/test_server.py -q -k warpline_preflight` — fails: `UNKNOWN_TOOL` / no `warpline` field. + +- [ ] **Implement (a) the `McpRuntime` field.** At the END of the `McpRuntime` dataclass (after `coached_engine: EnforcementEngine | None = None`, mcp.py:178 — field-order is load-bearing because of the positional ctor at `tests/mcp/test_server.py:150`): +```python + coached_engine: EnforcementEngine | None = None + warpline: Any | None = None # advisory sibling; NEVER read by a verdict path +``` + +- [ ] **Implement (b) the `build_runtime` env-gating block.** Mirror the FILIGREE block (mcp.py:221-226); insert adjacent to it. UNLIKE the Filigree block, wrap construction in try/except — warpline is PURELY ADVISORY, so a misconfigured URL must never crash the governance authority at startup (degrade to `None`; `import logging` is already present at the top of mcp.py): +```python + warpline = None + warpline_url = os.environ.get("WARPLINE_API_URL") + if warpline_url: + from legis.warpline_preflight.client import HttpWarplineClient, WarplineError + + try: + warpline = HttpWarplineClient(warpline_url) + except WarplineError: + logging.getLogger(__name__).warning( + "WARPLINE_API_URL is set but invalid; warpline advisory context " + "disabled (governance unaffected)." + ) + warpline = None +``` +and add `warpline=warpline,` to the `McpRuntime(...)` return (near the `filigree=filigree,` line, mcp.py:283). + +- [ ] **Implement (c) the tool definition** in `tool_definitions()` (mcp.py:351+), modeled on `git_rename_feed_get` (mcp.py:809-840). Use `_one_of` for the discriminated output: +```python + { + "name": "warpline_preflight_get", + "description": ( + "ADVISORY preflight context from the warpline sibling: impact " + "radius + reverify worklist over base..head. Purely advisory — " + "NEVER a governance verdict. Discriminated: 'checked' carries the " + "advisory facts; 'unavailable' (client unconfigured, transport " + "failure, 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", "impact_radius", "reverify_worklist"], + { + "status": {"type": "string", "enum": ["checked"]}, + "impact_radius": {"type": "object"}, + "reverify_worklist": {"type": "object"}, + }, + ), + _schema( + ["status", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, +``` + +- [ ] **Implement (d) the handler** (near the other read handlers): +```python +def _tool_warpline_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.preflight import read_warpline_preflight + + return _tool_result( + read_warpline_preflight( + runtime.warpline, + base=_require(args, "base"), + head=args.get("head", "HEAD"), + ) + ) +``` + +- [ ] **Implement (e) the three registries** (test `test_tool_registries_are_in_sync` enforces equality): + - `_AGENT_TOOLS` (mcp.py:81-106): add `"warpline_preflight_get",`. + - `_TOOL_HANDLERS` (mcp.py:2421-2444): add `"warpline_preflight_get": _tool_warpline_preflight_get,`. + - tool_definitions(): the entry from step (c). + +- [ ] **Run to pass:** `uv run pytest tests/mcp/test_server.py -q -k warpline_preflight`. + +- [ ] **Commit:** `feat(mcp): wire warpline_preflight_get advisory sibling tool`. + +--- + +## Task 4 — Byte-identical advisory-boundary acceptance spine (`test_warpline_advisory_boundary.py`) + +This is the spine of the whole change. It is its own task and proves the invariant directly. + +**Files** +- Create test: `tests/mcp/test_warpline_advisory_boundary.py` + +**Interfaces** +- Consumes: `build_runtime`, `call_tool`, the existing runtime/store fixtures. + +Steps: + +- [ ] **Write failing test** `tests/mcp/test_warpline_advisory_boundary.py`. It runs representative governance paths (a `policy_evaluate`, an override submit, and a sign-off read) twice — with `WARPLINE_API_URL` unset, and again with it set to an injected HOSTILE warpline returning arbitrary impact data — and asserts byte-identical results. Plus the structural-boundary test that `runtime.warpline` is referenced in no verdict-path function: +```python +import inspect +import json + +from legis.policy.grammar import AllowlistBoundary, PolicyGrammar + + +class _HostileWarpline: + """Returns arbitrary/garbage advisory data to prove it cannot perturb a verdict.""" + + def impact_radius(self, base, head): + return {"affected": [{"sei": "EVERYTHING"}], "count": 9999, "block": True} + + def reverify_worklist(self, base, head): + return {"entries": [{"sei": "EVERYTHING", "reason": "force"}], "count": 9999} + + +def _seed_real_verdict_runtime(tmp_path): + """A runtime that returns REAL, DETERMINISTIC verdicts. + + Uses the _runtime fixture's FixedClock (timestamps identical across runs) and + registers a real grammar so policy_evaluate returns an actual VIOLATION / + UNKNOWN verdict — NOT an error envelope. An error envelope on BOTH sides would + make the byte-identity assertion pass trivially and prove nothing — the exact + defect the first draft of this test had. Mirrors the seeding in + test_policy_evaluate_returns_unknown_distinct_from_clear (test_server.py:1225). + """ + 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 = [ + # A real VIOLATION verdict (socket not in the {json} allowlist). + call_tool( + runtime, "policy_evaluate", {"policy": "imports", "target": {"value": "socket"}} + ).get("structuredContent"), + # A real UNKNOWN verdict (unknown policy -> provenance gap). + call_tool( + runtime, "policy_evaluate", {"policy": "missing", "target": {}} + ).get("structuredContent"), + ] + # GUARD: these MUST be real verdicts, never error envelopes — otherwise the + # byte-identity assertion below is vacuous. + 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 + + +def test_governance_verdicts_byte_identical_warpline_unset_vs_hostile(tmp_path): + # Everything is held IDENTICAL across the two runtimes (same FixedClock, same + # seeded grammar) EXCEPT runtime.warpline. If warpline data could reach a + # verdict path, the hostile side would diverge. + runtime_unset = _seed_real_verdict_runtime(tmp_path / "a") + runtime_unset.warpline = None + unset = _run_governance_paths(runtime_unset) + + runtime_set = _seed_real_verdict_runtime(tmp_path / "b") + runtime_set.warpline = _HostileWarpline() # structurally present, hostile + setval = _run_governance_paths(runtime_set) + + assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True) + + +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 + # scan — it sees only these named functions, not helpers they call — so this + # COMPLEMENTS, never replaces, the byte-identity test above. + import legis.mcp as mcp + + verdict_path_fns = [ + mcp._tool_policy_evaluate, + mcp._engine, + mcp._coached_engine, + mcp._governance_trail_records, + mcp._tool_identity_gap_list, + mcp._tool_lineage_integrity_get, + mcp._tool_policy_boundary_check, + mcp._tool_signoff_status_get, + mcp._tool_override_submit, + ] + for fn in verdict_path_fns: + src = inspect.getsource(fn) + assert ".warpline" not in src, f"{fn.__name__} references warpline" +``` +> If any function name above differs in the codebase, adjust to the actual symbol; the set MUST cover `policy_evaluate`, `_engine`, `_coached_engine` (mcp.py:1526), the gates, sign-off, and the three honesty reads (`identity_gap_list`, `lineage_integrity_get`, `policy_boundary_check`). Task 5 adds `mcp._tool_attestation_get` to this list once that handler exists. + +- [ ] **Run to fail then pass:** Before Task 3 wiring this fails to import the tools; after Task 3 it passes. Run `uv run pytest tests/mcp/test_warpline_advisory_boundary.py -q`. The seeding (`_seed_real_verdict_runtime`) is identical on both sides and produces REAL deterministic verdicts via `FixedClock`, so the byte-identity assertion is meaningful — it would FAIL if warpline presence perturbed a verdict, and the in-test guard rejects a vacuous error-envelope pass. + +- [ ] **Commit:** `test(warpline): byte-identical advisory-boundary acceptance spine`. + +--- + +## Task 5 — `attestation_get` structural scaffolding: schema, registries, no-trail `unavailable`, tamper `AUDIT_INTEGRITY_FAILURE` + +This task builds everything about `attestation_get` EXCEPT the positive-admission classifier (which is BLOCKED — Task 8). The fail-closed paths, the discriminated schema, and the three-registry registration are concrete and testable now. + +**Files** +- Modify: `src/legis/mcp.py`, `src/legis/service/governance.py`, `src/legis/service/__init__.py` +- Modify test: `tests/mcp/test_server.py`, `tests/service/test_governance.py` + +**Interfaces** +- Produces: MCP tool `attestation_get` (input `{sei: string (required)}`); handler `_tool_attestation_get(runtime, args)`; service stub `read_sei_attestations(verified_runtime_records, sei) -> dict[str, Any]` returning the discriminated `checked`/`unavailable` shape. The handler applies the FAIL-CLOSED PRE-GATE: `if runtime.protected_gate is None: return unavailable` (cannot verify signatures in an engine-only deployment), then reads through `_governance_trail_records(runtime)` (which raises `AuditIntegrityError` on a tampered protected trail). +- Consumes: `_governance_trail_records` (mcp.py:2173), `verified_records` (governance.py:156), `AuditIntegrityError` (mapped to `AUDIT_INTEGRITY_FAILURE` by the MCP adapter). + +Steps: + +- [ ] **Write failing tests.** In `tests/mcp/test_server.py` (reusing `_runtime` and `call_tool`; the tamper test defines its OWN inline `_TamperVerifier` / `_FakeProtectedGate` below — do NOT reuse `_TamperedLedger` at test_server.py:2243, which raises `BindingError` from the closure gate, a different model): +```python +def test_attestation_get_unavailable_when_no_protected_gate(tmp_path): + # ENGINE-ONLY DEPLOYMENT: no LEGIS_HMAC_KEY -> runtime.protected_gate is None. + # The trail is not signature-verifiable, so attestation_get MUST return a + # success-envelope unavailable, NOT a silent empty that reads as "never attested". + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) # no protected gate wired + assert runtime.protected_gate is None + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert not result.get("isError") + sc = result["structuredContent"] + assert sc["status"] == "unavailable" + assert sc["sei"] == "mod.fn#1" + assert sc["attestations"] == [] + assert sc["unavailable"] and "reason" in sc["unavailable"][0] + + +def test_attestation_get_tamper_yields_audit_integrity_failure(tmp_path): + # FAIL-CLOSED: a tampered protected trail -> AUDIT_INTEGRITY_FAILURE, nothing + # surfaced. Build a protected runtime whose trail verifier raises TamperError. + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) + + class _TamperVerifier: + def verify(self, records): + from legis.enforcement.protected import TamperError + + raise TamperError("record 4 hash mismatch") + + class _FakeProtectedGate: + def records(self): + return ["bad-record"] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _TamperVerifier() + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert result.get("isError") + assert result["structuredContent"]["error_code"] == "AUDIT_INTEGRITY_FAILURE" +``` +And in `tests/service/test_governance.py` (reusing `_FakeProtectedGate` / `_TamperVerifier` / `verified_records` imports at lines 227-279): +```python +def test_read_sei_attestations_returns_checked_shape_on_empty_verified_trail(): + from legis.service.governance import read_sei_attestations + + out = read_sei_attestations([], "mod.fn#1") + assert out["status"] == "checked" + assert out["sei"] == "mod.fn#1" + assert out["attestations"] == [] +``` + +- [ ] **Run to fail:** `uv run pytest tests/mcp/test_server.py tests/service/test_governance.py -q -k attestation` — fails: `UNKNOWN_TOOL` / no `read_sei_attestations`. + +- [ ] **Implement (a) the service stub** in `src/legis/service/governance.py` next to `read_identity_gaps` / `read_lineage_integrity`. The positive-admission classifier body is BLOCKED (Task 8); ship the discriminated skeleton that always returns `checked` with an empty list for now (the no-trail `unavailable` discrimination is owned by the HANDLER pre-gate, not this function, because a pre-materialized `runtime_records` list cannot carry the verified/unverified distinction — validator high finding): +```python +def read_sei_attestations(verified_runtime_records: list, sei: str) -> dict[str, Any]: + """Per-SEI human-cleared attestation facts from the VERIFIED governance trail. + + ASYMMETRIC ERROR RULE: a FALSE "attested" lets warpline skip reverify on + un-cleared code (security hole); an OMITTED attestation only wastes work + (safe). Every ambiguous/failure case therefore resolves toward "not attested" + — omit the record, never surface it. ``verified_runtime_records`` MUST already + have come through ``verified_records`` — the handler guarantees this via the + protected-gate + trail-verifier pre-gate, and a tampered protected trail has + already raised AuditIntegrityError before this function is called. The + parameter is named for that contract: a future caller passing raw + ``_engine(runtime).records`` is then a self-documenting mistake. This function + takes a MATERIALIZED list (not a callable) — a bare list cannot carry the + verified/unverified distinction, so the gate decision lives in the handler. + + BLOCKED — positive admission classifier (Task 8): which records count as an + attestation, and the forge-proof discriminator, await owner ratification. + Until then this surfaces ZERO attestations (fail-closed: omit everything). + """ + records = list(verified_runtime_records) + attestations: list[dict[str, Any]] = [] + # Task 8: classify `records` for `sei` here once the discriminator is ratified. + return {"status": "checked", "sei": sei, "attestations": attestations} +``` + Export it: add `read_sei_attestations` to the governance import block + `__all__` in `src/legis/service/__init__.py`. + +- [ ] **Implement (b) the handler** in `src/legis/mcp.py`, with the FAIL-CLOSED PRE-GATE moved into the handler (validator high finding: the gate-presence decision must precede any record read, since `verified_records` falls through to unverified `engine_records()` when `trail_owner` is `None`): +```python +def _tool_attestation_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.governance import read_sei_attestations + + sei = _require(args, "sei") + # FAIL-CLOSED: attestation is only possible when the trail is signature- + # verifiable. `verified_records` ONLY runs TrailVerifier.verify when BOTH a + # protected trail_owner AND a trail_verifier are wired (governance.py:199-205); + # with a protected_gate but no verifier it returns engine records UNVERIFIED. + # Gate on BOTH so the invariant holds by construction, not by the convention + # that build_runtime co-locates them under one `if hmac_key:` block. Return the + # discriminated unavailable (NEVER a silent empty 'checked' that reads as + # "never attested", NEVER unverified field values). + if runtime.protected_gate is None or runtime.trail_verifier is None: + return _tool_result( + { + "status": "unavailable", + "sei": sei, + "attestations": [], + "unavailable": [ + {"reason": "trail not signature-verifiable (no protected gate / verifier)"} + ], + } + ) + # _governance_trail_records runs verified_records, which raises + # AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on a tampered protected trail. + return _tool_result(read_sei_attestations(_governance_trail_records(runtime), sei)) +``` + +- [ ] **Implement (c) the tool definition** via `_one_of` (discriminated, so it routes through `_one_of` per the conformance tests): +```python + { + "name": "attestation_get", + "description": ( + "Per-SEI human-cleared governance attestation FACTS (no proven_good " + "verdict). Through the same fail-closed verified-trail path the " + "honesty reads use: a tampered trail -> AUDIT_INTEGRITY_FAILURE; an " + "engine-only deployment (no protected gate) -> 'unavailable'. Never " + "read an empty attestations list under 'unavailable' as 'never " + "attested'; a forged attestation is never returned." + ), + "inputSchema": _schema(["sei"], {"sei": string}), + "outputSchema": _one_of( + [ + _schema( + ["status", "sei", "attestations"], + { + "status": {"type": "string", "enum": ["checked"]}, + "sei": string, + "attestations": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "content_hash", "recorded_at", "seq"], + "properties": { + "kind": { + "type": "string", + "enum": ["signoff_cleared", "operator_override"], + }, + "content_hash": string, + "recorded_at": string, + "seq": integer, + "signoff_seq": integer, + }, + }, + }, + }, + ), + _schema( + ["status", "sei", "attestations", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "sei": string, + "attestations": {"type": "array", "maxItems": 0}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, +``` + +- [ ] **Implement (d) the three registries:** add `"attestation_get"` to `_AGENT_TOOLS`, `"attestation_get": _tool_attestation_get` to `_TOOL_HANDLERS`, and the definition above to `tool_definitions()`. + +- [ ] **Run to pass:** `uv run pytest tests/mcp/test_server.py tests/service/test_governance.py -q -k attestation`. + +- [ ] **Commit:** `feat(mcp): add attestation_get fail-closed scaffolding (classifier BLOCKED)`. + +--- + +## Task 6 — Surface bookkeeping: tool-count bump 22→24 + surface-set literal + structural boundary + +**Files** +- Modify test: `tests/mcp/test_server.py` + +**Interfaces** +- Consumes: `_AGENT_TOOLS`, `_TOOL_HANDLERS`, `tool_definitions()`. + +Steps: + +- [ ] **Update the surface-set literal** `test_initialize_and_tools_list_exposes_full_agent_surface` (tests/mcp/test_server.py:266-326): add `"warpline_preflight_get"` and `"attestation_get"` to the `set(by_name) == {...}` literal at lines 282-305 (now 24 names). The binding invariant `test_tool_registries_are_in_sync` (test_server.py:2053-2064: `defined == set(_TOOL_HANDLERS) == set(_AGENT_TOOLS)`) needs NO edit — it is structural and already passes once all three registries carry both new tools (Tasks 3 + 5). + +- [ ] **Confirm no new error code is introduced.** Both tools degrade ambiguous/failure cases to a success-envelope `status: "unavailable"`; the only error path is `AUDIT_INTEGRITY_FAILURE`, already in the pinned list (test_server.py:2067-2093) and `_recovery_for`. So `_recovery_for` (mcp.py:1222-1284) and the pinned-code test require NO change. Add an explicit assertion to lock that: +```python +def test_warpline_tools_introduce_no_new_error_codes(tmp_path): + # warpline_preflight_get / attestation_get degrade to success-envelope + # status:"unavailable"; their only error path is the pre-existing + # AUDIT_INTEGRITY_FAILURE. No new error code => no _recovery_for / pinned-code change. + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) + assert not call_tool(runtime, "warpline_preflight_get", {"base": "x"}).get("isError") + assert not call_tool(runtime, "attestation_get", {"sei": "x#1"}).get("isError") +``` + +- [ ] **Confirm C-8 names pass** (test_c8 at test_server.py:2096-2107): neither `warpline_preflight_get` nor `attestation_get` contains `enable/provision/grant/hmac/sign_key/set_key`. No code change; this test already covers the new names once registered. + +- [ ] **Run to pass:** `uv run pytest tests/mcp/test_server.py -q`. + +- [ ] **Commit:** `test(mcp): bump agent surface to 24 tools for warpline + attestation`. + +--- + +## Task 7 — outputSchema conformance vectors for both new tools + +**Files** +- Modify test: `tests/mcp/test_output_schema_conformance.py` + +**Interfaces** +- Consumes: `_conformant(runtime, name, args)` (lines 97-105), `_tool(name)` (75-78), `_runtime` (81-94), `ERROR_ENVELOPE_SCHEMA`, `call_tool` (all imported in-file). + +Steps: + +- [ ] **Write conformance tests.** The catalog-wide tests (`test_every_tool_declares_a_valid_output_schema`, `test_every_output_schema_declares_top_level_object_type`, `test_one_of_helper_always_injects_top_level_object_type`) auto-cover both new tools because each schema routes through `_one_of` / `_schema`. Add per-tool driven vectors mirroring the `identity_gap_list` unavailable-conformance model (conformance:492-498) and the scan_route error-path model (conformance:418-443): +```python +def test_warpline_preflight_get_unavailable_conforms(tmp_path): + runtime, _store = _runtime(tmp_path) # warpline None + payload = _conformant(runtime, "warpline_preflight_get", {"base": "aaa"}) + assert payload["status"] == "unavailable" + + +def test_warpline_preflight_get_checked_conforms(tmp_path): + class _FakeWarpline: + def impact_radius(self, base, head): + return {"affected": [], "count": 0} + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + runtime, _store = _runtime(tmp_path) + runtime.warpline = _FakeWarpline() + payload = _conformant(runtime, "warpline_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"}) + assert payload["status"] == "unavailable" + + +def test_attestation_get_tamper_error_conforms_to_envelope(tmp_path): + from legis.mcp import ERROR_ENVELOPE_SCHEMA, call_tool + from jsonschema import Draft202012Validator + + runtime, _store = _runtime(tmp_path) + + class _TamperVerifier: + def verify(self, records): + from legis.enforcement.protected import TamperError + + raise TamperError("mismatch") + + class _FakeProtectedGate: + def records(self): + return ["bad"] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _TamperVerifier() + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert result.get("isError") + Draft202012Validator(ERROR_ENVELOPE_SCHEMA).validate(result["structuredContent"]) +``` + +- [ ] **Run to pass:** `uv run pytest tests/mcp/test_output_schema_conformance.py -q`. + +- [ ] **Commit:** `test(mcp): outputSchema conformance vectors for warpline + attestation tools`. + +--- + +## Task 8 — `read_sei_attestations` positive-admission classifier — **BLOCKED pending owner confirmation** + +> **STATUS: BLOCKED.** This task does NOT proceed until the owner ratifies the three open questions below. They are SPEC-LEVEL security decisions (they contradict spec lines 92 / 102 / 112), not implementation details deferred to line 116. Do NOT invent a discriminator marker; do NOT surface field values over an unverified trail. The scaffolding (Task 5) ships now and surfaces ZERO attestations (fail-closed) until ratified. The test suite below is written and committed as `@pytest.mark.skip(reason="BLOCKED: owner classifier ratification")`; the skip is removed when the owner answers. + +**The three open questions the owner MUST answer:** + +1. **Operator-override discriminator (forgeable as a bare field check).** The obvious reading `extensions.judge_verdict == "OVERRIDDEN_BY_OPERATOR"` + `extensions.protected_cell == True` is FORGEABLE: the chill `EnforcementEngine` (engine.py:70-71, `judge is None`) writes caller `extensions` VERBATIM with no server-side override, so a self-clear can carry those fields. A false "attested" → warpline skips reverify on un-cleared code → the exact ASYMMETRIC-RULE security hole. **Recommended resolution:** the classifier admits an operator override ONLY when `extensions.judge_metadata_signature` VERIFIES under the protected key over `signing_fields(payload, seq=rec.seq)` (protected.py:45-92, 186-197) AND the signed `judge_verdict == "OVERRIDDEN_BY_OPERATOR"` — never the bare field value. Because the handler pre-gate (Task 5) already requires `runtime.protected_gate` and reads through `verified_records` (which runs `TrailVerifier.verify` and raises on any forged/unsigned protected record), the additional in-classifier requirement is the PRESENCE of `judge_metadata_signature` in `extensions` (a chill record stuffing ONLY `judge_verdict` carries no such signature, passes the verifier because `_requires_verification` does not select it, and MUST therefore be omitted by the classifier's signature-presence check). + +2. **Fail-closed routing in the engine-only deployment.** `verified_records` falls through to UNVERIFIED `engine_records()` when `trail_owner` is `None` (governance.py:205), and only runs `TrailVerifier.verify` when `trail_verifier` is also wired (governance.py:199-205); a pre-materialized records list cannot carry the verified/unverified distinction. **Recommended resolution (already implemented in Task 5):** the handler gates on `runtime.protected_gate is None or runtime.trail_verifier is None → unavailable` BEFORE any records are read; `read_sei_attestations` only ever sees `verified_records` output. Owner must confirm engine-only deployments cannot attest at all (return `unavailable`). + +3. **Unsigned structured (procedural) sign-offs.** `signoff.py:104-105` writes a `SIGNED_OFF` record with NO `signoff_signature` when no signer/key is wired (module docstring 4-6: "structured sign-offs are procedural (unsigned)"). **Recommended resolution:** only a verifying-signed sign-off (`signoff_signature` present and verifying, selected + verified by `TrailVerifier`) is admissible; an unsigned structured `SIGNED_OFF` resolves to omission. In an engine-only deployment this is already covered by the handler pre-gate (no protected gate → `unavailable`). + +**Files (on ratification)** +- Modify: `src/legis/service/governance.py` (`read_sei_attestations` body) +- Modify test: `tests/service/test_governance.py`, `tests/mcp/test_server.py` + +**Interfaces (the ratified shape — recommended)** +- For each surfaced attestation: `{"kind": "signoff_cleared"|"operator_override", "content_hash": , "recorded_at": , "seq": , "signoff_seq"?: }`. NEVER `content_hash == ""`. SEI is `entity_key.value`; `identity_stable` must be true. Sign-off discriminator is `SignoffState.SIGNED_OFF.value` (`"SIGNED_OFF"`, UPPERCASE — never the spec's lowercase `'signed_off'`, which matches nothing). content_hash for a sign-off is JOINED from the PENDING request via `extensions.request_seq` at `extensions.loomweave.content_hash`; for an operator override it is INLINE at `extensions.loomweave.content_hash`. + +**MANDATORY test suite (all `required_test_cases` from the three validators; write now, `@pytest.mark.skip` until ratified):** + +- [ ] **Write the (skipped) classifier test suite** in `tests/service/test_governance.py` and `tests/mcp/test_server.py`. Each marked `@pytest.mark.skip(reason="BLOCKED: owner classifier ratification (Task 8)")`. Cases: + + - [ ] **FORGE-NEGATIVE (protected, stuffed full markers).** A chill self-clear record stuffed with `extensions={"judge_verdict": "OVERRIDDEN_BY_OPERATOR", "protected_cell": True}` but NO verifying `judge_metadata_signature` → `_requires_verification` selects it (`protected_cell is True`) → `TrailVerifier.verify` raises `TamperError` → `attestation_get` returns `isError`, `error_code == "AUDIT_INTEGRITY_FAILURE"` (nothing surfaced). + - [ ] **FORGE-NEGATIVE (subtle — proves the classifier's OWN signature check).** A chill self-clear stuffing ONLY `judge_verdict == "OVERRIDDEN_BY_OPERATOR"` (NO `protected_cell`/`file_fingerprint`/`ast_path`/signature) → NOT selected by `_requires_verification`, so it PASSES the trail verifier → `attestation_get` MUST STILL OMIT it because it lacks a verifying `judge_metadata_signature`. Asserts the classifier requires a verifying signature, not mere passage through `verified_records`. + - [ ] **ENGINE-ONLY DEPLOYMENT.** A real UNSIGNED structured `SIGNED_OFF` record exists for the SEI, no protected gate / no `LEGIS_HMAC_KEY` → `attestation_get` returns `status == "unavailable"`, ZERO attestations, never a bare empty `checked`. (Already passing via Task 5's pre-gate.) + - [ ] **POSITIVE (protected operator-override).** A record with a `judge_metadata_signature` that VERIFIES under the protected key over `signing_fields(payload, seq=rec.seq)` AND signed `judge_verdict == "OVERRIDDEN_BY_OPERATOR"` AND inline `extensions.loomweave.content_hash` present → surfaces `kind: "operator_override"` with that `content_hash` and `seq == rec.seq`. + - [ ] **POSITIVE (protected-cell cleared sign-off).** A `SIGNED_OFF` record with a verifying `signoff_signature`, `identity_stable` true, joined to a PENDING (via `extensions.request_seq`) with non-empty `extensions.loomweave.content_hash` → surfaces `kind: "signoff_cleared"` with the joined `content_hash` and `signoff_seq == request_seq`. + - [ ] **EXCLUSION (BLOCKED verdict).** A `BLOCKED` verdict record for the SEI → omitted. + - [ ] **EXCLUSION (ACCEPTED self-clear).** A coached/chill `ACCEPTED` self-clear (`judge_verdict == "ACCEPTED"`, no operator override) → omitted. + - [ ] **FILTER (`identity_stable`).** A cleared sign-off whose `entity_key.identity_stable` is False (locator-keyed, no rename-stable SEI) → omitted. + - [ ] **FILTER (SEI scoping).** Records for a DIFFERENT SEI are not surfaced under the queried SEI. + - [ ] **JOIN-EMPTY (asymmetric omit).** A cleared `SIGNED_OFF` whose joined PENDING has NO `extensions.loomweave.content_hash` → the record is OMITTED (never surfaced with `content_hash == ""`), but the surrounding read still returns `status: "checked"` (the verified trail was read) with that record simply absent — a per-record omit, NOT a whole-read `unavailable`, and NEVER a silent "never attested". + - [ ] **INLINE-EMPTY (asymmetric omit).** A verifying operator-override with absent inline `extensions.loomweave.content_hash` → OMITTED, never `content_hash == ""`. + - [ ] **TAMPER at the tool level.** A tampered governance trail → `call_tool(runtime, "attestation_get", {"sei"})` returns `isError`, `error_code == "AUDIT_INTEGRITY_FAILURE"`. (Already passing via Task 5.) + - [ ] **UNAVAILABLE shape.** No governance trail wired → success envelope `{"status": "unavailable", "sei", "attestations": [], "unavailable": [{"reason"}]}`; assert NOT `isError` and NOT a silent empty `checked`. (Already passing via Task 5.) + - [ ] **outputSchema conformance.** The discriminated checked/unavailable union routes through `_one_of` (top-level `"type": "object"`); the unavailable path conforms; an `isError` tamper path validates against `ERROR_ENVELOPE_SCHEMA`. (Already passing via Task 7.) + - [ ] **BYTE-IDENTITY (tool-local sanity).** `attestation_get` reads only the local verified trail and never `runtime.warpline`; a structural test asserts `runtime.warpline` is absent from `read_sei_attestations` and `_tool_attestation_get` source. (Already asserted in Task 4's structural test once `_tool_attestation_get` is added to that function set.) + +- [ ] **On ratification:** implement the agreed classifier in `read_sei_attestations` using the verifying-signature discriminator (the recommended resolution above), reusing `_binding_entity_from_backfill`'s iteration idiom (`for rec in records: payload = rec.payload`), `EntityKey.from_dict(payload["entity_key"])` for the SEI/`identity_stable` filter, the `signoff.py` `request_record`/`is_cleared` join for sign-offs, and `verify(signing_fields(payload, seq=rec.seq), sig, key)` for the operator-override signature check. Remove the `@pytest.mark.skip` marks, run the full suite to pass, commit `feat(governance): ratified per-SEI attestation classifier`. + +--- + +## Final verification (run before declaring done) + +- [ ] `uv run pytest tests/warpline_preflight tests/service/test_preflight.py tests/service/test_governance.py tests/mcp/test_server.py tests/mcp/test_output_schema_conformance.py tests/mcp/test_warpline_advisory_boundary.py -q` — all green (Task 8 classifier cases skipped pending ratification). +- [ ] `uv run pytest -q` — full suite green (no regression in the 22 pre-existing tools, now 24). +- [ ] Grep guard: `grep -rn "runtime.warpline\|\.warpline" src/legis/mcp.py` shows references ONLY in `_tool_warpline_preflight_get`, the `McpRuntime` dataclass field, and the `build_runtime` gating block — NOWHERE in `_tool_policy_evaluate`, `_engine`, `_coached_engine`, the gates, sign-off, or the honesty reads. +- [ ] Confirm `_AGENT_TOOLS` has exactly 24 members; `test_tool_registries_are_in_sync` green. diff --git a/docs/superpowers/specs/2026-06-24-legis-warpline-interfaces-design.md b/docs/superpowers/specs/2026-06-24-legis-warpline-interfaces-design.md new file mode 100644 index 0000000..60e9a3f --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-legis-warpline-interfaces-design.md @@ -0,0 +1,169 @@ +# Legis ↔ warpline interfaces — preflight consumer + per-SEI attestation read — design + +**Date:** 2026-06-24 +**Status:** Design approved (brainstorm), pre-implementation +**Scope:** The Legis side of warpline's requested interfaces (request item 5): **(a)** read warpline affected-set summaries as *advisory* preflight facts, and **(b)** expose a per-SEI attestation/governance-posture read so warpline can implement governance-as-verification (Rung 2). The rename feed warpline consumes (`git_rename_list` / `git_rename_feed_get`) **already ships** and is not rebuilt here. + +--- + +## 1. Problem & motivation + +Warpline (a Weft suite member; impact-radius / reverify-worklist analysis over `base..head`) has requested two interfaces from Legis: + +1. **Advisory preflight context.** Before an agent acts, it is useful to see warpline's *affected set* (impact radius) and *reverify worklist* **next to** Legis's own governance honesty reads (`identity_gap_list`, `lineage_integrity_get`). Warpline is a heuristic impact analyzer; it must **never** decide a governance verdict — its output is purely advisory. + +2. **Governance-as-verification (Rung 2).** Warpline wants to treat an entity that Legis has **attested at a given commit** as "proven-good" and skip reverifying it. Legis exposes no per-SEI attestation read today (it only serves `serve` / `mcp` / the governance-gate). This is **optional/future** on warpline's side — without it, warpline reports `governance=unavailable` (with a reason triple) and proceeds. Exposing it lets warpline shrink its reverify worklist using Legis attestations. + +### The boundary that governs every decision below + +> **Legis remains the only governance / sign-off / attestation authority. Warpline context is purely advisory.** +> +> **Acceptance:** governance decisions are **byte-identical** whether warpline is present or absent. + +This is the same honesty discipline already pervasive in the codebase (the discriminated `checked` / `unavailable` / `diverged` reads, fail-closed verification). The novel risk is a *new* one: Legis is becoming an HTTP **client** of a sibling for the first time on a path that sits *next to* governance reads. The invariant is that warpline data is **structurally incapable** of reaching a verdict path — it lives in its own tool and is never an input to `policy_evaluate`, the gates, sign-off, or any honesty read. + +## 2. Goals / non-goals + +### Goals +- A new env-gated HTTP client (`HttpWarplineClient`, `WARPLINE_API_URL`) modeled exactly on `HttpFiligreeClient` — stdlib `urllib`, injectable `fetch`, loopback/HTTPS URL gating, response-size bound, no-redirect. +- A new MCP tool **`warpline_preflight_get`** (a *sibling*, not embedded in the governance reads) returning an honest discriminated read: `checked` (advisory facts) vs `unavailable` (with reasons). +- A new MCP tool **`attestation_get`** returning, for an SEI, the **verified human-cleared attestation facts** (no `proven_good` verdict), through the same fail-closed trail-verification path the other honesty reads use. +- An acceptance test proving governance verdicts are unchanged when `WARPLINE_API_URL` is unset vs set. +- Full surface bookkeeping (outputSchema, `_AGENT_TOOLS`, surface conformance, tool-count, output-schema conformance vectors). + +### Non-goals +- Rebuilding the rename feed — `git_rename_list` / `git_rename_feed_get` ship today. Two-way rename-parser conformance vectors remain tracked by the existing open ticket **`legis-c4cbf78fdb`** (G16) and are out of scope here. +- Legis becoming an MCP *client* of warpline (rejected in brainstorm; HTTP mirrors the established sibling pattern). +- Any "skip reverification" / proven-good decision inside Legis — that is warpline's Rung-2 call. Legis returns facts. +- Warpline writing to Legis, or Legis feeding warpline data into any governance computation. +- Pinning warpline's exact wire format. The client is built to a **documented inferred contract** (see §6) that is shape-validated; it is corrected when warpline's real format lands. + +## 3. Part (a) — warpline preflight consumer + +### 3.1 Client: `HttpWarplineClient` + +New module `src/legis/warpline_preflight/client.py`, a near-clone of `src/legis/filigree/client.py`: + +- `class WarplineError(RuntimeError)` — a warpline call failed at the transport or decode layer. +- `_validate_base_url` — http(s) + host required; `http` to a non-loopback host rejected unless `LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1` (with the same logged warning Filigree emits — warpline responses are advisory but a tampered "nothing impacted" should still be opt-in only). +- `MAX_RESPONSE_BYTES = 1_000_000`, no-redirect opener, JSON content-type check. +- Injectable `Fetch` callable so tests run fully offline (no network). +- `@runtime_checkable` `WarplineClient` Protocol with the two methods. +- Two methods, both read-only `GET`: + - `impact_radius(base: str, head: str) -> dict` + - `reverify_worklist(base: str, head: str) -> dict` + +Wired in `build_runtime` exactly like Filigree: +```python +warpline = None +warpline_url = os.environ.get("WARPLINE_API_URL") +if warpline_url: + from legis.warpline_preflight.client import HttpWarplineClient + warpline = HttpWarplineClient(warpline_url) +``` +and held on `McpRuntime` as `warpline: Any | None = None`. + +### 3.2 Tool: `warpline_preflight_get` + +- **Input:** `{base: string (required), head?: string}` — `head` defaults to `"HEAD"` (mirrors `git_rename_feed_get`). +- **Handler `_tool_warpline_preflight_get`:** delegates to a transport-agnostic service function `read_warpline_preflight(warpline_client, base, head)` in a new `src/legis/service/preflight.py`, mirroring how `read_identity_gaps` / `read_lineage_integrity` live in `service/governance.py`. +- **Honest discriminated output** (the house pattern — an empty affected-set must NOT read as "nothing impacted"): + + `status: "checked"`: + ```json + {"status": "checked", "impact_radius": {...}, "reverify_worklist": {...}} + ``` + `status: "unavailable"` (client not configured, transport failure, **or payload shape mismatch**): + ```json + {"status": "unavailable", "unavailable": [{"reason": "warpline client not configured"}]} + ``` +- The service function calls both warpline methods; **either** failing degrades the whole read to `unavailable` with a reason (partial advisory context is not surfaced as `checked`). A `WarplineError` is caught and converted to an `unavailable` reason — it never escapes as `INTERNAL_ERROR`, exactly as `read_identity_gaps` converts `LoomweaveError`. +- Added to `_AGENT_TOOLS`. + +### 3.3 Why a sibling tool, not embedded + +`identity_gap_list` / `lineage_integrity_get` carry outputSchema conformance tests and *are* the governance authority surface this work must protect. Embedding an advisory external read into a governance honesty read muddies "Legis is the authority" and risks an advisory failure perturbing a governance read. A dedicated sibling keeps advisory and authority **structurally** separate; an agent composes a preflight view by calling all three. + +## 4. Part (b) — per-SEI attestation read + +### 4.1 Tool: `attestation_get` + +- **Input:** `{sei: string (required)}`. Legis does **not** accept a commit. Warpline resolves the SEI's content_hash at commit X via Loomweave (which it already queries for `timeline` / `changed`) and matches it against the `content_hash` Legis returns. The match — and the "skip reverify" decision — is warpline's Rung-2 call, not Legis's. +- **Service function `read_sei_attestations(runtime_records, sei)`** in `service/governance.py` (next to the other honesty reads). It is reached **only when the trail is signature-verifiable** — the handler pre-gate requires BOTH a protected gate AND a `trail_verifier` (governance.py `verified_records` runs `TrailVerifier.verify` only when both are wired; with neither, it would return engine records UNVERIFIED). When verifiable, a tampered/forged record raises `AuditIntegrityError → AUDIT_INTEGRITY_FAILURE` upstream of the classifier, OR — for a forge the chain signature cannot catch, e.g. a mutated **unsigned** PENDING-request `content_hash` (FORGE-B) — the classifier itself omits it. The classifier admits a record only when every field it keys on is **covered by a signature** (`signing_fields` / `signoff_signing_fields`); a forged "attested" is therefore never returned when the trail is verifiable. +- **Honest discriminated output:** + + `status: "checked"`: + ```json + {"status": "checked", "sei": "", "attestations": [ + {"kind": "signoff_cleared", "content_hash": "...", "recorded_at": "...", "seq": 12, "signoff_seq": 7}, + {"kind": "operator_override", "content_hash": "...", "recorded_at": "...", "seq": 19} + ]} + ``` + `status: "unavailable"` (trail not signature-verifiable — no protected gate / no `trail_verifier`; a no-key deployment still HAS a trail — chill overrides, unsigned procedural sign-offs — it is UNVERIFIABLE, not absent, and reading unverified records would be a false-green): + ```json + {"status": "unavailable", "sei": "", "attestations": [], "unavailable": [{"reason": "..."}]} + ``` + +### 4.2 What counts as an attestation (decided: human-cleared only) + +Only governance records that represent a **human clearance** for the SEI count — the strongest "governed-good" signal warpline can safely skip reverification on: + +1. **Cleared sign-offs** — a `SIGNED_OFF` record (`extensions.signoff_state == SignoffState.SIGNED_OFF.value == "SIGNED_OFF"`, UPPERCASE — `verdict.py`) carrying a `signoff_signature` (the verified-selection marker), whose `entity_key` is the queried SEI and `identity_stable` is true. Its `content_hash` is joined from the matching `PENDING` request via the **signed** `extensions.request_seq` (the cleared record carries no loomweave content_hash of its own; the request does, at `extensions.loomweave.content_hash`). The join is **integrity-checked, not trusted** (FORGE-B): the classifier recomputes `legis.canonical.content_hash` over the FULL stored PENDING payload and requires it == the SIGNED_OFF record's signed `extensions.request_payload_hash`. `kind: "signoff_cleared"`, `seq` = the SIGNED_OFF record seq, `signoff_seq` = the request seq. Absent/empty content_hash → omit. +2. **Protected operator-overrides** — a record carrying a `judge_metadata_signature` (the verified-selection marker) with the **signed** `extensions.judge_verdict == Verdict.OVERRIDDEN_BY_OPERATOR.value` (`signing_fields["verdict"]` — FORGE-A is closed: mutating it breaks the signature and fails closed upstream), `extensions.protected_cell is True` (signed), entity == SEI with `identity_stable` true (read from the SIGNED `entity_key` dict, never the unsigned top-level `identity_stable` duplicate), and a non-empty inline `extensions.loomweave.content_hash` (signed). `kind: "operator_override"`. + +**Discriminator discipline (necessary-but-not-sufficient trap):** admission keys off the **signature MARKER** (`judge_metadata_signature` / `signoff_signature`) present on the candidate, NOT the bare `judge_verdict` / `signoff_state` value — a marker-less injected record can ride through `TrailVerifier._requires_verification` UNVERIFIED, so marker presence is what proves THIS record was in the verified selection. The classifier never re-verifies signatures (the pre-gate did); it only keys off fields inside `signing_fields` / `signoff_signing_fields` and independently integrity-checks the sign-off join. Never key off `judge_advisory_verdict` or the simple-tier engine `judge_verdict` (both unsigned). + +**Explicitly excluded (omitted):** chill/coached self-clear overrides, unsigned/procedural sign-offs (no `signoff_signature`), `BLOCKED` verdicts, empty content_hash, cross-SEI, `identity_stable` false — they are not proof of anything warpline should skip reverification on. WHEN IN DOUBT, OMIT. (The decision is conservative on purpose; broadening the set later is additive.) + +**Shipped sound:** BOTH kinds (`operator_override`, `signoff_cleared`) shipped — each distinguishing/content field it keys on is signed (`signing_fields` protected.py / `signoff_signing_fields` signoff.py), so membership in the verified set proves the field authentic and the request-join is integrity-bound via the signed `request_payload_hash`. Neither kind is escalated/blocked. Covered by forge-negative unit tests in `tests/service/test_governance.py` and an end-to-end MCP test in `tests/mcp/test_server.py`. + +## 5. Rename feed (part b, item 1) — confirm, don't rebuild + +`git_rename_list` (committed renames over a rev range) and `git_rename_feed_get` (base/head + optional working-tree renames, Loomweave-ready) already ship and are unchanged. They are what warpline consumes to keep its `timeline` / `changed` stable across file moves. The only follow-up — two-way rename-parser conformance vectors — is the existing open ticket `legis-c4cbf78fdb` (G16). No code change here. + +## 6. Inferred warpline contract (TO-CONFIRM) + +Warpline's real wire format was not supplied. The client is built to this **inferred, shape-validated** contract; a mismatch degrades to `unavailable` (never fabricates an affected-set). When warpline ships its real shape, only the parser + one fixture change. + +- `GET {base}/api/impact-radius?base=&head=` → object. Inferred shape: `{"affected": [{"sei": "...", "path": "...", ...}], "count": , ...}`. +- `GET {base}/api/reverify-worklist?base=&head=` → object. Inferred shape: `{"entries": [{"sei": "...", "reason": "...", ...}], "count": , ...}`. + +Shape validation is **minimal and tolerant**: the client requires each response to be a JSON object (else `WarplineError`); the *fields within* are passed through verbatim to the advisory payload. This keeps Legis from coupling to warpline's evolving internal vocabulary while still refusing to present a non-object/garbage response as `checked`. **⚠️ Confirm paths + payloads with warpline before treating happy-path parsing as final.** + +## 7. Honesty & error handling (the recurring bug class here) + +- **No silent empties.** Both new reads discriminate `checked` from `unavailable`. An unreachable/unconfigured warpline → `unavailable` with a reason, never an empty affected-set that reads as "nothing impacted". An unwired governance trail → `unavailable`, never an empty attestations list that reads as "never attested". +- **Advisory failure is contained.** A `WarplineError` is caught in the service layer and mapped to `unavailable`; it never becomes `INTERNAL_ERROR` and never perturbs a governance read (different tool, different call). +- **Attestation reads are fail-closed.** A trail that is **not signature-verifiable** (no protected gate / no `trail_verifier` — e.g. no `LEGIS_HMAC_KEY`) → `unavailable`, reading no unverified record (NOT an empty `checked` that reads as "never attested"). When the trail IS verifiable: tamper → `AUDIT_INTEGRITY_FAILURE` via the shared `verified_records` path, and any forge the chain signature cannot catch (a mutated unsigned PENDING `content_hash`, FORGE-B) is omitted by the classifier's integrity-join. The classifier admits only signature-covered fields, so no forged attestation is returned **when the trail is verifiable**. +- **Insecure transport is opt-in.** `http` to non-loopback warpline is rejected unless `LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1`, with the same warning Filigree logs. + +## 8. Testing + +- **`tests/service/test_preflight.py`** — `read_warpline_preflight`: checked (both methods succeed via injected fetch); unavailable when client is `None`; unavailable on `WarplineError`; unavailable on non-object payload. +- **`tests/service/test_governance.py` (extend)** — `read_sei_attestations`: cleared sign-off surfaces with joined content_hash + signoff_seq; operator override surfaces; chill/coached self-clear and `BLOCKED` are excluded; SEI filter and `identity_stable` filter; unavailable when no trail wired; `AUDIT_INTEGRITY_FAILURE` on a tampered trail. +- **`tests/warpline_preflight/test_client.py`** — URL validation (loopback ok, remote http rejected unless opt-in), response-size bound, no-redirect, non-JSON content-type → `WarplineError`, offline via injected fetch. +- **`tests/mcp/test_server.py` (extend)** — both tools dispatch end-to-end; `warpline_preflight_get` returns `unavailable` when `WARPLINE_API_URL` unset; `attestation_get` happy path + unavailable. +- **`tests/mcp/test_output_schema_conformance.py` (extend)** — outputSchema vectors for both new tools. +- **Surface/conformance** — `tests/checks/test_check_surface.py` (or the surface conformance test) tool-count + `_AGENT_TOOLS` membership. +- **Acceptance / invariant — `tests/mcp/test_warpline_advisory_boundary.py` (new):** run a representative governance path (e.g. `policy_evaluate` / an override submit / a sign-off read) with `WARPLINE_API_URL` unset and again with it set to an injected warpline that returns arbitrary impact data; assert the governance result is **byte-identical**. This is the spine of the whole change. + +## 9. File manifest + +**New** +- `src/legis/warpline_preflight/__init__.py` +- `src/legis/warpline_preflight/client.py` — `HttpWarplineClient`, `WarplineClient` Protocol, `WarplineError`. +- `src/legis/service/preflight.py` — `read_warpline_preflight`. +- `tests/warpline_preflight/test_client.py` +- `tests/service/test_preflight.py` +- `tests/mcp/test_warpline_advisory_boundary.py` + +**Modified** +- `src/legis/mcp.py` — `McpRuntime.warpline`; `build_runtime` wiring; `warpline_preflight_get` + `attestation_get` tool definitions, handlers, `_TOOL_HANDLERS`, `_AGENT_TOOLS`; recovery hints for any new error codes. +- `src/legis/service/governance.py` — `read_sei_attestations`. +- `tests/service/test_governance.py`, `tests/mcp/test_server.py`, `tests/mcp/test_output_schema_conformance.py`, surface conformance test — extended. + +## 10. Open items / future state + +- **Warpline wire format** (§6) — confirm real paths + payloads; swap the inferred fixture. +- **Attestation set breadth** — human-cleared only for now; broadening to other verified records is additive if warpline asks. +- **Rename conformance vectors** — tracked separately as `legis-c4cbf78fdb` (G16). diff --git a/pyproject.toml b/pyproject.toml index 4fdf684..de807cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "legis" -version = "1.1.1" +version = "1.2.0" description = "Legis — the git/CI + governance layer of the Weft suite" readme = "README.md" license = "MIT" diff --git a/src/legis/__init__.py b/src/legis/__init__.py index 759daa4..ef0fdfa 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.1.1" +__version__ = "1.2.0" diff --git a/src/legis/mcp.py b/src/legis/mcp.py index 292007b..b70a271 100644 --- a/src/legis/mcp.py +++ b/src/legis/mcp.py @@ -102,6 +102,8 @@ "doctor_get", "policy_boundary_check", "posture_get", + "warpline_preflight_get", + "attestation_get", } ) _OVERRIDE_RATE_NOTE = "measures operator force-pasts; not movable by agent retries" @@ -176,6 +178,7 @@ class McpRuntime: # which _floored_registry treats fail-closed as a missing ledger (structured). posture_ledger: Any | None = None coached_engine: EnforcementEngine | None = None + warpline: Any | None = None # advisory sibling; NEVER read by a verdict path def _load_policy_cell_registry() -> PolicyCellRegistry: @@ -225,6 +228,20 @@ 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 + + try: + warpline = HttpWarplineClient(warpline_url) + except WarplineError: + logging.getLogger(__name__).warning( + "WARPLINE_API_URL is set but invalid; warpline advisory context " + "disabled (governance unaffected)." + ) + warpline = None + protected_gate = None trail_verifier = None signoff_gate = None @@ -288,6 +305,7 @@ def build_runtime(agent_id: str) -> McpRuntime: # install-time action (Phase 6) and build_runtime must not create local # state (audit H6 / the no-local-state-on-init invariant). posture_ledger=PostureLedger(posture_db_url(), initialize=False), + warpline=warpline, ) @@ -861,6 +879,40 @@ def tool_definitions() -> list[dict[str, Any]]: }, ), }, + { + "name": "warpline_preflight_get", + "description": ( + "ADVISORY preflight context from the warpline sibling: impact " + "radius + reverify worklist over base..head. Purely advisory — " + "NEVER a governance verdict. Discriminated: 'checked' carries the " + "advisory facts; 'unavailable' (client unconfigured, transport " + "failure, 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", "impact_radius", "reverify_worklist"], + { + "status": {"type": "string", "enum": ["checked"]}, + "impact_radius": {"type": "object"}, + "reverify_worklist": {"type": "object"}, + }, + ), + _schema( + ["status", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, { "name": "identity_gap_list", "description": ( @@ -1209,6 +1261,58 @@ def tool_definitions() -> list[dict[str, Any]]: }, ), }, + { + "name": "attestation_get", + "description": ( + "Per-SEI human-cleared governance attestation FACTS (no proven_good " + "verdict). Through the same fail-closed verified-trail path the " + "honesty reads use: a tampered trail -> AUDIT_INTEGRITY_FAILURE; an " + "engine-only deployment (no protected gate) -> 'unavailable'. Never " + "read an empty attestations list under 'unavailable' as 'never " + "attested'; a forged attestation is never returned." + ), + "inputSchema": _schema(["sei"], {"sei": string}), + "outputSchema": _one_of( + [ + _schema( + ["status", "sei", "attestations"], + { + "status": {"type": "string", "enum": ["checked"]}, + "sei": string, + "attestations": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "content_hash", "recorded_at", "seq"], + "properties": { + "kind": { + "type": "string", + "enum": ["signoff_cleared", "operator_override"], + }, + "content_hash": string, + "recorded_at": string, + "seq": integer, + "signoff_seq": integer, + }, + }, + }, + }, + ), + _schema( + ["status", "sei", "attestations", "unavailable"], + { + "status": {"type": "string", "enum": ["unavailable"]}, + "sei": string, + "attestations": {"type": "array", "maxItems": 0}, + "unavailable": { + "type": "array", + "items": _schema(["reason"], {"reason": string}), + }, + }, + ), + ] + ), + }, ] @@ -2170,6 +2274,18 @@ def _tool_filigree_closure_gate_get(runtime: McpRuntime, args: dict[str, Any]) - ) +def _tool_warpline_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.preflight import read_warpline_preflight + + return _tool_result( + read_warpline_preflight( + runtime.warpline, + 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. @@ -2186,6 +2302,34 @@ def _governance_trail_records(runtime: McpRuntime) -> list[Any]: ) +def _tool_attestation_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: + from legis.service.governance import read_sei_attestations + + sei = _require(args, "sei") + # FAIL-CLOSED: attestation is only possible when the trail is signature- + # verifiable. `verified_records` ONLY runs TrailVerifier.verify when BOTH a + # protected trail_owner AND a trail_verifier are wired (governance.py:199-205); + # with a protected_gate but no verifier it returns engine records UNVERIFIED. + # Gate on BOTH so the invariant holds by construction, not by the convention + # that build_runtime co-locates them under one `if hmac_key:` block. Return the + # discriminated unavailable (NEVER a silent empty 'checked' that reads as + # "never attested", NEVER unverified field values). + if runtime.protected_gate is None or runtime.trail_verifier is None: + return _tool_result( + { + "status": "unavailable", + "sei": sei, + "attestations": [], + "unavailable": [ + {"reason": "trail not signature-verifiable (no protected gate / verifier)"} + ], + } + ) + # _governance_trail_records runs verified_records, which raises + # AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on a tampered protected trail. + return _tool_result(read_sei_attestations(_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)) @@ -2441,6 +2585,8 @@ def _tool_posture_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, An "doctor_get": _tool_doctor_get, "policy_boundary_check": _tool_policy_boundary_check, "posture_get": _tool_posture_get, + "warpline_preflight_get": _tool_warpline_preflight_get, + "attestation_get": _tool_attestation_get, } diff --git a/src/legis/service/__init__.py b/src/legis/service/__init__.py index ebcc909..883a87d 100644 --- a/src/legis/service/__init__.py +++ b/src/legis/service/__init__.py @@ -23,6 +23,7 @@ evaluate_policy, read_identity_gaps, read_lineage_integrity, + read_sei_attestations, request_signoff, resolve_for_record, submit_override, @@ -30,6 +31,7 @@ submit_protected_override, verified_records, ) +from legis.service.preflight import read_warpline_preflight from legis.service.wardline import route_wardline_scan __all__ = [ @@ -47,6 +49,7 @@ "compute_override_rate", "read_identity_gaps", "read_lineage_integrity", + "read_sei_attestations", "evaluate_policy", "explain_policy", "request_signoff", @@ -54,6 +57,7 @@ "submit_override", "submit_operator_override", "submit_protected_override", + "read_warpline_preflight", "route_wardline_scan", "verified_records", ] diff --git a/src/legis/service/governance.py b/src/legis/service/governance.py index d94af5c..380d471 100644 --- a/src/legis/service/governance.py +++ b/src/legis/service/governance.py @@ -20,6 +20,7 @@ TrailVerifier, ) from legis.enforcement.signoff import SignoffGate, SignoffResult +from legis.enforcement.verdict import SignoffState, Verdict from legis.governance import params from legis.identity.entity_key import EntityKey from legis.identity.resolver import IdentityResolver @@ -219,6 +220,136 @@ def compute_override_rate(records: list): ) +def read_sei_attestations(verified_runtime_records: list, sei: str) -> dict[str, Any]: + """Per-SEI human-cleared attestation facts from the VERIFIED governance trail. + + ASYMMETRIC ERROR RULE: a FALSE "attested" lets warpline skip reverify on + un-cleared code (security hole); an OMITTED attestation only wastes work + (safe). Every ambiguous/failure case therefore resolves toward "not attested" + — omit the record, never surface it. ``verified_runtime_records`` MUST already + have come through ``verified_records`` — the handler guarantees this via the + protected-gate + trail-verifier pre-gate, and a tampered protected trail has + already raised AuditIntegrityError before this function is called. The + parameter is named for that contract: a future caller passing raw + ``_engine(runtime).records`` is then a self-documenting mistake. This function + takes a MATERIALIZED list (not a callable) — a bare list cannot carry the + verified/unverified distinction, so the gate decision lives in the handler. + + THE FORGE-PROOF DISCRIMINATOR (Task 8, owner-ratified). Two kinds are admitted, + and ONLY when every distinguishing/content field is COVERED BY A SIGNATURE — so + membership in the verified set actually proves the field is authentic: + + * ``operator_override`` — a protected operator-override verdict. Admit only when + ``judge_metadata_signature`` is present on the candidate (the marker that + proves THIS record was in the verified selection — a marker-less injected + record rides through ``_requires_verification`` UNVERIFIED, so keying on the + bare ``judge_verdict`` is forgeable), ``judge_verdict == OVERRIDDEN_BY_OPERATOR`` + (signed at signing_fields["verdict"], protected.py — FORGE-A is closed: + mutating it breaks the signature and fails closed upstream), ``protected_cell + is True`` (signed), the inline ``loomweave.content_hash`` is non-empty + (signed), and entity_key.value == sei AND entity_key.identity_stable + (the SIGNED entity dict — never the unsigned top-level identity_stable dup). + * ``signoff_cleared`` — a SIGNED_OFF record carrying a ``signoff_signature`` + (the verified-selection marker), whose joined PENDING request (by the signed + ``request_seq``) is INTEGRITY-BOUND: recompute ``content_hash`` over the FULL + stored PENDING payload and require it == the signed ``request_payload_hash`` + (FORGE-B: a pointer is not integrity; mutating the PENDING's content_hash + breaks this match). The surfaced content_hash is the PENDING's + ``loomweave.content_hash`` (the SIGNED_OFF carries none of its own), required + non-empty, and the entity is read from the SIGNED entity dict == sei AND + identity_stable. + + OMITTED: chill/coached self-clears, unsigned/procedural sign-offs, BLOCKED + verdicts, empty content_hash, cross-SEI, identity_stable False. WHEN IN DOUBT, + OMIT. The classifier never re-verifies signatures (the pre-gate already did); + it ONLY keys off fields inside signing_fields/signoff_signing_fields and + independently integrity-checks the sign-off join (which crosses a signature + boundary the SIGNED_OFF's own signature covers only via request_payload_hash). + + Returns status='checked' with the admitted attestations (possibly empty — an + empty result here is HONEST: the trail WAS verified, the SEI simply has no + human clearance). The handler owns the status='unavailable' pre-gate for the + no-key / engine-only case (an unverifiable trail must not be read here). + """ + from legis.canonical import content_hash as _content_hash + + records = list(verified_runtime_records) + attestations: list[dict[str, Any]] = [] + + for rec in records: + payload = rec.payload + ext = payload.get("extensions", {}) or {} + + # Entity must be the SIGNED entity_key dict (value + identity_stable both + # inside signing_fields["entity"]); the top-level payload["identity_stable"] + # is an unsigned duplicate — never read it. + entity = payload.get("entity_key") + if not isinstance(entity, dict): + continue + if entity.get("value") != sei or entity.get("identity_stable") is not True: + continue + + # --- operator_override ------------------------------------------------- + # PRECONDITION 1: the signature marker proves this record verified. + if "judge_metadata_signature" in ext and "signoff_state" not in ext: + if ext.get("judge_verdict") != Verdict.OVERRIDDEN_BY_OPERATOR.value: + continue # BLOCKED / ACCEPTED protected verdicts are not clearances + if ext.get("protected_cell") is not True: + continue + content = (ext.get("loomweave", {}) or {}).get("content_hash") or "" + if not content: + continue + attestations.append( + { + "kind": "operator_override", + "content_hash": content, + "recorded_at": payload.get("recorded_at"), + "seq": rec.seq, + } + ) + continue + + # --- signoff_cleared --------------------------------------------------- + # PRECONDITION 1: signoff_signature marker proves this record verified. + if "signoff_signature" in ext and ext.get("signoff_state") == SignoffState.SIGNED_OFF.value: + request_seq = ext.get("request_seq") + signed_request_hash = ext.get("request_payload_hash") + if request_seq is None or not signed_request_hash: + continue + # FORGE-B: join the PENDING by its seq COLUMN, then recompute the hash + # over the FULL stored PENDING payload and require it == the signed + # request_payload_hash. A pointer alone is not integrity. + pending_payload = None + for cand in records: + cand_ext = cand.payload.get("extensions", {}) or {} + if ( + cand.seq == request_seq + and cand_ext.get("signoff_state") == SignoffState.PENDING.value + ): + pending_payload = cand.payload + break + if pending_payload is None: + continue + if _content_hash(pending_payload) != signed_request_hash: + continue # PENDING content_hash mutated -> hash no longer matches + content = ( + (pending_payload.get("extensions", {}) or {}).get("loomweave", {}) or {} + ).get("content_hash") or "" + if not content: + continue + attestations.append( + { + "kind": "signoff_cleared", + "content_hash": content, + "recorded_at": payload.get("recorded_at"), + "seq": rec.seq, + "signoff_seq": request_seq, + } + ) + + return {"status": "checked", "sei": sei, "attestations": attestations} + + 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 new file mode 100644 index 0000000..f92f511 --- /dev/null +++ b/src/legis/service/preflight.py @@ -0,0 +1,38 @@ +"""The warpline advisory preflight read — 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 +``read_identity_gaps`` converts a ``LoomweaveError``. An unreachable/unconfigured +warpline → ``unavailable`` with a reason, never an empty affected-set that reads +as "nothing impacted". +""" + +from __future__ import annotations + +from typing import Any + + +def read_warpline_preflight( + warpline_client: Any | None, base: str, head: str +) -> dict[str, Any]: + from legis.warpline_preflight.client import WarplineError + + if warpline_client is None: + return { + "status": "unavailable", + "unavailable": [{"reason": "warpline client not configured"}], + } + try: + impact = warpline_client.impact_radius(base, head) + worklist = warpline_client.reverify_worklist(base, head) + except WarplineError as exc: + return { + "status": "unavailable", + "unavailable": [{"reason": f"warpline check failed: {exc}"}], + } + return { + "status": "checked", + "impact_radius": impact, + "reverify_worklist": worklist, + } diff --git a/src/legis/warpline_preflight/__init__.py b/src/legis/warpline_preflight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/legis/warpline_preflight/client.py b/src/legis/warpline_preflight/client.py new file mode 100644 index 0000000..04a0f2f --- /dev/null +++ b/src/legis/warpline_preflight/client.py @@ -0,0 +1,143 @@ +"""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 +(policy_evaluate, the gates, sign-off, or the honesty reads). Governance +verdicts are byte-identical whether WARPLINE_API_URL is set or unset. +""" + +from __future__ import annotations + +import json +import http.client +import ipaddress +import logging +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Callable, Protocol, runtime_checkable + +Fetch = Callable[[str, str, "dict | None"], dict] + +logger = logging.getLogger(__name__) + + +class WarplineError(RuntimeError): + """A Warpline call failed at the transport or decode layer.""" + + +MAX_RESPONSE_BYTES = 1_000_000 + + +@runtime_checkable +class WarplineClient(Protocol): + 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("/") + + +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 + + def _transport_fetch(self, method: str, url: str, body: dict | None) -> dict: + return _urllib_fetch(method, url, body, {}) + + 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", + ) + + 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", + ) diff --git a/tests/mcp/test_output_schema_conformance.py b/tests/mcp/test_output_schema_conformance.py index 4ce4180..457d7a5 100644 --- a/tests/mcp/test_output_schema_conformance.py +++ b/tests/mcp/test_output_schema_conformance.py @@ -622,3 +622,52 @@ def test_policy_boundary_check_no_root_instead_of_vacuous_pass(tmp_path): scanned = _conformant(runtime, "policy_boundary_check", {"root": "specimen"}) assert scanned["outcome"] == "PASS" assert scanned["scanned_root"].endswith("specimen") + + +def test_warpline_preflight_get_unavailable_conforms(tmp_path): + runtime, _store = _runtime(tmp_path) # warpline None + payload = _conformant(runtime, "warpline_preflight_get", {"base": "aaa"}) + assert payload["status"] == "unavailable" + + +def test_warpline_preflight_get_checked_conforms(tmp_path): + class _FakeWarpline: + def impact_radius(self, base, head): + return {"affected": [], "count": 0} + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + runtime, _store = _runtime(tmp_path) + runtime.warpline = _FakeWarpline() + payload = _conformant(runtime, "warpline_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"}) + assert payload["status"] == "unavailable" + + +def test_attestation_get_tamper_error_conforms_to_envelope(tmp_path): + from legis.mcp import ERROR_ENVELOPE_SCHEMA, call_tool + from jsonschema import Draft202012Validator + + runtime, _store = _runtime(tmp_path) + + class _TamperVerifier: + def verify(self, records): + from legis.enforcement.protected import TamperError + + raise TamperError("mismatch") + + class _FakeProtectedGate: + def records(self): + return ["bad"] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _TamperVerifier() + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert result.get("isError") + Draft202012Validator(ERROR_ENVELOPE_SCHEMA).validate(result["structuredContent"]) diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 14e130a..581430a 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -302,6 +302,8 @@ def test_initialize_and_tools_list_exposes_full_agent_surface(tmp_path): "doctor_get", "policy_boundary_check", "posture_get", + "warpline_preflight_get", + "attestation_get", } # posture_get is the dedicated read-only posture surface (Phase 8); the # change gate (posture set) stays operator/CLI only — no posture_set tool. @@ -2121,6 +2123,17 @@ def test_c8_no_agent_reachable_enablement_or_signing_surface(): assert forbidden_arg not in props +def test_warpline_tools_introduce_no_new_error_codes(tmp_path): + # warpline_preflight_get / attestation_get degrade to success-envelope + # status:"unavailable"; their only error path is the pre-existing + # AUDIT_INTEGRITY_FAILURE. No new error code => no _recovery_for / pinned-code change. + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) + assert not call_tool(runtime, "warpline_preflight_get", {"base": "x"}).get("isError") + assert not call_tool(runtime, "attestation_get", {"sei": "x#1"}).get("isError") + + def test_git_rename_feed_get_is_listed(): from legis.mcp import tool_definitions @@ -3363,3 +3376,185 @@ def test_check_list_target_type_schema_declares_enum_matching_handler(tmp_path): assert rejected["isError"] is True for value in _CHECK_TARGET_TYPES: assert value in rejected["structuredContent"]["message"] + + +# --------------------------------------------------------------------------- +# Task 3: warpline_preflight_get advisory sibling tool +# --------------------------------------------------------------------------- + + +def test_build_runtime_wires_warpline_from_env(monkeypatch, tmp_path): + from legis.mcp import build_runtime + from legis.warpline_preflight.client import HttpWarplineClient + + monkeypatch.setenv("WARPLINE_API_URL", "http://localhost:9100") + 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) + + +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.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 + # 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("LEGIS_SOURCE_ROOT", str(tmp_path)) + monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) + runtime = build_runtime("agent-x") + assert runtime.warpline is None + + +def test_warpline_preflight_get_unavailable_when_unwired(tmp_path): + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) # warpline defaults to None + result = call_tool(runtime, "warpline_preflight_get", {"base": "aaa"}) + assert not result.get("isError") + assert result["structuredContent"] == { + "status": "unavailable", + "unavailable": [{"reason": "warpline client not configured"}], + } + + +def test_warpline_preflight_get_checked_with_injected_client(tmp_path): + from legis.mcp import call_tool + + class _FakeWarpline: + def impact_radius(self, base, head): + return {"affected": [{"sei": "S1"}], "count": 1} + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + runtime, _store = _runtime(tmp_path) + runtime.warpline = _FakeWarpline() + result = call_tool(runtime, "warpline_preflight_get", {"base": "aaa", "head": "bbb"}) + 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} + + +# Task 5: attestation_get fail-closed scaffolding +# --------------------------------------------------------------------------- + + +def test_attestation_get_unavailable_when_no_protected_gate(tmp_path): + # ENGINE-ONLY DEPLOYMENT: no LEGIS_HMAC_KEY -> runtime.protected_gate is None. + # The trail is not signature-verifiable, so attestation_get MUST return a + # success-envelope unavailable, NOT a silent empty that reads as "never attested". + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) # no protected gate wired + assert runtime.protected_gate is None + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert not result.get("isError") + sc = result["structuredContent"] + assert sc["status"] == "unavailable" + assert sc["sei"] == "mod.fn#1" + assert sc["attestations"] == [] + assert sc["unavailable"] and "reason" in sc["unavailable"][0] + + +def test_attestation_get_tamper_yields_audit_integrity_failure(tmp_path): + # FAIL-CLOSED: a tampered protected trail -> AUDIT_INTEGRITY_FAILURE, nothing + # surfaced. Build a protected runtime whose trail verifier raises TamperError. + from legis.mcp import call_tool + + runtime, _store = _runtime(tmp_path) + + class _TamperVerifier: + def verify(self, records): + from legis.enforcement.protected import TamperError + + raise TamperError("record 4 hash mismatch") + + class _FakeProtectedGate: + def records(self): + return ["bad-record"] + + runtime.protected_gate = _FakeProtectedGate() + runtime.trail_verifier = _TamperVerifier() + result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"}) + assert result.get("isError") + assert result["structuredContent"]["error_code"] == "AUDIT_INTEGRITY_FAILURE" + + +def test_attestation_get_wired_deployment_empty_trail_is_checked(tmp_path): + # KEY-WIRED DEPLOYMENT (Task 8 shipped): when BOTH protected_gate AND + # trail_verifier are wired, the pre-gate passes and the classifier runs over + # the VERIFIED trail. An empty verified trail honestly yields status='checked' + # with an empty attestations list — the trail WAS checked and the SEI simply + # has no human clearance. (Unavailable is reserved for the no-key pre-gate.) + from legis.mcp import call_tool + + 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, "attestation_get", {"sei": "mod.fn#1"}) + assert not result.get("isError") + sc = result["structuredContent"] + assert sc["status"] == "checked" + assert sc["sei"] == "mod.fn#1" + assert sc["attestations"] == [] + + +def test_attestation_get_wired_deployment_admits_genuine_signoff(tmp_path): + # END-TO-END (Task 8): a real signed sign-off through the MCP handler surfaces + # as a signoff_cleared attestation under the queried SEI. + from legis.clock import FixedClock + from legis.enforcement.signoff import SignoffGate + from legis.mcp import call_tool + + runtime, store = _runtime(tmp_path) + key = b"signoff-key" + gate = SignoffGate(store, FixedClock("2026-06-02T12:00:00+00:00"), signer=True, key=key) + req = gate.request( + policy="protected.x", + entity_key=EntityKey(value="loomweave:eid:cleared", identity_stable=True), + rationale="review", + agent_id="agent-1", + extensions={"loomweave": {"content_hash": "ch:e2e"}}, + ) + cleared = gate.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok") + + runtime.protected_gate = gate + runtime.trail_verifier = TrailVerifier(key, frozenset({"protected.x"})) + + result = call_tool(runtime, "attestation_get", {"sei": "loomweave:eid:cleared"}) + assert not result.get("isError") + sc = result["structuredContent"] + assert sc["status"] == "checked" + assert sc["attestations"] == [ + { + "kind": "signoff_cleared", + "content_hash": "ch:e2e", + "recorded_at": "2026-06-02T12:00:00+00:00", + "seq": cleared.seq, + "signoff_seq": req.seq, + } + ] diff --git a/tests/mcp/test_warpline_advisory_boundary.py b/tests/mcp/test_warpline_advisory_boundary.py new file mode 100644 index 0000000..2864c63 --- /dev/null +++ b/tests/mcp/test_warpline_advisory_boundary.py @@ -0,0 +1,174 @@ +"""Byte-identical advisory-boundary acceptance spine. + +Proves the governance-verdict invariant: verdicts are byte-identical regardless +of whether ``runtime.warpline`` is None or points to a hostile advisory client +returning arbitrary garbage data. The structural companion test additionally +asserts that ``runtime.warpline`` is referenced in no verdict-path function +source, giving defense-in-depth coverage. + +If either test fails, warpline data has somehow reached a verdict path — +a security regression. +""" + +import inspect +import json + +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.policy.grammar import AllowlistBoundary, PolicyGrammar +from legis.store.audit_store import AuditStore + + +# --------------------------------------------------------------------------- +# Local helpers (copied verbatim from tests/mcp/test_server.py lines 53-91 +# so this file is self-contained and does not import from a peer test module). +# --------------------------------------------------------------------------- + + +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", + check_surface=None, + judge=None, +): + 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=judge + ) + return McpRuntime( + agent_id=agent_id, + initialized=True, + engine=engine, + check_surface=check_surface, + posture_ledger=_chill_posture_ledger(tmp_path), + ), store + + +# --------------------------------------------------------------------------- +# Advisory-boundary fixtures +# --------------------------------------------------------------------------- + + +class _HostileWarpline: + """Returns arbitrary/garbage advisory data to prove it cannot perturb a verdict.""" + + def impact_radius(self, base, head): + return {"affected": [{"sei": "EVERYTHING"}], "count": 9999, "block": True} + + def reverify_worklist(self, base, head): + return {"entries": [{"sei": "EVERYTHING", "reason": "force"}], "count": 9999} + + +def _seed_real_verdict_runtime(tmp_path): + """A runtime that returns REAL, DETERMINISTIC verdicts. + + Uses the _runtime fixture's FixedClock (timestamps identical across runs) and + registers a real grammar so policy_evaluate returns an actual VIOLATION / + UNKNOWN verdict — NOT an error envelope. An error envelope on BOTH sides would + make the byte-identity assertion pass trivially and prove nothing — the exact + defect the first draft of this test had. Mirrors the seeding in + test_policy_evaluate_returns_unknown_distinct_from_clear (test_server.py:1225). + """ + 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 = [ + # A real VIOLATION verdict (socket not in the {json} allowlist). + call_tool( + runtime, "policy_evaluate", {"policy": "imports", "target": {"value": "socket"}} + ).get("structuredContent"), + # A real UNKNOWN verdict (unknown policy -> provenance gap). + call_tool( + runtime, "policy_evaluate", {"policy": "missing", "target": {}} + ).get("structuredContent"), + ] + # GUARD: these MUST be real verdicts, never error envelopes — otherwise the + # byte-identity assertion below is vacuous. + 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_warpline_unset_vs_hostile(tmp_path): + # Everything is held IDENTICAL across the two runtimes (same FixedClock, same + # seeded grammar) EXCEPT runtime.warpline. If warpline data could reach a + # verdict path, the hostile side would diverge. + runtime_unset = _seed_real_verdict_runtime(tmp_path / "a") + runtime_unset.warpline = None + unset = _run_governance_paths(runtime_unset) + + runtime_set = _seed_real_verdict_runtime(tmp_path / "b") + runtime_set.warpline = _HostileWarpline() # structurally present, hostile + setval = _run_governance_paths(runtime_set) + + assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True) + + +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 + # scan — it sees only these named functions, not helpers they call — so this + # COMPLEMENTS, never replaces, the byte-identity test above. + # + # Tool-handler coverage is DERIVED from _TOOL_HANDLERS so that any future + # handler is covered by construction. The single legitimate advisory handler + # (_tool_warpline_preflight_get) is excluded by name — any other handler that + # starts reading .warpline will cause this test to fail immediately. + import legis.mcp as mcp + from legis.service.governance import read_sei_attestations + + # --- derived: every tool handler except the one legitimate advisory handler --- + _WARPLINE_HANDLER = "_tool_warpline_preflight_get" + for name, handler in mcp._TOOL_HANDLERS.items(): + if handler.__name__ == _WARPLINE_HANDLER: + continue + src = inspect.getsource(handler) + assert ".warpline" not in src, ( + f"tool handler {handler.__name__!r} (tool={name!r}) references warpline" + ) + + # --- explicit: non-handler verdict internals not in _TOOL_HANDLERS --- + for fn in [ + mcp._engine, + mcp._coached_engine, + mcp._governance_trail_records, + read_sei_attestations, + ]: + src = inspect.getsource(fn) + assert ".warpline" not in src, f"{fn.__name__} references warpline" diff --git a/tests/service/test_governance.py b/tests/service/test_governance.py index f0ae3d4..155917a 100644 --- a/tests/service/test_governance.py +++ b/tests/service/test_governance.py @@ -494,3 +494,360 @@ def test_source_binding_status_is_bound_into_the_signature(tmp_path): payload["extensions"]["source_binding"]["status"] = "verified" tampered = signing_fields(payload, seq=rec.seq) assert verify(tampered, result.signature, key) is False + + +# Task 8: read_sei_attestations forge-proof classifier +# --------------------------------------------------------------------------- + + +def test_read_sei_attestations_empty_trail_is_checked_not_unavailable(): + # The function now ALWAYS returns status='checked' — it only ever sees a + # signature-verified trail (the handler owns the unavailable pre-gate). An + # empty verified trail was genuinely checked and honestly has no attestation. + from legis.service.governance import read_sei_attestations + + out = read_sei_attestations([], "mod.fn#1") + assert out["status"] == "checked" + assert out["sei"] == "mod.fn#1" + assert out["attestations"] == [] + assert "unavailable" not in out + + +_ATTEST_SEI = "loomweave:eid:cleared" + + +class _StableJudge: + def evaluate(self, record): + return JudgeOpinion(verdict=Verdict.ACCEPTED, model="judge@1", rationale="ok") + + +def _signed_signoff_gate(tmp_path): + from legis.clock import FixedClock + from legis.enforcement.signoff import SignoffGate + + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + gate = SignoffGate( + store, FixedClock("2026-06-02T12:00:00+00:00"), signer=True, key=b"signoff-key" + ) + return store, gate + + +def _stable_entity_key(sei=_ATTEST_SEI): + return EntityKey(value=sei, identity_stable=True) + + +def _request_and_clear(gate, *, sei=_ATTEST_SEI, content_hash_value="ch:request-1"): + req = gate.request( + policy="protected.x", + entity_key=_stable_entity_key(sei), + rationale="please review", + agent_id="agent-1", + extensions={"loomweave": {"content_hash": content_hash_value}}, + ) + cleared = gate.sign_off( + request_seq=req.seq, operator_id="operator-1", rationale="approved" + ) + return req, cleared + + +def test_read_sei_attestations_admits_genuine_signed_signoff(tmp_path): + # POSITIVE / FORGE-B SOUNDNESS PROOF: a real signed SignoffGate request+sign_off. + # If content_hash(stored PENDING) does NOT equal the signed request_payload_hash + # this admission would not happen — the test passing IS the soundness proof. + from legis.service.governance import read_sei_attestations + + store, gate = _signed_signoff_gate(tmp_path) + req, cleared = _request_and_clear(gate, content_hash_value="ch:signoff-content") + + out = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out["status"] == "checked" + assert out["attestations"] == [ + { + "kind": "signoff_cleared", + "content_hash": "ch:signoff-content", + "recorded_at": "2026-06-02T12:00:00+00:00", + "seq": cleared.seq, + "signoff_seq": req.seq, + } + ] + + +def test_read_sei_attestations_admits_genuine_operator_override(tmp_path): + # POSITIVE: a real signed protected operator override surfaces as + # operator_override with the inline (signed) content_hash. + from legis.clock import FixedClock + from legis.service.governance import read_sei_attestations + + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + gate = ProtectedGate( + store, FixedClock("2026-06-02T12:00:00+00:00"), judge=_StableJudge(), key=b"k" + ) + result = gate.operator_override( + policy="protected.x", + entity_key=_stable_entity_key(), + rationale="operator clears", + operator_id="operator-1", + file_fingerprint="sha256:ff", + ast_path="ap", + extensions={"loomweave": {"content_hash": "ch:override-content"}}, + ) + + out = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out["status"] == "checked" + assert out["attestations"] == [ + { + "kind": "operator_override", + "content_hash": "ch:override-content", + "recorded_at": "2026-06-02T12:00:00+00:00", + "seq": result.seq, + } + ] + + +def test_read_sei_attestations_forge_b_mutated_pending_content_hash_omitted(tmp_path): + # FORGE-B: real signed SIGNED_OFF; mutate the joined PENDING's content_hash in + # the materialized list. The signed request_payload_hash no longer matches the + # recomputed content_hash(pending) -> the sign-off MUST be omitted (the + # mutated hash is NEVER surfaced). The SIGNED_OFF record is unchanged and still + # "verifies"; only the classifier's recompute-and-compare catches the forge. + from legis.service.governance import read_sei_attestations + + store, gate = _signed_signoff_gate(tmp_path) + req, _cleared = _request_and_clear(gate, content_hash_value="ch:original") + + records = store.read_all() + for rec in records: + if rec.seq == req.seq: + rec.payload["extensions"]["loomweave"]["content_hash"] = "ch:FORGED" + + out = read_sei_attestations(records, _ATTEST_SEI) + assert out["status"] == "checked" + assert out["attestations"] == [] + + +def test_read_sei_attestations_forge_a_mutated_verdict_breaks_signature(tmp_path): + # FORGE-A coverage proof: a real signed BLOCKED protected record whose + # judge_verdict is mutated to OVERRIDDEN_BY_OPERATOR. Because judge_verdict is + # SIGNED (signing_fields["verdict"]), the mutation breaks the v3 signature, so + # TrailVerifier.verify RAISES before the classifier ever runs — the forged + # override never reaches read_sei_attestations. + from legis.clock import FixedClock + from legis.enforcement.protected import TrailVerifier + from legis.enforcement.verdict import JudgeOpinion + + class _BlockingJudge: + def evaluate(self, record): + return JudgeOpinion(verdict=Verdict.BLOCKED, model="judge@1", rationale="no") + + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + key = b"k" + gate = ProtectedGate( + store, FixedClock("2026-06-02T12:00:00+00:00"), judge=_BlockingJudge(), key=key + ) + gate.submit( + policy="protected.x", + entity_key=_stable_entity_key(), + rationale="x", + agent_id="agent-1", + file_fingerprint="sha256:ff", + ast_path="ap", + extensions={"loomweave": {"content_hash": "ch:blocked"}}, + ) + + records = store.read_all() + assert records[0].payload["extensions"]["judge_verdict"] == "BLOCKED" + records[0].payload["extensions"]["judge_verdict"] = "OVERRIDDEN_BY_OPERATOR" + + verifier = TrailVerifier(key, frozenset({"protected.x"})) + with pytest.raises(TamperError): + verifier.verify(records) + + +def test_read_sei_attestations_chill_self_clear_stuffing_override_omitted(tmp_path): + # A chill/coached self-clear that STUFFS operator-override extensions. With NO + # signature marker it must be omitted (keying on bare judge_verdict would admit + # an unsigned forgery). Even WITH a non-verifying judge_metadata_signature it is + # omitted unless protected_cell + content_hash are present and signed — but the + # marker-present branch is what the verified pre-gate would have rejected; here + # we assert the no-marker stuffing is omitted. + from legis.service.governance import read_sei_attestations + + class _Rec: + def __init__(self, seq, payload): + self.seq = seq + self.payload = payload + + stuffed = _Rec( + 1, + { + "policy": "ordinary", + "entity_key": {"value": _ATTEST_SEI, "identity_stable": True}, + "recorded_at": "2026-06-02T12:00:00+00:00", + "extensions": { + # operator-override value but NO judge_metadata_signature marker + "judge_verdict": "OVERRIDDEN_BY_OPERATOR", + "protected_cell": True, + "loomweave": {"content_hash": "ch:stuffed"}, + }, + }, + ) + + out = read_sei_attestations([stuffed], _ATTEST_SEI) + assert out["attestations"] == [] + + +def test_chill_stuffing_with_non_verifying_signature_caught_by_pre_gate(tmp_path): + # The asymmetric twin of the no-marker case: a chill/coached self-clear that + # STUFFS operator-override extensions AND a garbage judge_metadata_signature + # marker. The classifier trusts marker presence BY DESIGN (marker == "this was + # in the verified selection"), so a direct classifier call would admit it. The + # defense is the pre-gate: a record carrying the marker IS selected by + # _requires_verification, and its signature does NOT verify -> TrailVerifier + # raises TamperError (-> AUDIT_INTEGRITY_FAILURE) BEFORE the classifier runs. + # Same principle as FORGE-A: marker present + signature invalid -> fail-closed. + from legis.enforcement.protected import TrailVerifier + + class _Rec: + def __init__(self, seq, payload): + self.seq = seq + self.payload = payload + + stuffed_signed = _Rec( + 1, + { + "policy": "ordinary", # non-protected policy + "entity_key": {"value": _ATTEST_SEI, "identity_stable": True}, + "recorded_at": "2026-06-02T12:00:00+00:00", + "rationale": "self-clear", + "agent_id": "agent-1", + "extensions": { + "judge_metadata_signature": "hmac-sha256:v3:garbage", # non-verifying + "judge_verdict": "OVERRIDDEN_BY_OPERATOR", + "protected_cell": True, + "loomweave": {"content_hash": "ch:stuffed"}, + }, + }, + ) + + verifier = TrailVerifier(b"k", frozenset()) + with pytest.raises(TamperError): + verifier.verify([stuffed_signed]) + + +def test_read_sei_attestations_unsigned_procedural_signoff_omitted(tmp_path): + # A structured (procedural) sign-off built with no signer -> SIGNED_OFF carries + # no signoff_signature marker -> omitted. + from legis.clock import FixedClock + from legis.enforcement.signoff import SignoffGate + from legis.service.governance import read_sei_attestations + + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + gate = SignoffGate(store, FixedClock("2026-06-02T12:00:00+00:00")) # no signer + _request_and_clear(gate) + + out = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out["status"] == "checked" + assert out["attestations"] == [] + + +def test_read_sei_attestations_cross_sei_not_surfaced(tmp_path): + # A genuine signed sign-off for a DIFFERENT SEI must not surface under the query. + from legis.service.governance import read_sei_attestations + + store, gate = _signed_signoff_gate(tmp_path) + _request_and_clear(gate, sei="loomweave:eid:other") + + out = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out["attestations"] == [] + + +def test_read_sei_attestations_identity_unstable_omitted(tmp_path): + # identity_stable False in the SIGNED entity dict -> omitted. + from legis.service.governance import read_sei_attestations + + class _Rec: + def __init__(self, seq, payload): + self.seq = seq + self.payload = payload + + unstable = _Rec( + 1, + { + "policy": "protected.x", + "entity_key": {"value": _ATTEST_SEI, "identity_stable": False}, + "recorded_at": "2026-06-02T12:00:00+00:00", + "extensions": { + "judge_metadata_signature": "hmac-sha256:v3:deadbeef", + "judge_verdict": "OVERRIDDEN_BY_OPERATOR", + "protected_cell": True, + "loomweave": {"content_hash": "ch:x"}, + }, + }, + ) + + out = read_sei_attestations([unstable], _ATTEST_SEI) + assert out["attestations"] == [] + + +def test_read_sei_attestations_empty_content_hash_omitted(tmp_path): + # Empty inline content_hash (operator_override) and empty joined content_hash + # (signoff) both omit — never surface content_hash == "". + from legis.service.governance import read_sei_attestations + + # operator_override inline-empty + class _Rec: + def __init__(self, seq, payload): + self.seq = seq + self.payload = payload + + override_empty = _Rec( + 1, + { + "policy": "protected.x", + "entity_key": {"value": _ATTEST_SEI, "identity_stable": True}, + "recorded_at": "2026-06-02T12:00:00+00:00", + "extensions": { + "judge_metadata_signature": "hmac-sha256:v3:deadbeef", + "judge_verdict": "OVERRIDDEN_BY_OPERATOR", + "protected_cell": True, + "loomweave": {"content_hash": ""}, + }, + }, + ) + out = read_sei_attestations([override_empty], _ATTEST_SEI) + assert out["attestations"] == [] + + # signoff join-empty: real signed sign-off whose PENDING has empty content_hash + store, gate = _signed_signoff_gate(tmp_path) + _request_and_clear(gate, content_hash_value="") + out2 = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out2["attestations"] == [] + + +def test_read_sei_attestations_blocked_verdict_omitted(tmp_path): + # A genuine signed BLOCKED protected verdict is not a clearance -> omitted. + from legis.clock import FixedClock + from legis.enforcement.verdict import JudgeOpinion + from legis.service.governance import read_sei_attestations + + class _BlockingJudge: + def evaluate(self, record): + return JudgeOpinion(verdict=Verdict.BLOCKED, model="judge@1", rationale="no") + + store = AuditStore(f"sqlite:///{tmp_path}/gov.db") + gate = ProtectedGate( + store, FixedClock("2026-06-02T12:00:00+00:00"), judge=_BlockingJudge(), key=b"k" + ) + gate.submit( + policy="protected.x", + entity_key=_stable_entity_key(), + rationale="x", + agent_id="agent-1", + file_fingerprint="sha256:ff", + ast_path="ap", + extensions={"loomweave": {"content_hash": "ch:blocked"}}, + ) + + out = read_sei_attestations(store.read_all(), _ATTEST_SEI) + assert out["status"] == "checked" + assert out["attestations"] == [] diff --git a/tests/service/test_preflight.py b/tests/service/test_preflight.py new file mode 100644 index 0000000..a7538c4 --- /dev/null +++ b/tests/service/test_preflight.py @@ -0,0 +1,63 @@ +from legis.service.preflight import read_warpline_preflight +from legis.warpline_preflight.client import WarplineError + + +class _OkWarpline: + def impact_radius(self, base, head): + return {"affected": [{"sei": "S1"}], "count": 1} + + def reverify_worklist(self, base, head): + return {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1} + + +class _ImpactRaisesWarpline: + def impact_radius(self, base, head): + raise WarplineError("boom") + + def reverify_worklist(self, base, head): + return {"entries": [], "count": 0} + + +class _WorklistRaisesWarpline: + def impact_radius(self, base, head): + return {"affected": [], "count": 0} + + def reverify_worklist(self, base, head): + raise WarplineError("timeout") + + +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}, + } + + +def test_unavailable_when_client_is_none_not_a_silent_empty(): + out = read_warpline_preflight(None, "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"] == [{"reason": "warpline client not configured"}] + # ASYMMETRIC: never an empty affected-set that reads as "nothing impacted". + assert "impact_radius" not in out + + +def test_unavailable_when_impact_radius_raises_warpline_error(): + out = read_warpline_preflight(_ImpactRaisesWarpline(), "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"][0]["reason"].startswith("warpline check failed:") + + +def test_unavailable_when_worklist_raises_warpline_error(): + # Partial advisory context is NOT surfaced as checked — either method failing + # degrades the WHOLE read to unavailable. + out = read_warpline_preflight(_WorklistRaisesWarpline(), "aaa", "bbb") + assert out["status"] == "unavailable" + assert out["unavailable"][0]["reason"].startswith("warpline check failed:") + + +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 diff --git a/tests/warpline_preflight/__init__.py b/tests/warpline_preflight/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/warpline_preflight/test_client.py b/tests/warpline_preflight/test_client.py new file mode 100644 index 0000000..7e04a88 --- /dev/null +++ b/tests/warpline_preflight/test_client.py @@ -0,0 +1,128 @@ +import inspect +import json + +import pytest + +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 _recorder(responses): + """An injectable Fetch that returns queued dicts and records calls.""" + calls = [] + + def fetch(method, url, body): + calls.append((method, url, body)) + return responses.pop(0) + + fetch.calls = calls + return fetch + + +def test_protocol_is_runtime_checkable(): + client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([{}])) + assert isinstance(client, 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_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_non_object_response_is_a_warpline_error(): + client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([["not", "a", "dict"]])) + 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") + + +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 _normalize(src): + # The ONLY intended differences are the sibling name and its error class. + return src.replace("Filigree", "Warpline").replace("filigree", "warpline") + + +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 + ) diff --git a/uv.lock b/uv.lock index 367cd58..7604c90 100644 --- a/uv.lock +++ b/uv.lock @@ -498,7 +498,7 @@ wheels = [ [[package]] name = "legis" -version = "1.1.1" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "cryptography" },