diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index ff8d2ae..577427c 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -4,8 +4,9 @@ # copied verbatim into the build output). It consumes the shared # @weft/site-kit, which lives in a SUBDIRECTORY of a DIFFERENT repo (the weft # hub). npm cannot install a git subdirectory directly, so a fetch step -# sparse-checks-out packages/site-kit into site/vendor/site-kit/ before -# `npm install` resolves the `file:./vendor/site-kit` dependency. +# sparse-checks-out packages/site-kit at a pinned reviewed commit into +# site/vendor/site-kit/ before `npm install` resolves the `file:./vendor/site-kit` +# dependency. name: Deploy legis site on: @@ -51,7 +52,7 @@ jobs: node-version: 22 - name: Fetch @weft/site-kit - # Sparse-fetch packages/site-kit from the weft hub repo into + # Sparse-fetch packages/site-kit from the pinned Weft commit into # vendor/site-kit/ so the file: dependency resolves on install. run: npm run fetch-site-kit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e447e2e..4b735f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,26 @@ jobs: - name: Install dependencies run: uv sync --dev + - name: Verify lockfile + run: uv lock --check + + - name: Run lint + run: uv run ruff check src + - name: Test gate (never publish a red build) - run: uv run pytest -q + run: uv run pytest --cov=legis --cov-report=term-missing --cov-report=json --cov-fail-under=88 + + - name: Enforce per-package coverage floors + run: uv run python scripts/check_coverage_floors.py + + - name: Run SEI conformance oracle + run: uv run pytest tests/conformance/test_sei_oracle.py + + - name: Run type check + run: uv run mypy src/legis + + - name: Run policy-boundary honesty gate + run: uv run legis policy-boundary-check --root src --repo-root . - name: Verify release tag matches project version env: @@ -61,44 +79,46 @@ jobs: env: LOOMWEAVE_URL: ${{ vars.LOOMWEAVE_URL }} LOOMWEAVE_LIVE_ORACLE_LOCATOR: ${{ vars.LOOMWEAVE_LIVE_ORACLE_LOCATOR }} - LEGIS_LOOMWEAVE_HMAC_KEY: ${{ secrets.LEGIS_LOOMWEAVE_HMAC_KEY }} steps: - uses: actions/checkout@v4 - # Skip-not-fail: when the release environment is not provisioned with the - # live oracle config, this job passes as a fast no-op so it never blocks - # the PyPI publish (the rc4 blocker f95036b removed — do not reintroduce - # it). When the config IS present, the oracle runs for real and a - # conformance failure blocks publish — the gate still bites where it can. - - name: Detect live oracle configuration - id: oracle_config + - name: Require live oracle configuration run: | missing=() - for name in LOOMWEAVE_URL LOOMWEAVE_LIVE_ORACLE_LOCATOR LEGIS_LOOMWEAVE_HMAC_KEY; do + for name in LOOMWEAVE_URL LOOMWEAVE_LIVE_ORACLE_LOCATOR; do if [ -z "${!name}" ]; then missing+=("${name}") fi done if [ "${#missing[@]}" -ne 0 ]; then joined="$(IFS=', '; echo "${missing[*]}")" - echo "::notice::Live Loomweave oracle not provisioned (${joined} unset) — skipping conformance, not blocking publish." - echo "configured=false" >> "$GITHUB_OUTPUT" - else - echo "configured=true" >> "$GITHUB_OUTPUT" + echo "::error::Missing required release conformance environment: ${joined}" + exit 1 fi - uses: astral-sh/setup-uv@v5 - if: steps.oracle_config.outputs.configured == 'true' with: enable-cache: true - name: Install dependencies - if: steps.oracle_config.outputs.configured == 'true' run: uv sync --dev - name: Run live Loomweave oracle - if: steps.oracle_config.outputs.configured == 'true' - run: uv run pytest tests/conformance/test_live_loomweave_oracle.py + env: + LEGIS_LOOMWEAVE_HMAC_KEY: ${{ secrets.LEGIS_LOOMWEAVE_HMAC_KEY }} + run: | + missing=() + for name in LOOMWEAVE_URL LOOMWEAVE_LIVE_ORACLE_LOCATOR LEGIS_LOOMWEAVE_HMAC_KEY; do + if [ -z "${!name}" ]; then + missing+=("${name}") + fi + done + if [ "${#missing[@]}" -ne 0 ]; then + joined="$(IFS=', '; echo "${missing[*]}")" + echo "::error::Missing required release conformance environment: ${joined}" + exit 1 + fi + uv run pytest tests/conformance/test_live_loomweave_oracle.py publish: name: Publish to PyPI diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddd4bf..09bbdea 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.1.1] — 2026-06-23 + +Security and release-readiness hardening after the 1.1.0 dogfood release. + +### Fixed + +- **Posture floor reads now fail closed and ignore metadata tails.** Missing or + uninitialized posture ledgers resolve to the structured floor instead of + crashing or inheriting a self-clearable registry default, and session metadata + appended after a floor transition can no longer become the effective floor. +- **Operator elevation sessions are authenticated.** Session files now carry a + backend-signed HMAC; forged or stale session metadata is refused before a + posture transition can be written. +- **Rekey recovery preserves the standing floor and is doctor-verifiable through + age-file custody.** Rekey appends a loud `KEY_RESET` without lowering posture, + and doctor can verify the follow-up acknowledgment through the default + age-file backend when the operator provides the passphrase. +- **Explicit posture installation fails closed when key custody is not + configured.** Bare `legis install` may still defer posture setup, but + `legis install --posture` now exits non-zero if no GENESIS can be written. +- **Governance and override authority checks are stricter.** Supplied SEIs must + bind to the asserted entity before a governance record is accepted, and + coached HTTP overrides require an enabled judge instead of recording a + no-judge success path. +- **Install, release, and site-kit custody are tighter.** `.mcp.json` + registration rejects non-canonical Legis command heads while preserving safe + operator env, release publication requires live Loomweave oracle conformance, + release secrets are scoped to the oracle step, and the fetched Weft site-kit + dependency is pinned. + ## [1.1.0] — 2026-06-19 Three defects surfaced by a `lacuna` dogfooding pass, confirmed (investigation + @@ -618,7 +648,9 @@ WP-M1 service-layer extraction, consolidated behind a stable version. `HTTPException`, so both HTTP and the forthcoming MCP adapter drive one code path. Behavior-preserving; FastAPI handlers are now thin adapters. -[Unreleased]: https://github.com/foundryside-dev/legis/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/foundryside-dev/legis/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/foundryside-dev/legis/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/foundryside-dev/legis/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/foundryside-dev/legis/compare/v1.0.0rc4...v1.0.0 [1.0.0rc4]: https://github.com/foundryside-dev/legis/compare/v1.0.0rc3...v1.0.0rc4 [1.0.0rc3]: https://github.com/foundryside-dev/legis/compare/v1.0.0rc2...v1.0.0rc3 diff --git a/README.md b/README.md index 08f5ca2..e81fb2a 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Legis is the fourth Weft product: the git/CI and governance side of the suite's ## Status -Legis is at **`1.0.0`** — the gold release. The standalone git/CI surfaces, the graded 2x2 enforcement engine, the agent-programmable policy grammar, SEI-keyed attestations, and the Wardline/Filigree suite combinations are built and tested. The git-rename provider to Loomweave is contract-locked, operative pending Loomweave's committed-range driving. +Legis is at **`1.1.1`** — the gold release line with post-launch posture, install, and release-gate hardening. The standalone git/CI surfaces, the graded 2x2 enforcement engine, the agent-programmable policy grammar, SEI-keyed attestations, and the Wardline/Filigree suite combinations are built and tested. The git-rename provider to Loomweave is contract-locked, operative pending Loomweave's committed-range driving. The transport-agnostic service layer (WP-M1) and the agent-facing MCP surface on top of it have landed (`legis mcp`). The MCP surface now declares output schemas across its tools, exposes read-side governance/diagnostic tools (`doctor_get`, `override_list`, `policy_boundary_check`, lineage-honesty reads, `check_report`, `signoff_bind_issue`), and keeps the API/MCP/CLI paths routed through the same service layer instead of duplicating governance decisions. Legis stands itself up with `legis install`: instruction block, `legis-workflow` skill pack, SessionStart hook, `.mcp.json` registration, and the Legis-only `.weft/legis/` ignore rule. `legis doctor [--fix]` provides an operator health view and safe repair for the install + config layer, tagging each problem `[auto-fixable]` or `[operator]` so it is clear what `--fix` will and will not touch. Doctor names enablement paths when governance is unwired (policy cells, Wardline routing), but it reports rather than auto-enabling policy surfaces or touching signing keys. -Gold was earned, not declared: 1.0.0 was first cut on 2026-06-09, then re-opened when a P0 governance-honesty false-green (G1 — an absent Wardline `findings` key routing zero defects under a green status) was caught *after* the cut. The fix, the cross-member conformance vector that makes it real, and a small batch of follow-through hardening shipped before final. See the combination matrix below for per-pairing status and `CHANGELOG.md` for the full release notes. +Gold was earned, not declared: 1.0.0 was first cut on 2026-06-09, then re-opened when a P0 governance-honesty false-green (G1 — an absent Wardline `findings` key routing zero defects under a green status) was caught *after* the cut. The 1.1.x line keeps that surface hardened: posture floors fail closed, operator sessions are signed, rekey recovery preserves the standing floor, and release publication is gated on live Loomweave conformance. See the combination matrix below for per-pairing status and `CHANGELOG.md` for the full release notes. ### Last week in practical terms @@ -135,7 +135,7 @@ Legis is a governance-*honesty* tool, so it states its own residual limits plain - **The coached cell is a model-robustness wall, not a cryptographic one.** A blocked agent clears the coached gate by convincing the LLM judge; a *malicious prompt injection* that persuades the model will likewise clear it. Structural injection (forging a verdict key) is closed and any transport/parse failure is fail-closed to `BLOCKED`, but the coached cell has no defense-in-depth against a model that is genuinely fooled. For verdicts that must not rest on the model's word, use the **protected** cell, where a judge `ACCEPTED` is advisory only and is downgraded to require operator sign-off (unless a deterministic, non-LLM validator confirms it). - **Tamper-evidence assumes the signing key is out of the attacker's reach, and is not absolute against raw DB-file writes.** v3 signing binds each record's chain position, so in-place edits, reordering, and renumbering are detected. A holder of raw write access to the governance `.db` can still *delete* a record and re-chain, or rewrite a record's policy to a non-protected value and strip its protected markers ("modify-to-unsigned"), or truncate the tail — these are residuals of the conceded raw-file-write threat tier. The opt-in `HeadAnchor` mitigates truncation/rewind (with a documented anchor-replay caveat). `legis doctor` now refuses to bless zero-byte or missing-schema audit stores without creating replacement tables, but that is an operator diagnostic, not a substitute for storage custody. Keep the governance store on storage only the operator controls. -- **The operator session file is a metadata record, not an encrypted vault.** A process with read access to `.weft/legis/operator_session.json` can read the keychain item id and, if it also has keychain access, produce arbitrary signatures during the window. This is the same tier as raw-DB-write access. The mitigation is OS keychain access control (the item accessible only to the legis process user), not file encryption of the session file. The file never holds the key, a passphrase, or a raw age blob — only window metadata and a backend-specific unlock reference (`None` for the age-file/env backends, where re-prompt is the unlock). +- **The operator session file is authenticated metadata, not an encrypted vault.** A process with read access to `.weft/legis/operator_session.json` can read the keychain item id and the session metadata signature; if it also has keychain access, it can produce arbitrary signatures during the window. This is the same tier as raw-DB-write access. The mitigation is OS keychain access control (the item accessible only to the legis process user), not file encryption of the session file. The file never holds the key, a passphrase, or a raw age blob — only window metadata, a backend-specific unlock reference (`None` for the age-file/env backends, where re-prompt is the unlock), and an HMAC proving the operator key opened that session. - **Durability tier.** The audit store runs `synchronous=FULL`, but a power loss can still drop the most recent un-checkpointed appends; the trail stays internally consistent (a shortened-but-valid tail), it does not corrupt. - **SEI binding integrity rests on TLS by design.** The Weft request HMAC authenticates legis's *requests* to Loomweave; it does not sign Loomweave's *responses*. Filigree binds are transport-open and rely on TLS plus the app-level `binding_signature` and local `BindingLedger` evidence, not on `X-Weft-*` headers. `LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1` still permits plaintext to a remote sibling and therefore **voids that custody seal** (an on-path attacker could forge a stable identity binding) — it logs a warning and is for dev/loopback use only. diff --git a/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-design.md b/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-design.md index ea91998..3753767 100644 --- a/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-design.md +++ b/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-design.md @@ -25,7 +25,7 @@ The mechanism for "the operator authorizes a change" must respect a hard constra - The floor applies **uniformly across every surface — MCP, HTTP API, and CLI — through one shared `FlooredRegistry` chokepoint.** As part of this, the HTTP API's cell-addressed submit routes are **unified into one policy-routed submit** so the server (not the caller) owns the cell decision; this closes the API floor-bypass door and makes the README's "API/MCP/CLI routed through the same service layer" claim true (see §3a). - Install **mints** an operator key and hands it to a custody backend; the key is never written to disk in plaintext by Legis (except the explicit env escape hatch). - An **operator elevation session** (`legis operator enable`) — `sudo` for governance signing — unlocks signing for a short, time-boxed, **attributable** window via an OS keychain prompt. -- A lost key is **recoverable, not catastrophic**: a keyless `rekey` that resets to chill, preserves history, and is loudly recorded. +- A lost key is **recoverable, not catastrophic**: a keyless `rekey` that preserves the standing floor, preserves history, and is loudly recorded. - Every keyed action is **tamper-evident** and produces exactly one append-only record — no silent path (consistent with `src/legis/enforcement/engine.py`). ### Non-goals (v1) @@ -64,7 +64,7 @@ v1 closes this by **routing the API by policy, exactly like MCP**, rather than b ## 4. The posture ledger -A new small append-only, hash-chained ledger at **`.weft/legis/posture.db`** (sibling to the existing audit stores; consistent with `weft-store-consolidation`). It reuses `src/legis/store/audit_store.py` machinery rather than introducing a new crypto/storage stack. The **current floor is the last record.** +A new small append-only, hash-chained ledger at **`.weft/legis/legis-posture.db`** (sibling to the existing audit stores; consistent with `weft-store-consolidation`). It reuses `src/legis/store/audit_store.py` machinery rather than introducing a new crypto/storage stack. The **current floor is the newest authoritative floor record** (`GENESIS`, `TRANSITION`, or `KEY_RESET`); metadata tails such as `OPERATOR_SESSION_OPENED` are skipped and cannot lower the effective floor. Record shape: @@ -82,12 +82,12 @@ Canonicalization reuses the existing `canonical.py` contract (the byte-for-byte ### Precedence / source-of-truth - The **signed ledger floor is authoritative.** The `cells.toml`/env registry is layered *above* it via the `max(...)` rule and can never lower the effective cell below the floor. -- **Absent/empty ledger** (genuinely uninstalled, or deleted store) → the floor is a **no-op (identity floor `chill`)**, deferring to the registry's own default. That default is itself fail-closed (`fail_closed_policy_cells()` → `structured`) **in production**, so a deleted/uninstalled ledger still yields `structured` there and can never silently mean "do nothing"; only under the explicit `LEGIS_DEV_DEFAULT_CELLS` dev opt-in does it stay `chill` (preserving the N3 keyless-chill acceptance). The floor only ever **raises** the effective cell, once an operator has written a `GENESIS`/`TRANSITION`. *(Reconciled 2026-06-17 during implementation: forcing `structured` over an absent ledger broke the dev opt-in, the N3 acceptance, and the `build_runtime` no-local-state invariant; deferring to the already-fail-closed registry default preserves all three while staying fail-closed in production. `build_runtime` also opens the ledger `initialize=False` so launching the server never creates the store.)* +- **Absent/empty ledger** (genuinely uninstalled, deleted store, or initialized without `GENESIS`) → the floor is **`structured`**, fail-closed. `LEGIS_DEV_DEFAULT_CELLS=1` can still make the raw registry default `chill`, but the posture floor is authoritative and a missing ledger raises the effective cell to `structured`, never self-clear. The floor only ever **raises** the effective cell, once an operator has written a `GENESIS`/`TRANSITION`/`KEY_RESET`. `build_runtime` still opens the ledger `initialize=False` so launching the server never creates the store. ## 5. Install behavior `legis install` with no prior posture ledger: -1. Creates `.weft/legis/posture.db` and writes the **`GENESIS` record: `floor = chill`**. +1. Creates `.weft/legis/legis-posture.db` and writes the **`GENESIS` record: `floor = chill`**. 2. **Mints the operator key** — `secrets.token_hex(32)`. This is net-new behaviour: `src/legis/config.py:31` currently states Legis touches no key material, and this design **explicitly amends that doctrine** for this one operator-authority key. 3. Hands the key to the **chosen custody backend** (§6). What lands in the ledger is the key **fingerprint + backend id**, never the key. @@ -109,7 +109,7 @@ Backends (v1): **Crypto is a mandatory dependency.** The age-file backend uses the `cryptography` package (scrypt KDF + AES-GCM); it is a hard dependency, not an optional extra — encrypted-at-rest custody is core to this feature and only grows in importance. (No `age` CLI shell-out.) -**age-file session ergonomics (accepted friction).** For the age-file backend *without* an available OS keychain to hold a session-wrapping secret, each `posture set` within the window **re-prompts for the passphrase** — the session file holds only metadata, never the key or passphrase. This is the honest trade-off and is intentional: the friction is the point; anyone who wants the smooth "no further prompts in the window" experience uses the keychain backend. +**age-file session ergonomics (accepted friction).** For the age-file backend *without* an available OS keychain to hold a session-wrapping secret, each `posture set` within the window **re-prompts for the passphrase** — the session file holds metadata plus a session HMAC, never the key or passphrase. This is the honest trade-off and is intentional: the friction is the point; anyone who wants the smooth "no further prompts in the window" experience uses the keychain backend. Default backend at install: **OS keychain if available, else age-file**; the env escape hatch only on an explicit `--insecure-key-in-env`. @@ -122,16 +122,16 @@ Per-action keychain prompts are replaced by a **time-boxed elevation session**: ``` legis operator enable [--ttl 5m] └─ OS keychain prompt ── human auths ──or not - └─ on auth: a session is opened for the TTL. The key NEVER lands on disk in - plaintext; the session file holds only metadata + a backend-specific unlock - reference (keychain item id, or an age session-wrapped blob), never the key + └─ on auth: a session is opened for the TTL. The key NEVER lands on disk in + plaintext; the session file holds metadata + a backend-specific unlock + reference + a session HMAC, never the key └─ within the window: posture set (and, future, sign-offs/verdicts/commits) are signed on request — keychain backend: silent (no further prompt); age-file-without-keychain: re-prompts per set (accepted friction) └─ TTL lapses → session file deleted (any wrapped blob gone) → locked ``` -- **v1 session model is a persisted session file, not an in-memory daemon.** `legis` is a fresh process per CLI invocation, so the "ssh-agent style" long-lived signing daemon is deferred to v1.1. v1 uses a two-level key hierarchy: at `enable`, custody is unlocked once; the operator key is held only via a backend-specific unlock reference in `.weft/legis/operator_session.json` (keychain item id, or an age-wrapped blob whose wrapping secret lives in the keychain) — never the raw key, never a passphrase. "Zeroized on TTL lapse" = the session file (and any wrapped blob it held) is deleted; the key in custody is untouched. +- **v1 session model is a persisted session file, not an in-memory daemon.** `legis` is a fresh process per CLI invocation, so the "ssh-agent style" long-lived signing daemon is deferred to v1.1. v1 uses a two-level key hierarchy: at `enable`, custody is unlocked once; the operator key authenticates the session metadata in `.weft/legis/operator_session.json`, which carries a backend-specific unlock reference (keychain item id, or `None` for age/env) and an HMAC — never the raw key, never a passphrase. "Zeroized on TTL lapse" = the session file is deleted; the key in custody is untouched. - **Default TTL: 5 minutes**, configurable via `--ttl`; `legis operator disable` ends it early. - The human's act of enabling **is** "humans on the loop, not in the loop" — a declaration of presence supervising a burst of work, not per-signature approval. @@ -155,11 +155,11 @@ Changing the floor = appending a `TRANSITION` record. The gate: Losing the key must be **embarrassing, not catastrophic** — "you're re-signing everything because you lost your key", not "you can no longer prove you operate this project, rebuild the repo." `legis posture rekey`: -- **Requires no old key** (you lost it) — but is therefore, by definition, a keyless way to become the operator. It is made safe by being **loud and self-limiting**: - - It **resets the floor to chill** and mints a **new** operator key (into the chosen backend). You cannot rekey directly into a high posture; to get back up you `operator enable` + `posture set` with the new key (the "embarrassing, re-sign everything" part). +- **Requires no old key** (you lost it) — but is therefore, by definition, a keyless way to become the operator. It is made safe by being **loud and floor-preserving**: + - It **preserves the standing floor** and mints a **new** operator key (into the chosen backend). You cannot use rekey to lower the current posture; to acknowledge the new epoch you `operator enable` + `posture set ` with the new key (the "embarrassing, re-sign everything" part). - It writes a **`KEY_RESET` genesis record chained onto the existing history** — history is preserved, not nuked — recording that the operator key was reset without proof of the prior key. - `legis doctor` surfaces the reset prominently ("posture key epoch reset on by "). -- **Threat symmetry / honesty:** an attacker can also run `rekey` to force chill — but only in the loudest possible way (an indelible, dated, attributed `KEY_RESET`). They cannot silently downgrade, and they cannot rekey *into* a chosen posture. This is exactly Legis's tamper-**evident** stance: the honest claim is "an unauthorized posture reset leaves a permanent mark", not "is impossible". +- **Threat symmetry / honesty:** an attacker can also run `rekey` to force a key-epoch reset — but only in the loudest possible way (an indelible, dated, attributed `KEY_RESET`). They cannot silently downgrade, and they cannot rekey *into* a chosen posture. This is exactly Legis's tamper-**evident** stance: the honest claim is "an unauthorized posture reset leaves a permanent mark", not "is impossible". ## 9. Honesty / threat model statement (published, per Legis doctrine) @@ -176,7 +176,7 @@ Legis states its own residual limits rather than hiding them in comments (`READM - **Gate:** transition refused with no open session; refused on fingerprint mismatch; accepted with valid session; fail-closed on signer error; exactly one record per outcome. - **Custody backends:** keychain (mocked secure store), age-file (real encrypt/decrypt round-trip), env escape hatch emits warning. Signer never returns key bytes to caller. - **Elevation session:** enable opens window + writes `OPERATOR_SESSION_OPENED`; TTL lapse zeroizes; `disable` ends early; every in-window signature carries `session_id`. -- **Rekey:** resets to chill, mints new epoch, writes `KEY_RESET` onto existing chain (history preserved), needs no old key, doctor flags it. +- **Rekey:** preserves the standing floor, mints new epoch, writes `KEY_RESET` onto existing chain (history preserved), needs no old key, doctor flags it. - **Doctor reconciliation:** floor-vs-registry report; ledger discontinuity / epoch-reset surfaced; **`legis doctor` exits non-zero on an unacknowledged `KEY_RESET`** so a rekey (legitimate or attacker-forced) fails CI loudly; zero-byte/missing store handled report-only (consistent with existing doctor posture). - **API unification:** unified `POST /overrides` routes by policy through `FlooredRegistry` and returns the discriminated outcome for each cell; a `floor=structured` floor refuses a would-be chill self-clear (no bypass); operator-clear routes (`/signoff/{seq}/sign`, `/protected/operator-override`) unchanged; existing `tests/api/*` rewritten against the unified route; `docs/federation/sei-conformance.md` updated and the SEI conformance vector re-pinned to the new route surface. @@ -196,7 +196,7 @@ These share v1's primitive but each is its own risk surface and spec. - **Any** floor change needs the key (the key exists from install, so direction-aware ratcheting is unnecessary); registry tightening above the floor stays keyless. - Custody is the real control, **not** CLI-vs-MCP surface gating. - Elevation sessions (`operator enable`, 5-min TTL) replace per-action prompts and provide the accountability record. -- Lost key → keyless `rekey` that resets to chill, preserves history, is loudly recorded. +- Lost key → keyless `rekey` that preserves the standing floor, preserves history, is loudly recorded. - v1 scope = elevation-session primitive + posture floor as its only consumer; the rest is future state. ### Decisions resolved post-plan (2026-06-16, against the workflow plan + review) diff --git a/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md b/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md index 5bbb26a..676a6f5 100644 --- a/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md +++ b/docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md @@ -99,7 +99,8 @@ Fail-closed rule for this phase: **absent ledger → `read_floor()` reports "no - `test_genesis_writes_chill_floor` — fresh DB; `ledger.genesis(...)` appends one `kind=GENESIS, floor="chill"`; `read_floor()` returns `"chill"`. - `test_read_floor_missing_ledger_returns_none` — no DB file; `read_floor()` returns `None`; assert it does NOT return `"chill"`. - `test_read_floor_is_last_record` — after genesis then a transition to `structured`, `read_floor()` returns `"structured"`. - - `test_read_floor_uses_tail_read` — instrument/spy that `read_floor()` does **not** call `read_all()`; it uses `get_latest_sequence_and_hash()` + `read_by_seq`. **(addresses Architecture medium: per-request hot path)** + - `test_read_floor_uses_tail_read` — instrument/spy that `read_floor()` does **not** call `read_all()`; it uses a tail-oriented query for the latest authoritative floor record. **(addresses Architecture medium: per-request hot path)** + - `test_read_floor_does_not_point_read_each_metadata_tail` — metadata records after the floor do not force a repeated `read_by_seq()` loop over the tail. - `test_chain_integrity` — `store.verify_integrity()` True after genesis + transition. - `test_idempotent_open` — opening the ledger twice over an existing DB does NOT append a second GENESIS. - `test_genesis_blocked_after_key_reset` — `genesis()` on a ledger whose tail is a `KEY_RESET` (non-empty, no `GENESIS` re-needed) returns without appending. **(addresses Quality high)** @@ -108,7 +109,7 @@ Fail-closed rule for this phase: **absent ledger → `read_floor()` reports "no - **Implementation:** - `PostureLedger.__init__(self, url, *, initialize=True)` constructs `AuditStore(url, initialize=initialize)` like `audit_store.py:116`. - `genesis(key_fingerprint, agent_id, recorded_at)` → keyless `PostureRecord(kind=GENESIS, floor="chill", ...)`, `store.append(record.to_payload())` (`audit_store.py:285`). **Guard:** return early if `store.read_all()` is non-empty (covers both an existing GENESIS and a KEY_RESET tail). - - `read_floor() -> str | None`: if DB/file absent → `None`. Else `seq, _ = store.get_latest_sequence_and_hash()`; if no records → `None`; else `return store.read_by_seq(seq).payload["floor"]` (two O(1) SQLite queries, no JSON-decode loop). `read_all()` is reserved for `verify_integrity()` in doctor. + - `read_floor() -> str | None`: if DB/file absent → `None`; otherwise issue one descending SQLite query over `audit_log.payload`, skip metadata records, and return the newest authoritative `floor` from `GENESIS`, `TRANSITION`, or `KEY_RESET`. `read_all()` is reserved for `verify_integrity()` in doctor, and metadata tails must not create a repeated `read_by_seq()` loop. - `transition(new_cell, *, signer, session_id, key_fingerprint, agent_id, rationale, recorded_at)`: **resolve current-epoch `key_fingerprint` via a tail read BEFORE `append_signed`** (never inside the build callback). Then `append_signed(build_payload)` (`audit_store.py:296`); inside `build(seq, prev_hash)`: assemble signing fields including `chain_seq=seq`, verify `signer.fingerprint() == key_fingerprint` first, then `signer.sign(fields)`; embed `operator_sig`/`session_id`. **Fail-closed:** signer raise or fingerprint mismatch → raise before persist (no half-write). - `rekey(...)` and `session_opened(...)` are signatures here, implemented in Phase 11 / Phase 3.2. - **Verify:** `pytest tests/posture/test_ledger.py -q`. @@ -160,7 +161,7 @@ Fail-closed rule: **no open session, or expired session → `posture set` / `tra - **Create:** `src/legis/posture/session.py`. Includes a local `_atomic_write_json(path, obj)` helper (temp file + `os.replace`) — **`_atomic_write_text` does NOT exist in `install.py`; do not import it.** **(addresses reality-grounding critical)** - **Test first:** `tests/posture/test_session.py`: - - `test_enable_writes_session_file` — `open_session(ttl=300, operator_id=..., backend_id=..., unlock_ref=...)` writes `.weft/legis/operator_session.json` containing only `session_id, operator_id, opened_at, ttl, expires_at, backend_id, unlock_ref` — assert NO `key`, NO passphrase, NO raw blob plaintext. + - `test_enable_writes_session_file` — `open_session(ttl=300, operator_id=..., backend_id=..., unlock_ref=..., signer=...)` writes `.weft/legis/operator_session.json` containing only `session_id, operator_id, opened_at, ttl, expires_at, backend_id, unlock_ref, session_sig` — assert NO `key`, NO passphrase, NO raw blob plaintext. - `test_age_backend_unlock_ref_is_none` — for an age-file session, `unlock_ref is None` (per D5: re-prompt is the unlock; only keychain stores an item id). **(addresses Architecture medium)** - `test_session_active_within_ttl` / `test_session_expired_after_ttl` — `is_active` honors TTL; `load_session()` past TTL returns `None` AND deletes the file. - `test_load_session_double_expire_is_safe` — calling `load_session()` twice past TTL returns `None` both times without raising; the self-delete catches `FileNotFoundError`. **(addresses Quality medium)** @@ -168,7 +169,7 @@ Fail-closed rule: **no open session, or expired session → `posture set` / `tra - `test_unique_session_id` — two `open_session` calls produce distinct `session_id`. - `test_second_enable_replaces_first` — a second `operator enable` **replaces** the session file atomically (only one active session at a time). This resolves the concurrent-session ambiguity: there is exactly one authoritative `operator_session.json`. **(addresses Quality critical: concurrent-session race)** - **Implementation:** - - `open_session(...)` writes the JSON atomically via the local `_atomic_write_json`. Generates `session_id = secrets.token_hex(...)`. A second `open_session` overwrites the prior file (single active session). + - `open_session(...)` writes the JSON atomically via the local `_atomic_write_json`. Generates `session_id = secrets.token_hex(...)`, signs the session metadata with the operator signer, and stores that HMAC as `session_sig`. A second `open_session` overwrites the prior file (single active session). - `load_session() -> Session | None`: reads file; if `now > expires_at` → delete (catching `FileNotFoundError`), return `None`. - `end_session()` deletes file (idempotent). - `unlock_ref` per D5: keychain → item id; age-file → `None`; env → `None`. @@ -296,7 +297,7 @@ Fail-closed/idempotent: **second install over an existing ledger leaves floor + - `test_operator_disable_ends_session` — deletes the session file. - `test_enable_default_ttl_5m` — no `--ttl` → 300s. - `test_ci_env_backend_opens_session_with_id` — with `LEGIS_OPERATOR_KEY` set, no keychain, `legis operator enable --insecure-key-in-env`: emits the plaintext warning, writes a session file with `backend_id="env"`, and a subsequent `posture set` produces a `TRANSITION` carrying a **non-null `session_id`** (env path still goes through a session, per D3). **(addresses systems high: CI bootstrap + session accountability)** -- **Implementation:** `operator` subparser with `enable [--ttl] [--insecure-key-in-env]`, `disable`. `_run_operator`: `enable` → keychain/age unlock (or env opt-in) → `open_session(...)` + `ledger.session_opened(...)`. `disable` → `end_session()`. **CI bootstrap sequence (documented in the CLI help and `docs/`):** set `LEGIS_OPERATOR_KEY`, run `legis operator enable --insecure-key-in-env`, then `legis posture set `. The env path NEVER signs without an open session — there is no second auth path that bypasses session accountability. +- **Implementation:** `operator` subparser with `enable [--ttl] [--insecure-key-in-env]`, `disable`. `_run_operator`: `enable` → keychain/age unlock (or env opt-in) → `open_session(..., signer=signer)` + `ledger.session_opened(...)`. `disable` → `end_session()`. **CI bootstrap sequence (documented in the CLI help and `docs/`):** set `LEGIS_OPERATOR_KEY`, run `legis operator enable --insecure-key-in-env`, then `legis posture set `. The env path NEVER signs without an open session — there is no second auth path that bypasses session accountability. - **Verify:** `pytest tests/cli/test_operator_cli.py -q`. --- @@ -426,19 +427,19 @@ Fail-closed: **`doctor` exits non-zero on an unacknowledged `KEY_RESET`** (spec ## PHASE 11 — Rekey / lost-key path -Fail-closed/loud: **rekey resets to chill, needs no old key, preserves history, writes `KEY_RESET`, doctor flags it** (spec §8). +Fail-closed/loud: **rekey preserves the standing floor, needs no old key, preserves history, writes `KEY_RESET`, doctor flags it** (spec §8). ### Task 11.1 — `posture rekey` - **Modify:** `src/legis/posture/ledger.py` (`rekey()`), `src/legis/cli.py` (`posture rekey`). - **Test first:** `tests/posture/test_rekey.py`: - - `test_rekey_resets_to_chill` — `read_floor()` == `"chill"` after rekey. + - `test_rekey_preserves_existing_floor` — `read_floor()` remains at the standing floor after rekey. - `test_rekey_mints_new_epoch` — new `key_fingerprint` != prior; new key handed to backend. - `test_rekey_preserves_history` — all prior records present; `verify_integrity()` True; `KEY_RESET` chained onto existing history (not a fresh DB). - `test_rekey_needs_no_old_key` — succeeds with no open session / no prior key available. - - `test_rekey_writes_key_reset_record` — exactly one `KEY_RESET` with `kind=KEY_RESET, floor=chill, key_fingerprint=, agent_id, recorded_at`. + - `test_rekey_writes_key_reset_record` — exactly one `KEY_RESET` with `kind=KEY_RESET, floor=, key_fingerprint=, agent_id, recorded_at`. - `test_doctor_flags_rekey` — after rekey, `legis doctor` exits non-zero until an acknowledging signed transition verifying against the new epoch (ties to 10.2). -- **Implementation:** `rekey(*, agent_id, recorded_at)`: `mint_key()` → backend; compute new fingerprint; `store.append(PostureRecord(kind=KEY_RESET, floor="chill", key_fingerprint=new_fp, ...).to_payload())` (keyless, chained onto existing chain — `append`, not `append_signed`). CLI `_run_posture` dispatches `rekey`. +- **Implementation:** `rekey(*, agent_id, recorded_at)`: read the standing floor (missing/empty ledger -> `structured`), `mint_key()` → backend; compute new fingerprint; `store.append(PostureRecord(kind=KEY_RESET, floor=, key_fingerprint=new_fp, ...).to_payload())` (keyless, chained onto existing chain — `append`, not `append_signed`). CLI `_run_posture` dispatches `rekey`. - **Verify:** `pytest tests/posture/test_rekey.py -q`. --- @@ -449,7 +450,7 @@ Create `tests/posture/test_security_honesty.py` asserting the spec's honesty gua - **`test_tty_session_expiry`** — past TTL, `load_session()` returns `None` and deletes the file; a `posture set` after expiry is refused. - **`test_key_never_returned_to_caller`** — no backend exposes raw key bytes; `sign()` returns only a prefixed signature; `fingerprint()` returns a hash. Behavioral (per Quality medium): assert the returned signature does not contain the key hex, and no public method/attr value equals the key. -- **`test_rekey_resets_to_chill`** — (cross-ref Phase 11) rekey can never land above chill. +- **`test_rekey_preserves_existing_floor`** — (cross-ref Phase 11) rekey cannot downgrade an elevated floor. - **`test_every_signature_carries_session_id`** — every `TRANSITION` in a window has `session_id` == the open session's id; a no-session transition is refused. Includes the **env-backend path** (D3): an `EnvSigner` transition still carries `session_id`. - **`test_env_escape_hatch_warns`** — `EnvSigner` requires explicit `--insecure-key-in-env` and emits an honest warning. - **`test_age_file_passphrase_required`** — age-file unlock with wrong/absent passphrase fails closed (no signature). @@ -460,7 +461,7 @@ Create `tests/posture/test_security_honesty.py` asserting the spec's honesty gua ### Task 12.1 — Published honesty-statement update **(NEW — addresses systems low)** - **Modify:** `README.md` "Known security limitations" (and align spec §9). -- **Implementation:** add the operator-session-file residual to the published honesty statement: *"A process with read access to `.weft/legis/operator_session.json` can read the keychain item id and, if it also has keychain access, produce arbitrary signatures during the window. This is the same tier as raw-DB-write access. The mitigation is OS keychain access control (item accessible only to the legis process user), not file encryption of the session file."* Consistent with the existing tamper-evident-not-tamper-proof stance. +- **Implementation:** add the operator-session-file residual to the published honesty statement: *"A process with read access to `.weft/legis/operator_session.json` can read the keychain item id and session HMAC; if it also has keychain access, it can produce arbitrary signatures during the window. This is the same tier as raw-DB-write access. The mitigation is OS keychain access control (item accessible only to the legis process user), not file encryption of the session file."* Consistent with the existing tamper-evident-not-tamper-proof stance. - **Verify:** manual doc read; no test (documentation honesty item). --- @@ -549,4 +550,4 @@ The original questions, for context: 5. **`FlooredRegistry` as a `PolicyCellRegistry` subclass (D1).** This is the cleanest fix for the explain/list honesty gap, but it couples `FlooredRegistry` to `PolicyCellRegistry.__init__`. If `PolicyCellRegistry`'s constructor is awkward to subclass, the fallback is a composition wrapper that re-implements `cell_for`/`default_cell`/`rule_for`. Confirm the subclass approach, or pre-approve the wrapper fallback so implementation isn't blocked mid-phase. -6. **Env-backend session semantics on CI (D3).** The plan requires `legis operator enable --insecure-key-in-env` before any `posture set` in CI, so every signature carries a `session_id`. This adds one bootstrap command to CI pipelines that move the floor. Confirm this is the desired CI ergonomics, or approve a one-shot `legis posture set --insecure-key-in-env` that opens an ephemeral synthetic session implicitly. \ No newline at end of file +6. **Env-backend session semantics on CI (D3).** The plan requires `legis operator enable --insecure-key-in-env` before any `posture set` in CI, so every signature carries a `session_id`. This adds one bootstrap command to CI pipelines that move the floor. Confirm this is the desired CI ergonomics, or approve a one-shot `legis posture set --insecure-key-in-env` that opens an ephemeral synthetic session implicitly. diff --git a/pyproject.toml b/pyproject.toml index 4c8796a..4fdf684 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "legis" -version = "1.1.0" +version = "1.1.1" description = "Legis — the git/CI + governance layer of the Weft suite" readme = "README.md" license = "MIT" diff --git a/site/scripts/fetch-site-kit.mjs b/site/scripts/fetch-site-kit.mjs index 70f9989..e5ee952 100644 --- a/site/scripts/fetch-site-kit.mjs +++ b/site/scripts/fetch-site-kit.mjs @@ -11,8 +11,8 @@ // before install. // // Local-dev fallback: if the network clone fails but a sibling weft checkout is -// present next to this repo, vendor from there so an offline `npm install`/build -// still works. CI always has the network and uses the clone path. +// present next to this repo at the same pinned commit, vendor from there so an +// offline `npm install`/build still works. CI always uses the pinned clone path. import { cp, rm, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; @@ -26,27 +26,49 @@ const dest = join(siteRoot, 'vendor', 'site-kit'); const REPO = 'https://github.com/foundryside-dev/weft.git'; const SUBDIR = 'packages/site-kit'; +const WEFT_SITE_KIT_COMMIT = 'a8f9a6a77458d2ec697cfbc1f71dd88a51962cb7'; const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +const capture = (cmd, args, opts = {}) => + execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim(); + async function vendorFrom(srcKit) { await rm(dest, { recursive: true, force: true }); await mkdir(dirname(dest), { recursive: true }); await cp(srcKit, dest, { recursive: true }); } +function verifyPinnedCheckout(repoRoot, label) { + const actualCommit = capture('git', ['-C', repoRoot, 'rev-parse', 'HEAD']); + if (actualCommit !== WEFT_SITE_KIT_COMMIT) { + throw new Error( + `${label} is at ${actualCommit}, expected pinned commit ${WEFT_SITE_KIT_COMMIT}`, + ); + } + const dirty = capture('git', ['-C', repoRoot, 'status', '--porcelain', '--', SUBDIR]); + if (dirty) { + throw new Error(`${label} has uncommitted changes under ${SUBDIR}`); + } +} + async function fetchViaClone() { const tmp = join(tmpdir(), `weft-site-kit-${process.pid}-${Date.now()}`); try { - run('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', REPO, tmp]); + run('git', ['clone', '--filter=blob:none', '--sparse', '--no-checkout', REPO, tmp]); run('git', ['-C', tmp, 'sparse-checkout', 'set', SUBDIR]); + run('git', ['-C', tmp, 'fetch', '--depth', '1', 'origin', WEFT_SITE_KIT_COMMIT]); + run('git', ['-C', tmp, 'checkout', '--detach', 'FETCH_HEAD']); + verifyPinnedCheckout(tmp, 'cloned weft checkout'); const srcKit = join(tmp, SUBDIR); if (!existsSync(srcKit)) { throw new Error(`sparse checkout did not produce ${SUBDIR}`); } await vendorFrom(srcKit); - console.log(`[fetch-site-kit] sparse-fetched ${SUBDIR} from ${REPO} -> ${dest}`); + console.log( + `[fetch-site-kit] sparse-fetched ${SUBDIR} from ${REPO}@${WEFT_SITE_KIT_COMMIT} -> ${dest}`, + ); return true; } finally { await rm(tmp, { recursive: true, force: true }); @@ -61,6 +83,7 @@ async function fetchViaSibling() { ]; const srcKit = candidates.find((p) => existsSync(p)); if (!srcKit) return false; + verifyPinnedCheckout(join(srcKit, '..', '..'), 'sibling weft checkout'); await vendorFrom(srcKit); console.log(`[fetch-site-kit] (offline fallback) vendored from sibling checkout ${srcKit} -> ${dest}`); return true; @@ -69,12 +92,17 @@ async function fetchViaSibling() { try { await fetchViaClone(); } catch (err) { + if (process.env.CI === 'true') { + console.error(`[fetch-site-kit] pinned clone failed in CI: ${err.message}`); + process.exit(1); + } console.warn(`[fetch-site-kit] network clone failed (${err.message}); trying a local sibling weft checkout…`); const ok = await fetchViaSibling(); if (!ok) { console.error( - '[fetch-site-kit] could not fetch @weft/site-kit: the git clone failed and no sibling ' + - 'weft checkout was found. Provide network access (CI path) or a ../weft checkout.', + '[fetch-site-kit] could not fetch @weft/site-kit: the pinned git clone failed and no clean sibling ' + + `weft checkout at ${WEFT_SITE_KIT_COMMIT} was found. Provide network access (CI path) ` + + 'or a matching ../weft checkout.', ); process.exit(1); } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 950ac64..1e7fa62 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -127,7 +127,7 @@ const CAPABILITIES = [ -

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

+

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

@@ -277,7 +277,7 @@ const CAPABILITIES = [

Status & honest limits

What it is, and what it is not.

- Legis is at v1.0.0 — the gold release; all four 2×2 cells work end-to-end. It is a + Legis is at v1.1.1 — the gold release line; all four 2×2 cells work end-to-end. It is a governance-honesty tool, so it states its own residual limits in the open rather than leaving them in source comments.

diff --git a/src/legis/__init__.py b/src/legis/__init__.py index f9f75c1..759daa4 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.0" +__version__ = "1.1.1" diff --git a/src/legis/api/app.py b/src/legis/api/app.py index 4f91a87..f739f35 100644 --- a/src/legis/api/app.py +++ b/src/legis/api/app.py @@ -222,8 +222,9 @@ class OverrideIn(BaseModel): rationale: str agent_id: str | None = None # weft SEI-on-entry (L1): an SEI the agent already holds, bound at the point of - # entry. When set, legis verifies it is alive and keys the record on it; a - # non-resolving value is rejected (422 unresolved_input) and records nothing. + # entry. When set, legis verifies it is alive and matches the submitted entity + # before keying the record on it; a non-resolving or unbound value is rejected + # (422 unresolved_input) and records nothing. entity_sei: str | None = None # Protected-cell inputs (Phase 9 unification): the source/AST binding the # protected gate requires. Optional on the unified body — when the floored @@ -392,7 +393,7 @@ def create_app( # reconciliation the ledger handle is opened ``initialize=False`` so creating # the app never writes posture.db — genesis is an install-time action and a # bare ``create_app`` must not create local state (audit H6). A missing/empty - # ledger reads ``None`` -> the registry's own (fail-closed) default stands. + # ledger reads ``None`` -> the fail-closed structured floor. if cell_registry is None: from legis.mcp import _load_policy_cell_registry @@ -400,9 +401,11 @@ def create_app( if posture_ledger is None: posture_ledger = PostureLedger(posture_db_url(), initialize=False) + injected_enforcement = enforcement is not None state: dict[str, Any] = { "checks": check_surface, "enforcement": enforcement, + "coached_enforcement": enforcement if enforcement is not None and enforcement.has_judge else None, "grammar": grammar, "pulls": pull_surface, "cell_registry": cell_registry, @@ -445,6 +448,26 @@ def engine() -> EnforcementEngine: ) return state["enforcement"] + def coached_engine() -> EnforcementEngine | None: + if state["coached_enforcement"] is not None: + return state["coached_enforcement"] + if injected_enforcement: + return None + from legis.clock import SystemClock + from legis.enforcement.judge_factory import configured_judge_from_env + from legis.store.audit_store import AuditStore + + judge = configured_judge_from_env("API") + if judge is None: + return None + state["coached_enforcement"] = EnforcementEngine( + AuditStore(governance_db_url()), SystemClock(), judge + ) + return state["coached_enforcement"] + + def simple_engine_for(cell: str) -> EnforcementEngine | None: + return coached_engine() if cell == "coached" else engine() + def grammar_() -> PolicyGrammar: if state["grammar"] is None: state["grammar"] = default_grammar() @@ -567,9 +590,24 @@ def post_override(body: OverrideIn, response: Response, actor: str = Depends(ver recorded_actor = _recorded_actor(actor, body.agent_id) if cell in ("chill", "coached"): + simple_engine = simple_engine_for(cell) + explanation = _explain_policy( + registry, + policy=body.policy, + entity=body.entity, + engine=simple_engine, + protected_gate=protected_gate, + signoff_gate=signoff_gate, + ) + if not explanation.enabled: + raise HTTPException( + status_code=404, + detail=f"cell {explanation.cell!r} is not enabled for override submission", + ) + assert simple_engine is not None try: result = _submit_override( - engine(), + simple_engine, identity=identity, policy=body.policy, entity=body.entity, diff --git a/src/legis/cli.py b/src/legis/cli.py index e29854b..cebbc82 100644 --- a/src/legis/cli.py +++ b/src/legis/cli.py @@ -214,9 +214,10 @@ def build_parser() -> argparse.ArgumentParser: prekey = posture_sub.add_parser( "rekey", help=( - "Lost-key recovery: mint a new operator key epoch, reset the floor " - "to chill, and chain a loud KEY_RESET (doctor stays non-zero until " - "you re-raise the floor with a signed `posture set` under the new key)" + "Lost-key recovery: mint a new operator key epoch while preserving " + "the standing floor, and chain a loud KEY_RESET (doctor stays " + "non-zero until you acknowledge the reset with a signed " + "`posture set` under the new key)" ), ) prekey.add_argument( @@ -453,16 +454,24 @@ def _run_posture(args) -> int: return 0 if command == "rekey": - # Lost-key recovery (Phase 11 / design §8): mint a fresh key epoch, reset - # the floor to chill, and chain a loud KEY_RESET. Needs NO open session - # and NO old key — a lost key cannot sign, so the indelible, doctor-flagged - # record IS the accountability. The new key bytes reach ONLY custody via - # the install key-sink; the ledger stores the fingerprint alone. - from legis.install import _default_key_sink, choose_install_backend + # Lost-key recovery (Phase 11 / design §8): mint a fresh key epoch while + # preserving the standing floor, and chain a loud KEY_RESET. Needs NO + # open session and NO old key — a lost key cannot sign, so the indelible, + # doctor-flagged record IS the accountability. The new key bytes reach + # ONLY custody via the install key-sink; the ledger stores the fingerprint + # alone. + from legis.install import OperatorKeyCustodyError, _default_key_sink, choose_install_backend backend = args.backend if backend is None: backend = choose_install_backend(insecure_env=False) + if backend == "env": + print( + "posture rekey: refused — env custody cannot persist the freshly " + "minted rekey key from this child process; use age-file or keychain.", + file=sys.stderr, + ) + return 1 ledger = PostureLedger(posture_db_url(), initialize=True) try: new_fp = ledger.rekey( @@ -471,13 +480,14 @@ def _run_posture(args) -> int: key_sink=_default_key_sink, backend=backend, ) - except Exception as exc: # noqa: BLE001 — custody gap is a fail-closed refusal + except (OperatorKeyCustodyError, RuntimeError, ValueError) as exc: print(f"posture rekey: refused — {exc}", file=sys.stderr) return 1 print( f"posture rekey: new key epoch {new_fp[:12]}… minted (backend={backend}); " - f"floor reset to chill. Re-raise it with a signed `legis posture set` " - f"under the new key — `legis doctor` stays non-zero until you do." + f"floor preserved as {ledger.read_floor() or 'structured'}. Acknowledge " + f"the reset with a signed `legis posture set` under the new key — " + f"`legis doctor` stays non-zero until you do." ) return 0 @@ -493,6 +503,7 @@ def _run_operator(args) -> int: PostureLedger, end_session, open_session, + persist_session, ) command = getattr(args, "operator_command", None) @@ -532,6 +543,28 @@ def _run_operator(args) -> int: print(f"operator enable: refused — {exc}", file=sys.stderr) return 1 + ledger = PostureLedger(posture_db_url(), initialize=False) + epoch_fp = ledger.current_epoch_fingerprint() + if epoch_fp is None: + print( + "operator enable: refused — no posture key epoch exists yet; run " + "`legis install --posture` after configuring key custody.", + file=sys.stderr, + ) + return 1 + try: + signer_fp = signer.fingerprint() + except Exception as exc: # noqa: BLE001 — custody gap is a fail-closed refusal + print(f"operator enable: refused — cannot read signer fingerprint: {exc}", file=sys.stderr) + return 1 + if signer_fp != epoch_fp: + print( + "operator enable: refused — signer fingerprint does not match the " + "current posture key epoch.", + file=sys.stderr, + ) + return 1 + # The keychain item id is the only non-null unlock_ref (D5); env/age-file # carry None (re-prompt / resident plaintext is the unlock). unlock_ref = getattr(signer, "_item_id", None) @@ -542,11 +575,14 @@ def _run_operator(args) -> int: operator_id=operator_id, backend_id=backend_id, unlock_ref=unlock_ref, + signer=signer, + persist=False, ) # The env path STILL opens a session (D3): every TRANSITION it later # produces carries this session_id, so there is no auth path that - # bypasses session accountability. - ledger = PostureLedger(posture_db_url(), initialize=False) + # bypasses session accountability. Append the audit event before writing + # the session file; a ledger failure must not leave a usable unaudited + # elevation window behind. ledger.session_opened( operator_id=operator_id, enabled_at=clock.now_iso(), @@ -554,6 +590,7 @@ def _run_operator(args) -> int: keychain_auth_ref=unlock_ref, session_id=session.session_id, ) + persist_session(session) if insecure_env: print( "WARNING: --insecure-key-in-env uses the plaintext LEGIS_OPERATOR_KEY " @@ -602,11 +639,11 @@ def _do_posture() -> tuple[bool, str]: try: fp = install_posture(project_root, backend=backend) except OperatorKeyCustodyError as exc: - # Fail-closed but non-fatal to the broader install: NO genesis was - # written (the sink runs before the append), so the ledger never - # carries a fingerprint the operator cannot sign against. Tell the - # operator how to complete custody and re-run --posture. - return True, ( + # NO genesis was written (the sink runs before the append), so the + # ledger never carries a fingerprint the operator cannot sign + # against. A broad install may defer posture setup, but an explicit + # posture install is a readiness check and must fail closed. + return not args.posture, ( f"deferred: {exc} " f"(re-run `legis install --posture` once custody is configured)" ) diff --git a/src/legis/config.py b/src/legis/config.py index 8d948dc..53a4228 100644 --- a/src/legis/config.py +++ b/src/legis/config.py @@ -34,7 +34,7 @@ operator-authority key is *minted at install* and held by a custody backend (OS keychain / age-encrypted file / env escape hatch). Two new in-scope paths appear under this subtree as a result — ``operator_session.json`` (ephemeral -elevation-session metadata + an unlock *reference*, never the key) and +elevation-session metadata + an unlock *reference* + session HMAC, never the key) and ``operator.age`` (the age-file backend's *encrypted* blob). Both are gitignored at install. The key plaintext itself is still never written to disk by legis except via the explicit ``--insecure-key-in-env`` escape hatch. @@ -87,13 +87,14 @@ def project_root() -> Path: return Path.cwd() -def _store_dir() -> Path: +def _store_dir(root: Path | None = None) -> Path: """The built-in runtime-state subtree. Repo-local ``weft.toml`` is intentionally ignored here. Load-bearing store relocation must come from explicit ``LEGIS_*_DB`` operator env vars. """ - return Path(".weft") / WEFT_MEMBER + base = Path(".") if root is None else Path(root) + return base / ".weft" / WEFT_MEMBER def _sqlite_url(path: Path) -> str: @@ -147,24 +148,24 @@ def posture_db_url() -> str: return _resolve_db_url(_POSTURE_DB_ENV, _POSTURE_DB_NAME) -def operator_session_path() -> Path: +def operator_session_path(root: Path | None = None) -> Path: """The ephemeral elevation-session metadata file (design §6). Holds only session/window metadata + a backend-specific unlock reference — never key plaintext, never a passphrase. Created by ``legis operator enable``, deleted on TTL lapse or ``disable``. Gitignored at install. """ - return _store_dir() / "operator_session.json" + return _store_dir(root) / "operator_session.json" -def operator_age_path() -> Path: +def operator_age_path(root: Path | None = None) -> Path: """The age-encrypted operator-key blob for the age-file custody backend. Project-rooted under ``.weft/legis/`` (the federation convention), NOT a home-config path. Encrypted at rest (scrypt + AES-GCM); gitignored at install. Only the age-file backend uses it. """ - return _store_dir() / "operator.age" + return _store_dir(root) / "operator.age" def protected_policies() -> frozenset[str]: diff --git a/src/legis/doctor.py b/src/legis/doctor.py index d8e6766..e3b16cc 100644 --- a/src/legis/doctor.py +++ b/src/legis/doctor.py @@ -524,17 +524,17 @@ def _posture_db_path(url: str) -> Path | None: def check_posture_chain(root: Path) -> DoctorCheck: """Report-only hash-chain integrity for the posture ledger (Task 10.1). - A missing or zero-byte store is ``ok`` ("no ledger yet") — the floor simply - defers to the registry default (fail-closed ``structured``); doctor must NOT - create the DB. A schema-present-but-tampered chain is ``error`` (report-only; - never auto-repaired). Mirrors :func:`check_audit_chain`'s no-leak posture but + A missing or zero-byte store is ``ok`` ("no ledger yet") and the runtime + floor fails closed to ``structured``; doctor must NOT create the DB. A + schema-present-but-tampered chain is ``error`` (report-only; never + auto-repaired). Mirrors :func:`check_audit_chain`'s no-leak posture but special-cases the missing store before the zero-byte schema check so an un-installed project reads as ``ok``, not ``error``.""" cid = "store.posture_chain" url = _posture_url(root) db = _posture_db_path(url) if db is not None and (not db.exists() or db.stat().st_size == 0): - return DoctorCheck(cid, "ok", message="no ledger yet (floor defers to registry default)") + return DoctorCheck(cid, "ok", message="no ledger yet (floor fail-closed structured)") return check_audit_chain(cid, url) @@ -568,12 +568,12 @@ def check_posture_ledger(root: Path) -> DoctorCheck: return DoctorCheck(cid, "ok", message=f"floor: {floor}") -def _operator_key_provider(fingerprint: str) -> str | None: +def _operator_key_provider(fingerprint: str, *, root: Path | None = None) -> str | None: """Default key provider: produce the hex key for *fingerprint* if a backend - can without revealing it elsewhere. Today only the env escape hatch is - probeable from the doctor process; keychain/age unlocks are operator-side - (re-prompt) and report as unreachable here. Returns the key hex ONLY for - internal verification — never rendered in any message.""" + can without revealing it elsewhere. The env escape hatch is probeable when + explicitly set; the age-file backend is probeable when the operator supplies + ``LEGIS_OPERATOR_KEY_AGE_PASSPHRASE`` for this doctor run. Returns the key + hex ONLY for internal verification — never rendered in any message.""" env_key = os.environ.get(_OPERATOR_KEY_ENV) if env_key: try: @@ -582,7 +582,20 @@ def _operator_key_provider(fingerprint: str) -> str | None: if key_fingerprint(env_key) == fingerprint: return env_key except Exception: # noqa: BLE001 — malformed env key is just unreachable - return None + pass + age_passphrase = os.environ.get("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE") + if age_passphrase: + try: + from legis.config import operator_age_path + from legis.posture.signing import key_fingerprint, unwrap_key + + age_path = operator_age_path(root) + if age_path.exists(): + key_hex = unwrap_key(age_path.read_bytes(), age_passphrase) + if key_fingerprint(key_hex) == fingerprint: + return key_hex + except Exception: # noqa: BLE001 — wrong passphrase/blob is unreachable + pass return None @@ -627,18 +640,20 @@ def _transition_acknowledges(rec: Any, *, new_fp: str, key_provider: Any) -> boo def check_posture_key_reset(root: Path, *, key_provider: Any = None) -> DoctorCheck: """Non-zero exit on an unacknowledged ``KEY_RESET`` (Task 10.2, D6). - A ``rekey`` resets the floor to chill and chains a ``KEY_RESET`` carrying a - fresh epoch fingerprint — loud and indelible (design §8). Until an operator - re-raises the floor with a ``TRANSITION`` whose ``operator_sig`` *verifies* - against the new epoch key, the reset is unacknowledged and doctor fails CI - (``error`` / ``run_doctor`` returns non-zero). Per D6, a later TRANSITION of - the right kind is NOT enough — record-kind presence is replayable; the - signature must verify under the new epoch. Missing/empty ledger → ``ok``. - Never renders key material (the fingerprint and the verification result are - the only signals).""" + A ``rekey`` preserves the standing floor and chains a ``KEY_RESET`` carrying + a fresh epoch fingerprint — loud and indelible (design §8). Until an operator + acknowledges the reset with a ``TRANSITION`` whose ``operator_sig`` + *verifies* against the new epoch key, the reset is unacknowledged and doctor + fails CI (``error`` / ``run_doctor`` returns non-zero). Per D6, a later + TRANSITION of the right kind is NOT enough — record-kind presence is + replayable; the signature must verify under the new epoch. Missing/empty + ledger → ``ok``. Never renders key material (the fingerprint and the + verification result are the only signals).""" cid = "store.posture_key_reset" if key_provider is None: - key_provider = _operator_key_provider + def key_provider(fingerprint: str) -> str | None: + return _operator_key_provider(fingerprint, root=root) + url = _posture_url(root) db = _posture_db_path(url) if db is not None and (not db.exists() or db.stat().st_size == 0): @@ -668,13 +683,14 @@ def check_posture_key_reset(root: Path, *, key_provider: Any = None) -> DoctorCh return DoctorCheck(cid, "ok", message="key epoch reset acknowledged by a signed transition") agent = reset.payload.get("agent_id") or "unknown" when = reset.payload.get("recorded_at") or "unknown" + floor = reset.payload.get("floor") or "structured" return DoctorCheck( cid, "error", message=( f"posture key epoch reset on {when} by {agent} — unacknowledged. The floor " - "is reset to chill; re-raise it with a signed `legis posture set` under the " - "new key (doctor stays non-zero until then). [operator]" + f"remains {floor}; acknowledge the reset with a signed `legis posture set` " + "under the new key (doctor stays non-zero until then). [operator]" ), repairable=False, ) @@ -691,7 +707,9 @@ def check_operator_key_accessible(root: Path, *, key_provider: Any = None) -> Do key. Missing/empty ledger → ``ok`` (nothing to reach yet).""" cid = "runtime.operator_key" if key_provider is None: - key_provider = _operator_key_provider + def key_provider(fingerprint: str) -> str | None: + return _operator_key_provider(fingerprint, root=root) + url = _posture_url(root) db = _posture_db_path(url) if db is not None and (not db.exists() or db.stat().st_size == 0): @@ -705,7 +723,16 @@ def check_operator_key_accessible(root: Path, *, key_provider: Any = None) -> Do return DoctorCheck(cid, "error", message=f"cannot read posture ledger: {exc}") if epoch_fp is None: return DoctorCheck(cid, "ok", message="no key epoch yet") - if os.environ.get(_OPERATOR_KEY_ENV): + env_key = os.environ.get(_OPERATOR_KEY_ENV) + env_matches_epoch = False + if env_key: + try: + from legis.posture.signing import key_fingerprint + + env_matches_epoch = key_fingerprint(env_key) == epoch_fp + except Exception: # noqa: BLE001 — malformed env key is a mismatch here + env_matches_epoch = False + if env_matches_epoch: return DoctorCheck( cid, "warn", @@ -715,13 +742,33 @@ def check_operator_key_accessible(root: Path, *, key_provider: Any = None) -> Do ), ) if key_provider(epoch_fp) is not None: + if env_key: + return DoctorCheck( + cid, + "warn", + message=( + "LEGIS_OPERATOR_KEY is present but does not match the current key epoch; " + "another custody backend is reachable. [operator]" + ), + ) return DoctorCheck(cid, "ok", message="operator key reachable") + if env_key: + return DoctorCheck( + cid, + "warn", + message=( + "LEGIS_OPERATOR_KEY is present but does not match the current key epoch — " + "`posture set` will refuse until a matching custody backend is available. " + "[operator]" + ), + ) return DoctorCheck( cid, "warn", message=( "operator key not reachable in any backend — `posture set` will refuse; " - "`legis posture rekey` to recover (resets to chill, mints a new epoch). [operator]" + "`legis posture rekey` to recover (mints a new epoch and preserves the " + "current floor). [operator]" ), ) diff --git a/src/legis/enforcement/judge_factory.py b/src/legis/enforcement/judge_factory.py index b9136b6..eeab6e0 100644 --- a/src/legis/enforcement/judge_factory.py +++ b/src/legis/enforcement/judge_factory.py @@ -24,8 +24,12 @@ def evaluate(self, record: OverrideRecord) -> JudgeOpinion: ) -def build_judge_from_env(surface: str, *, fetch: Fetch | None = None) -> LLMJudge | FailClosedJudge: +def configured_judge_from_env(surface: str, *, fetch: Fetch | None = None) -> LLMJudge | None: cfg = llm_client_config_from_env() if cfg is None: - return FailClosedJudge(surface) + return None return LLMJudge(OpenRouterLLMClient(cfg, fetch=fetch)) + + +def build_judge_from_env(surface: str, *, fetch: Fetch | None = None) -> LLMJudge | FailClosedJudge: + return configured_judge_from_env(surface, fetch=fetch) or FailClosedJudge(surface) diff --git a/src/legis/install.py b/src/legis/install.py index fddb5f3..31d8377 100644 --- a/src/legis/install.py +++ b/src/legis/install.py @@ -910,7 +910,7 @@ def mcp_entry_is_current(project_root: Path) -> bool: return False if not _mcp_args_are_current(entry.get("args")): return False - if not _mcp_command_resolves_safely(entry.get("command"), project_root): + if not _mcp_command_resolves_safely(entry.get("command"), project_root, entry.get("args")): return False env = _safe_mcp_env(entry.get("env")) return env is not None and env == entry.get("env", {}) @@ -986,36 +986,82 @@ def ensure_gitignore(project_root: Path) -> tuple[bool, str]: _REJECTED_MCP_ENV_KEYS = _UNSAFE_MCP_ENV_KEYS | _SECRET_MCP_ENV_KEYS -def _mcp_args_are_current(args: Any) -> bool: +def _mcp_args_invocation_kind(args: Any) -> str | None: if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): - return False + return None if args[:1] == ["mcp"]: + kind = "legis" tail = args - elif args[:2] == ["-m", "legis"]: - tail = args[2:] elif args[:3] == ["-P", "-m", "legis"]: + kind = "python-module" tail = args[3:] else: - return False + return None if tail[:1] != ["mcp"]: - return False + return None try: agent_idx = tail.index("--agent-id") except ValueError: - return False - return agent_idx + 1 < len(tail) and bool(tail[agent_idx + 1]) + return None + if not (agent_idx + 1 < len(tail) and bool(tail[agent_idx + 1])): + return None + return kind + + +def _mcp_args_are_current(args: Any) -> bool: + return _mcp_args_invocation_kind(args) is not None + + +def _command_basename(command: str, resolved: str) -> str: + # Prefer the operator-authored command token when it names a path; otherwise + # use the PATH resolution. This keeps bare "python3.12" / "legis" forms + # honest without trusting a misleading absolute symlink target name. + token = command if ("/" in command or "\\" in command) else resolved + return Path(token).name.lower() -def _mcp_command_resolves_safely(command: Any, project_root: Path) -> bool: +def _is_legis_executable(command: str, resolved: str) -> bool: + return _command_basename(command, resolved) in {"legis", "legis.exe"} + + +def _is_python_executable(command: str, resolved: str) -> bool: + base = _command_basename(command, resolved) + if base == "py.exe": + return True + if base.endswith(".exe"): + base = base[:-4] + return base == "python" or re.fullmatch(r"python3(?:\.\d+)*", base) is not None + + +def _mcp_command_resolves_safely( + command: Any, + project_root: Path, + args: Any | None = None, +) -> bool: if not isinstance(command, str) or not command: return False if _path_head_is_project_local(command, project_root): return False resolved = shutil.which(command) if resolved is not None: - return not _path_head_is_project_local(resolved, project_root) - path = Path(command) - return path.is_absolute() and path.is_file() + if _path_head_is_project_local(resolved, project_root): + return False + resolved_path = resolved + else: + path = Path(command) + if not (path.is_absolute() and path.is_file()): + return False + resolved_path = str(path) + if not os.access(resolved_path, os.X_OK): + return False + kind = _mcp_args_invocation_kind(args) if args is not None else None + if kind == "legis": + return _is_legis_executable(command, resolved_path) + if kind == "python-module": + return _is_python_executable(command, resolved_path) + if args is not None: + return False + return True def _safe_mcp_env(env: Any) -> dict[str, str] | None: @@ -1111,7 +1157,11 @@ def register_mcp_json( usable = ( existing.get("type") == "stdio" and _mcp_args_are_current(existing.get("args")) - and _mcp_command_resolves_safely(existing.get("command"), project_root) + and _mcp_command_resolves_safely( + existing.get("command"), + project_root, + existing.get("args"), + ) and _safe_mcp_env(existing.get("env")) == existing.get("env", {}) ) @@ -1261,9 +1311,11 @@ def install_posture( url = posture_db_url_for_install() ledger = PostureLedger(url, initialize=True) - # Idempotency guard (spec §5): an existing GENESIS or KEY_RESET tail means - # the epoch is already established — re-mint nothing, append nothing. - if ledger.store.get_latest_sequence_and_hash()[0] != 0: + # Idempotency guard (spec §5): an existing GENESIS or KEY_RESET means the + # epoch is already established — re-mint nothing, append nothing. A + # metadata-only ledger has no epoch and remains recoverable: append GENESIS + # below rather than treating OPERATOR_SESSION_OPENED as install completion. + if ledger.current_epoch_fingerprint() is not None: return ledger.current_epoch_fingerprint() # The env escape hatch SIGNS with LEGIS_OPERATOR_KEY (EnvSigner), so GENESIS diff --git a/src/legis/mcp.py b/src/legis/mcp.py index 09e8434..292007b 100644 --- a/src/legis/mcp.py +++ b/src/legis/mcp.py @@ -175,6 +175,7 @@ class McpRuntime: # site via _floored_registry. None on a runtime built without posture wiring, # which _floored_registry treats fail-closed as a missing ledger (structured). posture_ledger: Any | None = None + coached_engine: EnforcementEngine | None = None def _load_policy_cell_registry() -> PolicyCellRegistry: @@ -605,12 +606,14 @@ def tool_definitions() -> list[dict[str, Any]]: "a locator/symbol for legis to resolve (L2, degrades to a " "locator key if Loomweave can't resolve it), OR pass entity_sei " "to bind a SEI you already hold at the point of entry (L1) — " - "legis verifies it is alive and keys the governance record " - "directly on it. A non-resolving entity_sei returns " - "UNRESOLVED_INPUT (weft-reason unresolved_input) and records " - "NOTHING, never a locator-keyed record masquerading as a stable " - "bind. entity is still required (it carries the source-path used " - "for the protected-cell fingerprint binding)." + "legis verifies the SEI is alive and that entity resolves live " + "to the same SEI before keying the governance record on it. A " + "non-resolving or unbound entity_sei returns UNRESOLVED_INPUT " + "(weft-reason unresolved_input) and records NOTHING, never a " + "locator-keyed record masquerading as a stable bind or evidence " + "on an unrelated stable identity. entity is still required (it " + "carries the source-path used for the protected-cell fingerprint " + "binding)." ), "inputSchema": _schema( ["policy", "entity", "rationale"], @@ -1235,18 +1238,20 @@ def _recovery_for(code: str) -> dict[str, Any]: ), "CELL_NOT_ENABLED": ( "Two enablement tiers, by cell — both operator-enabled, out-of-band. " - "Simple tier (chill/coached) is reachable WITHOUT a key: the operator " - "maps the policy to a cell via policy/cells.toml or LEGIS_POLICY_CELLS " + "Simple tier is reachable WITHOUT a signing key: the operator maps " + "the policy to a cell via policy/cells.toml or LEGIS_POLICY_CELLS " "(LEGIS_DEV_DEFAULT_CELLS=1 selects the chill dev default), then " - "relaunches. Complex tier (structured/protected and the binding " - "ledger) additionally needs LEGIS_HMAC_KEY set by the operator " - "out-of-band, then a relaunch. The error message names which cell is " - "unenabled." + "relaunches; coached also needs LEGIS_JUDGE_PROVIDER and its " + "provider credentials set out-of-band. Complex tier " + "(structured/protected and the binding ledger) additionally needs " + "LEGIS_HMAC_KEY set by the operator out-of-band, then a relaunch. " + "The error message names which cell is unenabled." ), "UNRESOLVED_INPUT": ( - "The inline entity_sei did not resolve to a live, stable identity, so " - "nothing was recorded (weft SEI-on-entry fail-closed). See the " - "weft_reason.fix: confirm the SEI is alive in Loomweave, or drop " + "The inline entity_sei did not resolve to a live, stable identity, " + "or it was not bound to the submitted entity, so nothing was recorded " + "(weft SEI-on-entry fail-closed). See the weft_reason.fix: confirm " + "the SEI is alive and matches the entity in Loomweave, or drop " "entity_sei and submit the entity as a locator/symbol for legis to " "resolve." ), @@ -1518,6 +1523,27 @@ def _engine(runtime: McpRuntime) -> EnforcementEngine: return runtime.engine +def _coached_engine(runtime: McpRuntime) -> EnforcementEngine | None: + if runtime.coached_engine is not None: + return runtime.coached_engine + if runtime.engine is not None and runtime.engine.has_judge: + return runtime.engine + from legis.config import governance_db_url + from legis.enforcement.judge_factory import configured_judge_from_env + + judge = configured_judge_from_env("MCP") + if judge is None: + return None + runtime.coached_engine = EnforcementEngine( + AuditStore(governance_db_url()), SystemClock(), judge + ) + return runtime.coached_engine + + +def _simple_engine_for_cell(runtime: McpRuntime, cell: str) -> EnforcementEngine | None: + return _coached_engine(runtime) if cell == "coached" else _engine(runtime) + + def _checks(runtime: McpRuntime) -> CheckSurface: if runtime.check_surface is None: from legis.config import check_db_url @@ -1674,6 +1700,32 @@ def _signoff_signed_record( return None +def _dedupe_records(records: list[Any]) -> list[Any]: + deduped = [] + seen = set() + for rec in records: + key = ( + getattr(rec, "seq", None), + getattr(rec, "content_hash", None), + getattr(rec, "chain_hash", None), + ) + if key in seen: + continue + seen.add(key) + deduped.append(rec) + return deduped + + +def _simple_engine_records(runtime: McpRuntime) -> list[Any]: + records = [] + for engine in (runtime.engine, runtime.coached_engine): + if engine is not None: + records.extend(engine.records()) + if records or runtime.engine is not None or runtime.coached_engine is not None: + return _dedupe_records(records) + return _engine(runtime).records() + + def _verified_records(runtime: McpRuntime) -> list[Any]: if runtime.protected_gate is not None: return service_verified_records( @@ -1696,19 +1748,20 @@ def _verified_records(runtime: McpRuntime) -> list[Any]: except TamperError as exc: raise AuditIntegrityError(f"audit integrity failure: {exc}") from exc return records - if runtime.engine is None: - return [] - return runtime.engine.records() + return _simple_engine_records(runtime) def _tool_policy_explain(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]: # D0: explain through the FlooredRegistry — explain_policy floors # transparently because FlooredRegistry IS-A PolicyCellRegistry (D1). + policy = _require(args, "policy") + registry = _floored_registry(runtime) + cell = registry.cell_for(policy) explanation = explain_policy( - _floored_registry(runtime), - policy=_require(args, "policy"), + registry, + policy=policy, entity=_require(args, "entity"), - engine=runtime.engine, + engine=_simple_engine_for_cell(runtime, cell) if cell in ("chill", "coached") else None, protected_gate=runtime.protected_gate, signoff_gate=runtime.signoff_gate, ) @@ -1732,7 +1785,7 @@ def _tool_policy_list(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, An # reports enabled:false without LEGIS_HMAC_KEY (no false-green). explanation = explain_cell( cell, - engine=runtime.engine, + engine=_simple_engine_for_cell(runtime, cell) if cell in ("chill", "coached") else None, protected_gate=runtime.protected_gate, signoff_gate=runtime.signoff_gate, ) @@ -1778,9 +1831,9 @@ def _tool_override_submit(runtime: McpRuntime, args: dict[str, Any]) -> dict[str # genuine retry returns the original outcome (with a floor_warning below). raw_cell = _registry(runtime).cell_for(policy) simple_engine = ( - _engine(runtime) + _simple_engine_for_cell(runtime, dispatch_cell) if dispatch_cell in ("chill", "coached") - else runtime.engine + else None ) explanation = explain_policy( registry, @@ -1849,8 +1902,9 @@ def _tool_override_submit(runtime: McpRuntime, args: dict[str, Any]) -> dict[str ) return _tool_result(response) if explanation.cell in ("chill", "coached"): + assert simple_engine is not None override_result = submit_override( - _engine(runtime), + simple_engine, identity=runtime.identity, policy=policy, entity=entity, @@ -2340,10 +2394,8 @@ def _tool_posture_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, An # D0/D2: read the floor FRESH off the held ledger handle (never cached). The # posture REPORT is fail-closed at the posture layer (cross-cutting checklist # #1): an absent/empty ledger reports the floor as 'structured', never chill - # — independent of the dev registry default. (That is distinct from the - # FlooredRegistry chokepoint, where a None floor is the identity no-op so it - # does not force-raise a dev default; here we are reporting the POSTURE, not - # routing through the registry.) + # — independent of the dev registry default. The FlooredRegistry routing + # chokepoint uses the same None -> structured fallback. ledger = runtime.posture_ledger raw_floor = ledger.read_floor() if ledger is not None else None floor = "structured" if raw_floor is None else raw_floor diff --git a/src/legis/posture/__init__.py b/src/legis/posture/__init__.py index 873a939..f276518 100644 --- a/src/legis/posture/__init__.py +++ b/src/legis/posture/__init__.py @@ -8,7 +8,12 @@ from __future__ import annotations from legis.posture.floor import FlooredRegistry, floored_registry -from legis.posture.ledger import PostureLedger, PostureSetResult, set_floor +from legis.posture.ledger import ( + REFUSED_SESSION_NOT_RECORDED, + PostureLedger, + PostureSetResult, + set_floor, +) from legis.posture.records import ( KIND_GENESIS, KIND_KEY_RESET, @@ -22,6 +27,7 @@ is_active, load_session, open_session, + persist_session, ) from legis.posture.signing import ( AgeFileSigner, @@ -29,6 +35,7 @@ InsecureEnvKeyWarning, KeychainSigner, PostureSigner, + PostureVerifier, key_fingerprint, mint_key, select_backend, @@ -50,6 +57,8 @@ "PostureRecord", "PostureSetResult", "PostureSigner", + "PostureVerifier", + "REFUSED_SESSION_NOT_RECORDED", "Session", "end_session", "floored_registry", @@ -58,6 +67,7 @@ "load_session", "mint_key", "open_session", + "persist_session", "select_backend", "set_floor", "unwrap_key", diff --git a/src/legis/posture/floor.py b/src/legis/posture/floor.py index 6cc9027..5dd48d7 100644 --- a/src/legis/posture/floor.py +++ b/src/legis/posture/floor.py @@ -11,10 +11,10 @@ * The floor only ever *raises* the effective cell (``_max_tier``); it never lowers it. A ``protected`` registry cell under a ``chill`` floor stays ``protected``. - * A missing/empty ledger (``read_floor() is None``) maps to the identity - floor ``chill`` (a no-op): the registry's own default stands, which is - itself fail-closed (``structured``) in production. The floor only RAISES - once an operator has written a genesis/transition (:func:`floored_registry`). + * A missing/empty/deleted ledger (``read_floor() is None``) maps to the + fail-closed floor ``structured``. A signed GENESIS at ``chill`` is distinct: + it is explicit posture evidence and therefore remains ``chill`` until an + operator raises it. * The floor value is read fresh at every construction; it is never cached on a runtime (D2). ``floored_registry`` calls ``read_floor()`` at call time. @@ -77,13 +77,8 @@ def floored_registry(inner: PolicyCellRegistry, ledger: _FloorReader) -> Floored """ floor = ledger.read_floor() if floor is None: - # Absent/empty ledger -> the IDENTITY floor (chill, the bottom tier), a - # pure no-op: max(chill, X) == X, so the registry's own default stands. - # That default is itself fail-closed (fail_closed_policy_cells() -> - # structured) in production, so an uninstalled/deleted ledger still - # yields structured there; under the explicit LEGIS_DEV_DEFAULT_CELLS - # opt-in it stays chill (preserving the N3 keyless-chill acceptance). The - # floor only RAISES once an operator has written a genesis/transition. - # (design §4, reconciled 2026-06-17 during implementation.) - floor = "chill" + # Absent/empty/deleted ledger -> fail closed. A real signed GENESIS at + # chill still returns "chill" from read_floor(); None means there is no + # posture evidence to trust. + floor = "structured" return FlooredRegistry(inner, floor=floor) diff --git a/src/legis/posture/ledger.py b/src/legis/posture/ledger.py index e5d4c7e..87eb7a2 100644 --- a/src/legis/posture/ledger.py +++ b/src/legis/posture/ledger.py @@ -10,18 +10,24 @@ * **Absent ledger** (no DB file, or an empty store) -> ``read_floor()`` returns ``None``; callers map that to the fail-closed ``structured`` default, NEVER ``chill``. Only an explicit ``GENESIS`` record makes ``chill`` the floor. - * The current floor is the *last* record's ``floor`` field, read via an O(1) - tail read (``get_latest_sequence_and_hash`` + ``read_by_seq``), never the - O(N) ``read_all`` loop — ``read_floor`` is on the per-request hot path. + * The current floor is the latest authoritative floor record's ``floor`` field + (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``), found by one descending + payload scan from the tail, never the O(N) ``read_all`` loop or a repeated + point-read loop over metadata. Metadata records such as + ``OPERATOR_SESSION_OPENED`` must not lower the effective floor, even if they + carry a stale ``floor`` field. """ from __future__ import annotations -from dataclasses import dataclass +import json from collections.abc import Callable +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlparse +from sqlalchemy import select + from legis.posture.records import ( KIND_GENESIS, KIND_KEY_RESET, @@ -73,6 +79,8 @@ def _sqlite_file(url: str) -> Path | None: class PostureLedger: """Domain wrapper over an ``AuditStore`` for the posture-floor ledger.""" + _FLOOR_RECORD_KINDS = frozenset({KIND_GENESIS, KIND_TRANSITION, KIND_KEY_RESET}) + def __init__(self, url: str, *, initialize: bool = True) -> None: from legis.store.audit_store import AuditStore @@ -82,28 +90,38 @@ def __init__(self, url: str, *, initialize: bool = True) -> None: # -- reads --------------------------------------------------------------- def read_floor(self) -> str | None: - """The current floor (last record's ``floor``), or ``None`` if no ledger. + """The current floor (latest authoritative floor record), or ``None``. - O(1) tail read: two indexed SQLite queries, no JSON-decode loop. A - missing DB file or an empty store both report ``None`` (fail-closed: - callers map ``None`` -> ``structured``). + Single descending table scan, never a ``read_all`` loop and never a + point-read loop over metadata tails. A missing DB file or an empty store + both report ``None`` (fail-closed: callers map ``None`` -> ``structured``). + Metadata records are skipped so an operator session record cannot lower + an already-raised floor by becoming the tail. """ path = _sqlite_file(self._url) if path is not None and not path.exists(): return None - seq, _ = self.store.get_latest_sequence_and_hash() - if seq == 0: - return None - rec = self.store.read_by_seq(seq) - if rec is None: - return None - return rec.payload.get("floor") + self.store._assert_no_batch_in_progress("read_floor") + with self.store._engine.begin() as conn: + if not self.store._has_log_table(conn): + return None + rows = conn.execute( + select(self.store._log.c.payload) + .order_by(self.store._log.c.seq.desc()) + ) + for row in rows: + payload = json.loads(row.payload) + kind = payload.get("kind") + floor = payload.get("floor") + if kind in self._FLOOR_RECORD_KINDS and floor is not None: + return floor + return None def epoch_reset_unacknowledged(self) -> bool: """True iff the current key epoch was opened by a ``KEY_RESET`` that no later ``TRANSITION`` has acknowledged (design §8/§10). - A ``rekey`` resets the floor to ``chill`` and chains a ``KEY_RESET`` + A ``rekey`` preserves the standing floor and chains a ``KEY_RESET`` carrying a fresh epoch fingerprint. Until an operator signs a follow-on ``TRANSITION`` under that new epoch, the reset is *unacknowledged* — a pending operator action the agent should surface (the same signal the @@ -157,12 +175,14 @@ def genesis( ) -> None: """Write the keyless ``GENESIS`` record (``floor=chill``), once. - Idempotent / re-key-safe: if the store already has ANY record (an - existing GENESIS, or a KEY_RESET tail), this is a no-op — a second - install must never append a second GENESIS, and a rekey'd ledger must - not be re-genesised. + Idempotent / re-key-safe: if the store already has an epoch-opening + record (an existing GENESIS, or a KEY_RESET tail), this is a no-op — a + second install must never append a second GENESIS, and a rekey'd ledger + must not be re-genesised. Metadata-only ledgers from an interrupted old + operator-enable path have no epoch and remain recoverable by appending + GENESIS. """ - if self.store.get_latest_sequence_and_hash()[0] != 0: + if self.current_epoch_fingerprint() is not None: return record = PostureRecord( kind=KIND_GENESIS, @@ -219,10 +239,24 @@ def build(seq: int, prev_hash: str) -> dict[str, Any]: "posture transition refused: signer key fingerprint does not " "match the current epoch fingerprint" ) - # Sign the content (sans signature) bound to its chain position. + # Sign the content (sans signature) bound to its chain position, then + # verify the produced signature against the actual held key material. + # A self-attested fingerprint plus arbitrary signature is not enough. fields = {k: v for k, v in payload.items() if k != "operator_sig"} fields["chain_seq"] = seq payload["operator_sig"] = signer.sign(fields) + from legis.posture.signing import verify_signer_signature + + if not verify_signer_signature( + signer, + fields, + payload["operator_sig"], + expected_fingerprint=key_fingerprint, + ): + raise ValueError( + "posture transition refused: signer did not prove custody of " + "the current epoch key" + ) return payload self.store.append_signed(build) @@ -246,7 +280,9 @@ def session_opened( (``keychain_auth_ref`` — the keychain item id, or ``None`` for age-file/env, per D5). Every ``TRANSITION`` produced in the window then carries this ``session_id``, so the trail reads back as "operator X - opened a window at T; within it the floor moved A->B". + opened a window at T; within it the floor moved A->B". The record does + not carry ``floor``; :meth:`read_floor` ignores metadata records so a + session-open tail cannot change the effective floor. """ self.store.append( { @@ -260,6 +296,16 @@ def session_opened( } ) + def session_opened_recorded(self, session_id: str) -> bool: + """True iff a matching session-open audit record exists in the ledger.""" + for rec in self.store.read_all(): + if ( + rec.payload.get("kind") == KIND_SESSION_OPENED + and rec.payload.get("session_id") == session_id + ): + return True + return False + def rekey( self, *, @@ -272,10 +318,10 @@ def rekey( The lost-key / recovery path (design §8). Fail-closed/loud invariants: - * **Resets to chill.** The ``KEY_RESET`` carries ``floor="chill"`` so - the floor can never *rise* across a reset — the post-reset state is - the safest-to-self-clear cell, and the operator must re-raise it with - a fresh signed ``TRANSITION`` under the new epoch. + * **Preserves the standing floor.** The ``KEY_RESET`` carries the + current floor because runtime routing reads the floor from the tail + record. A missing/empty ledger falls back to the fail-closed + ``structured`` floor, never the self-clearable ``chill`` default. * **Needs no old key and no open session.** Rekey is the recovery mechanism for a lost custody key, so it deliberately mints a new key without proving possession of the old one (a lost key cannot sign) @@ -288,23 +334,30 @@ def rekey( non-zero until a signed ``TRANSITION`` verifies under the NEW epoch (Task 10.2 / D6). - The freshly-minted key bytes reach ONLY the custody ``key_sink`` (handed - off BEFORE the record is written, mirroring ``install_posture`` — if - custody fails we have written no fingerprint we cannot later sign + The freshly-minted key bytes reach ONLY the required custody ``key_sink`` + (handed off BEFORE the record is written, mirroring ``install_posture`` — + if custody fails we have written no fingerprint we cannot later sign against); the ledger stores the new fingerprint alone. Returns the new epoch ``key_fingerprint``. """ + if key_sink is None: + from legis.install import OperatorKeyCustodyError + + raise OperatorKeyCustodyError( + "posture rekey requires an explicit operator-key custody sink" + ) + from legis.posture.signing import key_fingerprint, mint_key + floor = self.read_floor() or "structured" key_hex = mint_key() new_fp = key_fingerprint(key_hex) # Hand the key to custody BEFORE appending the reset: a custody failure # must leave the ledger untouched (no fingerprint we cannot sign against). - if key_sink is not None: - key_sink(key_hex, backend) + key_sink(key_hex, backend) record = PostureRecord( kind=KIND_KEY_RESET, - floor="chill", + floor=floor, key_fingerprint=new_fp, agent_id=agent_id, recorded_at=recorded_at, @@ -321,6 +374,8 @@ def rekey( # Refusal reasons (stable discriminants so callers can branch / report). REFUSED_NO_SESSION = "no_open_session" REFUSED_NO_EPOCH = "no_key_epoch" +REFUSED_SESSION_NOT_RECORDED = "session_not_recorded" +REFUSED_SESSION_AUTH_FAILED = "session_auth_failed" REFUSED_FINGERPRINT_MISMATCH = "fingerprint_mismatch" REFUSED_SIGNER_ERROR = "signer_error" @@ -386,6 +441,12 @@ def set_floor( reason=REFUSED_NO_EPOCH, session_id=sess.session_id, ) + if not ledger.session_opened_recorded(sess.session_id): + return PostureSetResult( + accepted=False, + reason=REFUSED_SESSION_NOT_RECORDED, + session_id=sess.session_id, + ) # The signer must hold the current epoch's key. Checking against the LEDGER # epoch (not the session's recorded field) closes the concurrent-session / # rekey race: a signer for a superseded epoch is refused even with a live @@ -406,6 +467,16 @@ def set_floor( reason=REFUSED_FINGERPRINT_MISMATCH, session_id=sess.session_id, ) + if not _session.verify_session_signature( + sess, + signer, + expected_fingerprint=epoch_fp, + ): + return PostureSetResult( + accepted=False, + reason=REFUSED_SESSION_AUTH_FAILED, + session_id=sess.session_id, + ) # 3. Append exactly one signed TRANSITION. A signer raise inside the build # callback (or a re-checked fingerprint mismatch) propagates out of diff --git a/src/legis/posture/session.py b/src/legis/posture/session.py index 79ef7f8..c9ee9dc 100644 --- a/src/legis/posture/session.py +++ b/src/legis/posture/session.py @@ -6,10 +6,10 @@ process per CLI invocation, so the long-lived signing daemon is deferred (design §6). -``.weft/legis/operator_session.json`` holds ONLY window metadata + a -backend-specific unlock reference: +``.weft/legis/operator_session.json`` holds ONLY window metadata, a +backend-specific unlock reference, and a key-authenticated session signature: - ``session_id, operator_id, opened_at, ttl, expires_at, backend_id, unlock_ref`` + ``session_id, operator_id, opened_at, ttl, expires_at, backend_id, unlock_ref, session_sig`` It never holds key plaintext, a passphrase, or a raw age blob. Per D5 the ``unlock_ref`` is the keychain item id for the keychain backend and ``None`` for @@ -34,13 +34,18 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Protocol from legis.config import operator_session_path -# The exact metadata key set persisted to operator_session.json. Pinned so a -# test can assert NO key/passphrase/blob ever leaks into the file. -_SESSION_KEYS = ( +class _Signer(Protocol): + def sign(self, fields: dict[str, Any]) -> str: ... + + +# The exact metadata key set persisted to operator_session.json, excluding the +# signature over that metadata. Pinned so a test can assert NO key/passphrase/blob +# ever leaks into the file. +_SESSION_SIGNED_KEYS = ( "session_id", "operator_id", "opened_at", @@ -49,6 +54,8 @@ "backend_id", "unlock_ref", ) +_SESSION_KEYS = (*_SESSION_SIGNED_KEYS, "session_sig") +_SESSION_SIG_PURPOSE = "legis.operator_session.v1" @dataclass(frozen=True) @@ -62,6 +69,7 @@ class Session: expires_at: float backend_id: str unlock_ref: str | None + session_sig: str def is_active(self, *, now: float | None = None) -> bool: """True iff the window has not yet lapsed (``now <= expires_at``).""" @@ -93,13 +101,68 @@ def _atomic_write_json(path: Path, obj: dict[str, Any]) -> None: raise +def _session_signature_fields(data: dict[str, Any]) -> dict[str, Any]: + """Canonical field set signed by the operator key for a session file.""" + return { + "purpose": _SESSION_SIG_PURPOSE, + **{key: data[key] for key in _SESSION_SIGNED_KEYS}, + } + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _session_from_data(data: Any) -> Session | None: + """Parse persisted session metadata, returning ``None`` for invalid shapes.""" + if not isinstance(data, dict): + return None + try: + session_id = data["session_id"] + operator_id = data["operator_id"] + opened_at = data["opened_at"] + ttl = data["ttl"] + expires_at = data["expires_at"] + backend_id = data["backend_id"] + unlock_ref = data.get("unlock_ref") + session_sig = data["session_sig"] + except KeyError: + return None + + if ( + not isinstance(session_id, str) + or not isinstance(operator_id, str) + or not _is_number(opened_at) + or not isinstance(ttl, int) + or isinstance(ttl, bool) + or not _is_number(expires_at) + or not isinstance(backend_id, str) + or not (unlock_ref is None or isinstance(unlock_ref, str)) + or not isinstance(session_sig, str) + ): + return None + + return Session( + session_id=session_id, + operator_id=operator_id, + opened_at=opened_at, + ttl=ttl, + expires_at=expires_at, + backend_id=backend_id, + unlock_ref=unlock_ref, + session_sig=session_sig, + ) + + def open_session( *, ttl: int, operator_id: str, backend_id: str, unlock_ref: str | None, + signer: _Signer, now: float | None = None, + persist: bool = True, ) -> Session: """Open (or atomically replace) the single active elevation session. @@ -117,10 +180,20 @@ def open_session( expires_at=opened_at + ttl, backend_id=backend_id, unlock_ref=unlock_ref, + session_sig="", ) + payload = {key: getattr(session, key) for key in _SESSION_SIGNED_KEYS} + payload["session_sig"] = signer.sign(_session_signature_fields(payload)) + session = Session(**payload) + if persist: + persist_session(session) + return session + + +def persist_session(session: Session) -> None: + """Atomically write a previously signed session to the session file.""" payload = {key: getattr(session, key) for key in _SESSION_KEYS} _atomic_write_json(operator_session_path(), payload) - return session def load_session(*, now: float | None = None) -> Session | None: @@ -139,17 +212,8 @@ def load_session(*, now: float | None = None) -> Session | None: data = json.loads(raw) except (json.JSONDecodeError, ValueError): return None - try: - session = Session( - session_id=data["session_id"], - operator_id=data["operator_id"], - opened_at=data["opened_at"], - ttl=data["ttl"], - expires_at=data["expires_at"], - backend_id=data["backend_id"], - unlock_ref=data.get("unlock_ref"), - ) - except (KeyError, TypeError): + session = _session_from_data(data) + if session is None: return None if not session.is_active(now=now): _delete(path) @@ -157,6 +221,26 @@ def load_session(*, now: float | None = None) -> Session | None: return session +def verify_session_signature( + session: Session, + signer: Any, + *, + expected_fingerprint: str, +) -> bool: + """True iff the session file signature verifies under the epoch key.""" + from legis.posture.signing import verify_signer_signature + + fields = _session_signature_fields( + {key: getattr(session, key) for key in _SESSION_SIGNED_KEYS} + ) + return verify_signer_signature( + signer, + fields, + session.session_sig, + expected_fingerprint=expected_fingerprint, + ) + + def end_session() -> None: """Delete the session file (idempotent — ``disable`` may run twice).""" _delete(operator_session_path()) diff --git a/src/legis/posture/signing.py b/src/legis/posture/signing.py index 842f80b..44eb714 100644 --- a/src/legis/posture/signing.py +++ b/src/legis/posture/signing.py @@ -5,10 +5,12 @@ gate (Phase 5) hands a signer canonical record fields and receives an ``operator_sig``; the signer holds the key and signs internally. -**The key never lands in the caller's hands.** Every backend exposes exactly -two methods — :meth:`fingerprint` (sha256 of the held key, safe to surface) and -:meth:`sign` (a v3 HMAC over the caller's fields). No backend exposes a ``key`` -attribute or returns key bytes from any public method (test-pinned). +**The key never lands in the caller's hands.** Every backend exposes +:meth:`fingerprint` (sha256 of the held key, safe to surface), :meth:`sign` (a +v3 HMAC over the caller's fields), and may expose :meth:`verify` to prove a +signature against backend-owned key material without returning it. No backend +exposes a ``key`` attribute or returns key bytes from any public method +(test-pinned). **``chain_seq`` is mandatory in the signed fields.** The caller folds the record's chain position (``chain_seq=seq``) into the fields it hands ``sign``; @@ -86,6 +88,18 @@ def fingerprint(self) -> str: ... def sign(self, fields: dict) -> str: ... +@runtime_checkable +class PostureVerifier(PostureSigner, Protocol): + """Optional custody-backend verifier contract. + + Non-extractable backends can verify signatures internally without exposing + raw key material to the caller. Legacy/raw test signers that lack this + method still go through the local fallback below. + """ + + def verify(self, fields: dict, signature: str) -> bool: ... + + def _sign_with_key(fields: dict, key_hex: str) -> str: """v3-sign ``fields`` with a hex key, discarding the bytes on return. @@ -118,6 +132,9 @@ def fingerprint(self) -> str: def sign(self, fields: dict) -> str: return _sign_with_key(fields, self._key_hex) + def verify(self, fields: dict, signature: str) -> bool: + return _enf_signing.verify(fields, signature, bytes.fromhex(self._key_hex)) + # -- env escape hatch -------------------------------------------------------- @@ -234,6 +251,9 @@ def fingerprint(self) -> str: def sign(self, fields: dict) -> str: return _sign_with_key(fields, self._key()) + def verify(self, fields: dict, signature: str) -> bool: + return _enf_signing.verify(fields, signature, bytes.fromhex(self._key())) + class _KeychainStore(Protocol): """The OS-keychain seam: get a stored secret by item id (injectable).""" @@ -264,6 +284,55 @@ def fingerprint(self) -> str: def sign(self, fields: dict) -> str: return _sign_with_key(fields, self._key()) + def verify(self, fields: dict, signature: str) -> bool: + return _enf_signing.verify(fields, signature, bytes.fromhex(self._key())) + + +def _verification_key_hex(signer: PostureSigner) -> str: + """Extract the held key from supported custody signers for local verification. + + This compatibility fallback stays inside the posture module boundary: + callers still receive only a signer object, while older local/test signers + that lack ``verify()`` can still prove the produced signature verifies under + key material whose fingerprint matches the standing epoch. + """ + if isinstance(signer, _RawKeySigner): + return signer._key_hex + if isinstance(signer, (AgeFileSigner, KeychainSigner)): + return signer._key() + key = getattr(signer, "_key", None) + if isinstance(key, bytes): + return key.hex() + if isinstance(key, str): + return key + raise TypeError("unsupported posture signer backend") + + +def verify_signer_signature( + signer: PostureSigner, + fields: dict, + signature: str, + *, + expected_fingerprint: str, +) -> bool: + """True iff *signature* verifies under the signer's actual held key. + + A self-attested ``fingerprint()`` is not enough: the key material used for + verification must hash to the epoch fingerprint and must validate the HMAC. + """ + try: + if signer.fingerprint() != expected_fingerprint: + return False + verifier = getattr(signer, "verify", None) + if callable(verifier): + return bool(verifier(fields, signature)) + key_hex = _verification_key_hex(signer) + if key_fingerprint(key_hex) != expected_fingerprint: + return False + return _enf_signing.verify(fields, signature, bytes.fromhex(key_hex)) + except Exception: # noqa: BLE001 - custody faults fail closed + return False + # -- backend selection ------------------------------------------------------- diff --git a/src/legis/service/governance.py b/src/legis/service/governance.py index 088c9cf..d94af5c 100644 --- a/src/legis/service/governance.py +++ b/src/legis/service/governance.py @@ -80,10 +80,12 @@ def resolve_for_entry( * ``entity_sei`` (L1, inline bind) — the agent already holds a stable SEI and binds it at the point of entry. legis verifies it is alive through the - Loomweave ``resolve_sei`` transport and keys directly on it. A non-resolving - SEI raises :class:`UnresolvedInputError` (weft-reason ``unresolved_input``) - and the caller records NOTHING — never a locator-keyed record masquerading - as a stable bind. + Loomweave ``resolve_sei`` transport, resolves the submitted ``entity`` + locator, and records only when both resolve to the same live SEI. A + non-resolving or unbound SEI raises :class:`UnresolvedInputError` + (weft-reason ``unresolved_input``) and the caller records NOTHING — never + a locator-keyed record masquerading as a stable bind or evidence on an + unrelated stable identity. * ``entity`` alone (L2, locator/symbol) — the pre-existing path: legis resolves the locator to an SEI when it can and degrades to a locator key otherwise (:func:`resolve_for_record`). Unchanged for every existing caller. @@ -121,16 +123,34 @@ def resolve_for_entry( "locator/symbol (entity) for legis to resolve." ), ) + locator_resolution = identity.resolve(entity) + if ( + locator_resolution.alive is not True + or not locator_resolution.entity_key.identity_stable + or locator_resolution.entity_key != resolution.entity_key + ): + locator_value = locator_resolution.entity_key.value + raise UnresolvedInputError( + cause=( + f"entity_sei {entity_sei!r} resolved live but does not match " + f"entity {entity!r}; entity resolved to {locator_value!r}" + ), + fix=( + "Submit the SEI that Loomweave resolves for the supplied entity, " + "or omit entity_sei and submit the entity as a locator/symbol so " + "legis can bind it itself." + ), + ) ext: dict = {} - if resolution.alive is not None: + if locator_resolution.alive is not None: ext["loomweave"] = { - "alive": resolution.alive, - "content_hash": resolution.content_hash, - "lineage_snapshot": resolution.lineage_snapshot, - "identity_resolution_status": resolution.identity_resolution_status, - "lineage_snapshot_status": resolution.lineage_snapshot_status, + "alive": locator_resolution.alive, + "content_hash": locator_resolution.content_hash, + "lineage_snapshot": locator_resolution.lineage_snapshot, + "identity_resolution_status": locator_resolution.identity_resolution_status, + "lineage_snapshot_status": locator_resolution.lineage_snapshot_status, } - return resolution.entity_key, ext + return locator_resolution.entity_key, ext def verified_records( diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index abb54db..7611ae4 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -12,6 +12,20 @@ def _chill_registry() -> PolicyCellRegistry: return PolicyCellRegistry(default_cell="chill") +class _ChillPostureLedger: + def read_floor(self) -> str: + return "chill" + + def epoch_reset_unacknowledged(self) -> bool: + return False + + +def _chill_posture_ledger() -> _ChillPostureLedger: + # Explicit posture evidence for auth-only tests. Missing posture evidence is + # tested elsewhere and now fails closed to structured. + return _ChillPostureLedger() + + def test_mutating_routes_default_deny_without_unsafe_dev_flag(monkeypatch): monkeypatch.delenv("LEGIS_UNSAFE_DEV_AUTH", raising=False) monkeypatch.delenv("LEGIS_API_SECRET", raising=False) @@ -35,7 +49,12 @@ def test_unsafe_dev_flag_allows_unauthenticated_local_writes(monkeypatch): monkeypatch.setenv("LEGIS_UNSAFE_DEV_AUTH", "1") monkeypatch.delenv("LEGIS_API_SECRET", raising=False) monkeypatch.delenv("LEGIS_API_TOKEN_ACTORS", raising=False) - client = TestClient(create_app(cell_registry=_chill_registry())) + client = TestClient( + create_app( + cell_registry=_chill_registry(), + posture_ledger=_chill_posture_ledger(), + ) + ) resp = client.post( "/overrides", @@ -111,7 +130,12 @@ def test_scoped_tokens_separate_writer_and_operator_authority(monkeypatch, tmp_p ) monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") - client = TestClient(create_app(cell_registry=_chill_registry())) + client = TestClient( + create_app( + cell_registry=_chill_registry(), + posture_ledger=_chill_posture_ledger(), + ) + ) writer = {"Authorization": "Bearer agent-token"} operator = {"Authorization": "Bearer op-token"} @@ -166,7 +190,12 @@ def test_unscoped_token_actor_does_not_grant_operator_authority(monkeypatch, tmp def test_authenticated_writer_identity_does_not_require_body_agent_id(monkeypatch, tmp_path): monkeypatch.setenv("LEGIS_API_TOKEN_ACTORS", "agent-a:writer=agent-token") monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") - client = TestClient(create_app(cell_registry=_chill_registry())) + client = TestClient( + create_app( + cell_registry=_chill_registry(), + posture_ledger=_chill_posture_ledger(), + ) + ) resp = client.post( "/overrides", @@ -216,7 +245,12 @@ def test_single_secret_defaults_to_writer_only_and_fails_closed_on_operator(monk monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") monkeypatch.delenv("LEGIS_API_SECRET_SCOPE", raising=False) - client = TestClient(create_app(cell_registry=_chill_registry())) + client = TestClient( + create_app( + cell_registry=_chill_registry(), + posture_ledger=_chill_posture_ledger(), + ) + ) auth = {"Authorization": "Bearer super-secret"} # writer route: allowed @@ -241,7 +275,12 @@ def test_single_secret_operator_scope_opt_in_grants_operator(monkeypatch, tmp_pa monkeypatch.setenv("LEGIS_API_SECRET_SCOPE", "writer|operator") monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") - client = TestClient(create_app(cell_registry=_chill_registry())) + client = TestClient( + create_app( + cell_registry=_chill_registry(), + posture_ledger=_chill_posture_ledger(), + ) + ) auth = {"Authorization": "Bearer super-secret"} assert client.post( diff --git a/tests/api/test_floor_admission.py b/tests/api/test_floor_admission.py index 9ee1239..1ae9fee 100644 --- a/tests/api/test_floor_admission.py +++ b/tests/api/test_floor_admission.py @@ -34,11 +34,14 @@ def _mem_signer(): from legis.enforcement import signing as enf_signing class _MemSigner: + def __init__(self): + self._key = KEY + def fingerprint(self): return _fp() def sign(self, fields): - return enf_signing.sign(fields, KEY, version="v3") + return enf_signing.sign(fields, self._key, version="v3") return _MemSigner() @@ -97,14 +100,14 @@ def test_floor_read_per_request(tmp_path): def test_missing_ledger_floor_structured(tmp_path): - # No genesis written: read_floor() is None -> floored_registry falls back to - # the registry's own default. With a fail-closed default that is structured. + # No genesis written: read_floor() is None -> the effective floor is + # structured even when the underlying registry would otherwise self-clear. url = f"sqlite:///{tmp_path / 'absent-posture.db'}" absent = PostureLedger(url, initialize=False) c = _app( tmp_path, posture_ledger=absent, - registry=PolicyCellRegistry(default_cell="structured"), + registry=PolicyCellRegistry(default_cell="chill"), ) resp = c.post("/overrides", json=BODY) assert resp.status_code == 202 diff --git a/tests/api/test_override_api.py b/tests/api/test_override_api.py index 3dc0149..2d7ba8e 100644 --- a/tests/api/test_override_api.py +++ b/tests/api/test_override_api.py @@ -64,6 +64,14 @@ def coached_client(tmp_path, opinion): )) +def coached_without_judge_client(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + eng = EnforcementEngine(store, FixedClock("2026-06-02T12:00:00+00:00")) + return TestClient(create_app( + enforcement=eng, cell_registry=_registry(), posture_ledger=_genesis_ledger(tmp_path), + )) + + CHILL_BODY = { "policy": "no-broad-except", "entity": "src/app.py:handler", @@ -116,6 +124,16 @@ def test_coached_blocked_post_returns_409_with_judge_reasoning(tmp_path): assert len(c.get("/overrides").json()) == 1 +def test_coached_without_judge_returns_not_enabled_without_write(tmp_path): + c = coached_without_judge_client(tmp_path) + resp = c.post("/overrides", json=COACHED_BODY) + assert resp.status_code == 404 + detail = resp.json()["detail"].lower() + assert "coached" in detail + assert "not enabled" in detail + assert c.get("/overrides").json() == [] + + def test_coached_accepted_post_returns_201(tmp_path): c = coached_client( tmp_path, JudgeOpinion(Verdict.ACCEPTED, "judge@1", "specific and correct") @@ -127,3 +145,29 @@ def test_coached_accepted_post_returns_201(tmp_path): assert body["cell"] == "coached" assert body["verdict"] == "ACCEPTED" assert body["judge_model"] == "judge@1" + + +def test_default_api_runtime_uses_env_judge_for_coached_cell(tmp_path, monkeypatch): + from legis.enforcement.llm_client import OpenRouterLLMClient + + def fake_init(self, config, *, fetch=None): + self.model_id = "openrouter:test-model" + + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov-env.db'}") + monkeypatch.setenv("LEGIS_JUDGE_PROVIDER", "openrouter") + monkeypatch.setenv("OPENROUTER_API_KEY", "secret") + monkeypatch.setattr(OpenRouterLLMClient, "__init__", fake_init) + monkeypatch.setattr( + OpenRouterLLMClient, + "complete", + lambda self, prompt: '{"verdict":"ACCEPTED","rationale":"ok"}', + ) + + client = TestClient(create_app(cell_registry=_registry(), posture_ledger=_genesis_ledger(tmp_path))) + + resp = client.post("/overrides", json=COACHED_BODY) + assert resp.status_code == 201 + body = resp.json() + assert body["cell"] == "coached" + assert body["verdict"] == "ACCEPTED" + assert body["judge_model"] == "openrouter:test-model" diff --git a/tests/cli/test_operator_cli.py b/tests/cli/test_operator_cli.py index 05918c9..e4cbc3f 100644 --- a/tests/cli/test_operator_cli.py +++ b/tests/cli/test_operator_cli.py @@ -60,6 +60,40 @@ def test_operator_enable_opens_session(posture_env, monkeypatch, capsys): assert "300" in out or "5m" in out +def test_operator_enable_refuses_without_current_epoch(posture_env, monkeypatch, capsys): + # A broad install may leave an initialized-but-empty posture DB when custody + # is deferred. Enabling an operator session before GENESIS would create a + # metadata-only ledger that later blocks explicit posture install recovery. + PostureLedger(posture_db_url(), initialize=True) + monkeypatch.setenv("LEGIS_OPERATOR_KEY", "ab" * 32) + + with pytest.warns(InsecureEnvKeyWarning): + rc = main(["operator", "enable", "--insecure-key-in-env"]) + + assert rc == 1 + assert not operator_session_path().exists() + assert PostureLedger(posture_db_url(), initialize=False).store.read_all() == [] + err = capsys.readouterr().err.lower() + assert "epoch" in err or "genesis" in err + + +def test_operator_enable_refuses_key_that_does_not_match_epoch( + posture_env, monkeypatch, capsys +): + _genesis(bytes.fromhex("ab" * 32)) + monkeypatch.setenv("LEGIS_OPERATOR_KEY", "cd" * 32) + + with pytest.warns(InsecureEnvKeyWarning): + rc = main(["operator", "enable", "--insecure-key-in-env"]) + + assert rc == 1 + assert not operator_session_path().exists() + records = PostureLedger(posture_db_url(), initialize=False).store.read_all() + assert [rec.payload["kind"] for rec in records] == ["GENESIS"] + err = capsys.readouterr().err.lower() + assert "fingerprint" in err or "epoch" in err + + def test_operator_disable_ends_session(posture_env, monkeypatch): key_hex = "ab" * 32 _genesis(bytes.fromhex(key_hex)) diff --git a/tests/cli/test_posture_cli.py b/tests/cli/test_posture_cli.py index 14610fe..32511a6 100644 --- a/tests/cli/test_posture_cli.py +++ b/tests/cli/test_posture_cli.py @@ -17,8 +17,10 @@ import pytest from legis.cli import main +from legis.config import posture_db_url from legis.posture import session as session_mod from legis.posture.ledger import PostureLedger +from legis.posture.signing import _RawKeySigner @pytest.fixture @@ -73,37 +75,65 @@ def test_posture_set_with_session(posture_env, capsys, monkeypatch): # Open an env-backed session and put the matching key in the env so the CLI # can build an EnvSigner whose fingerprint matches the ledger epoch. monkeypatch.setenv("LEGIS_OPERATOR_KEY", key_hex) - session_mod.open_session( + sess = session_mod.open_session( ttl=300, operator_id="operator@example", backend_id="env", unlock_ref=None, + signer=_RawKeySigner(key_hex), + ) + PostureLedger(posture_db_url(), initialize=False).session_opened( + operator_id=sess.operator_id, + enabled_at="t-session", + ttl=sess.ttl, + keychain_auth_ref=sess.unlock_ref, + session_id=sess.session_id, ) from legis.posture import InsecureEnvKeyWarning with pytest.warns(InsecureEnvKeyWarning): rc = main(["posture", "set", "structured"]) assert rc == 0 - from legis.config import posture_db_url assert PostureLedger(posture_db_url(), initialize=False).read_floor() == "structured" assert fp # sanity: a real fingerprint was minted -def test_posture_rekey_resets_to_chill(posture_env, capsys, monkeypatch): - # Phase 11 / Task 11.1 — `legis posture rekey` mints a new epoch, resets the - # floor to chill, and preserves history. The env backend's sink is a no-op - # (the new key goes to LEGIS_OPERATOR_KEY out of band), so no prior key is - # needed — rekey is the lost-key recovery path. +def test_posture_rekey_refuses_env_backend(posture_env, capsys): + _genesis(b"k" * 32) + + rc = main(["posture", "rekey", "--backend", "env"]) + + assert rc == 1 + err = capsys.readouterr().err.lower() + assert "env" in err + assert "persist" in err or "custody" in err + + +def test_posture_rekey_preserves_existing_floor_with_age_file(posture_env, capsys, monkeypatch): + # Phase 11 / Task 11.1 — `legis posture rekey` mints a new epoch while + # preserving the standing floor and history, handing the new key to durable + # custody before the KEY_RESET is written. from legis.config import posture_db_url key_hex = "ab" * 32 key = bytes.fromhex(key_hex) fp0 = _genesis(key) - # Move the floor up so the reset visibly drops it back to chill. + # Move the floor up so a reset-downgrade regression is visible. monkeypatch.setenv("LEGIS_OPERATOR_KEY", key_hex) - session_mod.open_session( - ttl=300, operator_id="op@example", backend_id="env", unlock_ref=None + sess = session_mod.open_session( + ttl=300, + operator_id="op@example", + backend_id="env", + unlock_ref=None, + signer=_RawKeySigner(key_hex), + ) + PostureLedger(posture_db_url(), initialize=False).session_opened( + operator_id=sess.operator_id, + enabled_at="t-session", + ttl=sess.ttl, + keychain_auth_ref=sess.unlock_ref, + session_id=sess.session_id, ) from legis.posture import InsecureEnvKeyWarning @@ -111,24 +141,26 @@ def test_posture_rekey_resets_to_chill(posture_env, capsys, monkeypatch): assert main(["posture", "set", "structured"]) == 0 assert PostureLedger(posture_db_url(), initialize=False).read_floor() == "structured" - rc = main(["posture", "rekey", "--backend", "env"]) + monkeypatch.setenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", "correct horse") + rc = main(["posture", "rekey", "--backend", "age-file"]) assert rc == 0 ledger = PostureLedger(posture_db_url(), initialize=False) - assert ledger.read_floor() == "chill" + assert ledger.read_floor() == "structured" # New epoch minted; history preserved + chain intact. assert ledger.current_epoch_fingerprint() != fp0 assert ledger.store.verify_integrity() is True -def test_posture_rekey_needs_no_session(posture_env, capsys): +def test_posture_rekey_needs_no_session(posture_env, capsys, monkeypatch): # Rekey requires NO open elevation session and NO old key — it is the # recovery path for a lost custody key. from legis.config import posture_db_url _genesis(b"k" * 32) - rc = main(["posture", "rekey", "--backend", "env"]) + monkeypatch.setenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", "correct horse") + rc = main(["posture", "rekey", "--backend", "age-file"]) assert rc == 0 assert PostureLedger(posture_db_url(), initialize=False).read_floor() == "chill" # Doctor would now flag the unacknowledged reset (Task 10.2); the CLI says so. out = capsys.readouterr().out.lower() - assert "rekey" in out or "reset" in out or "chill" in out + assert "rekey" in out or "reset" in out or "floor preserved" in out diff --git a/tests/doctor/test_posture_checks.py b/tests/doctor/test_posture_checks.py index e5f814f..bb9fb00 100644 --- a/tests/doctor/test_posture_checks.py +++ b/tests/doctor/test_posture_checks.py @@ -67,7 +67,7 @@ def _append_key_reset(ledger: PostureLedger, *, new_fp: str, agent_id: str, reco doctor tests construct the record directly so they don't depend on it).""" record = PostureRecord( kind=KIND_KEY_RESET, - floor="chill", + floor=ledger.read_floor() or "structured", key_fingerprint=new_fp, agent_id=agent_id, recorded_at=recorded_at, @@ -221,6 +221,71 @@ def test_key_reset_acknowledged_ok(posture_env, monkeypatch): assert c.ok is True +def test_key_reset_acknowledged_with_age_file_backend(posture_env, monkeypatch, tmp_path): + from pathlib import Path + + from legis.config import operator_age_path + from legis.doctor import check_posture_key_reset + from legis.posture.signing import AgeFileSigner, wrap_key + + monkeypatch.chdir(tmp_path) + ledger, key2, fp2 = _genesis_then_rekey(posture_env) + passphrase = "correct horse" + monkeypatch.setenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", passphrase) + monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False) + age_path = operator_age_path() + age_path.parent.mkdir(parents=True, exist_ok=True) + age_path.write_bytes(wrap_key(key2, passphrase)) + + ledger.transition( + "structured", + signer=AgeFileSigner(blob=age_path.read_bytes(), passphrase_cb=lambda: passphrase), + session_id="sess-ack", + key_fingerprint=fp2, + agent_id="alice", + rationale="re-raise after rekey", + recorded_at="2026-06-16T01:00:00Z", + ) + + c = check_posture_key_reset(Path(".")) + assert c.status == "ok" + assert c.ok is True + + +def test_key_reset_acknowledged_with_age_file_backend_uses_doctor_root( + posture_env, monkeypatch, tmp_path +): + from legis.doctor import check_posture_key_reset + from legis.posture.signing import AgeFileSigner, wrap_key + + repo = tmp_path / "repo" + other_cwd = tmp_path / "other" + repo.mkdir() + other_cwd.mkdir() + ledger, key2, fp2 = _genesis_then_rekey(posture_env) + passphrase = "correct horse" + monkeypatch.setenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", passphrase) + monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False) + age_path = repo / ".weft" / "legis" / "operator.age" + age_path.parent.mkdir(parents=True, exist_ok=True) + age_path.write_bytes(wrap_key(key2, passphrase)) + + ledger.transition( + "structured", + signer=AgeFileSigner(blob=age_path.read_bytes(), passphrase_cb=lambda: passphrase), + session_id="sess-ack", + key_fingerprint=fp2, + agent_id="alice", + rationale="re-raise after rekey", + recorded_at="2026-06-16T01:00:00Z", + ) + + monkeypatch.chdir(other_cwd) + c = check_posture_key_reset(repo) + assert c.status == "ok" + assert c.ok is True + + def test_key_reset_acknowledged_requires_new_epoch_fingerprint(posture_env, monkeypatch): """A TRANSITION signed under the OLD epoch key does NOT acknowledge (D6).""" from legis.doctor import check_posture_key_reset @@ -347,6 +412,47 @@ def test_operator_key_env_present_warns(posture_env, monkeypatch): assert "env" in (c.message or "").lower() +def test_operator_key_age_file_reachable_uses_doctor_root(posture_env, monkeypatch, tmp_path): + from legis.doctor import check_operator_key_accessible + from legis.posture.signing import wrap_key + + repo = tmp_path / "repo" + other_cwd = tmp_path / "other" + repo.mkdir() + other_cwd.mkdir() + ledger = _open(posture_env) + key = mint_key() + fp = key_fingerprint(key) + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + passphrase = "correct horse" + monkeypatch.setenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", passphrase) + monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False) + age_path = repo / ".weft" / "legis" / "operator.age" + age_path.parent.mkdir(parents=True, exist_ok=True) + age_path.write_bytes(wrap_key(key, passphrase)) + + monkeypatch.chdir(other_cwd) + c = check_operator_key_accessible(repo) + assert c.status == "ok" + assert "reachable" in (c.message or "").lower() + + +def test_operator_key_env_mismatch_is_not_reported_usable(posture_env, monkeypatch): + from legis.doctor import check_operator_key_accessible + + ledger = _open(posture_env) + key = mint_key() + fp = key_fingerprint(key) + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + monkeypatch.setenv("LEGIS_OPERATOR_KEY", mint_key()) + + c = check_operator_key_accessible(__import__("pathlib").Path(".")) + assert c.status == "warn" + msg = (c.message or "").lower() + assert "does not match" in msg + assert "usable" not in msg + + def test_operator_key_no_ledger_is_ok(tmp_path, monkeypatch): from legis.doctor import check_operator_key_accessible diff --git a/tests/mcp/test_output_schema_conformance.py b/tests/mcp/test_output_schema_conformance.py index 29056b8..4ce4180 100644 --- a/tests/mcp/test_output_schema_conformance.py +++ b/tests/mcp/test_output_schema_conformance.py @@ -32,6 +32,25 @@ KEY = b"protected-key-1" +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 + + class _ScriptedJudge: def __init__(self, *opinions): self._opinions = list(opinions) @@ -59,7 +78,7 @@ def _tool(name): return next(t for t in tool_definitions() if t["name"] == name) -def _runtime(tmp_path, *, judge=None, registry=None): +def _runtime(tmp_path, *, judge=None, registry=None, posture_ledger=None): from legis.mcp import McpRuntime store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") @@ -71,6 +90,7 @@ def _runtime(tmp_path, *, judge=None, registry=None): initialized=True, engine=engine, cell_registry=registry, + posture_ledger=posture_ledger, ), store @@ -212,11 +232,14 @@ def test_posture_get_conforms_missing_and_floored(tmp_path): ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self): return fp def sign(self, fields): - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") ledger.transition( "structured", @@ -234,7 +257,11 @@ def sign(self, fields): def test_override_submit_conforms_accepted_self(tmp_path): - runtime, _ = _runtime(tmp_path, registry=PolicyCellRegistry(default_cell="chill")) + runtime, _ = _runtime( + tmp_path, + registry=PolicyCellRegistry(default_cell="chill"), + posture_ledger=_chill_posture_ledger(tmp_path), + ) payload = _conformant( runtime, "override_submit", @@ -251,6 +278,7 @@ def test_override_submit_conforms_judged_accept_and_block(tmp_path): JudgeOpinion(Verdict.BLOCKED, "judge@1", "insufficient rationale"), ), registry=PolicyCellRegistry(default_cell="coached"), + posture_ledger=_chill_posture_ledger(tmp_path), ) accepted = _conformant( runtime, diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 1bc58d0..14e130a 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -50,6 +50,25 @@ def evaluate(self, record): KEY = b"protected-key-1" +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, *, @@ -68,6 +87,7 @@ def _runtime( initialized=True, engine=engine, check_surface=check_surface, + posture_ledger=_chill_posture_ledger(tmp_path), ), store @@ -122,6 +142,17 @@ def test_cli_has_mcp_subcommand_with_launch_bound_agent_id(): assert args.agent_id == "agent-1" +def test_mcp_runtime_positional_constructor_preserves_identity_slot(): + from legis.mcp import McpRuntime + + identity = object() + + runtime = McpRuntime("agent-1", False, None, None, identity) + + assert runtime.identity is identity + assert runtime.coached_engine is None + + def test_build_runtime_wires_env_configured_openrouter_judge(tmp_path, monkeypatch): from legis.enforcement.llm_client import OpenRouterLLMClient from legis.mcp import build_runtime @@ -134,7 +165,11 @@ def fake_init(self, config, *, fetch=None): monkeypatch.setenv("OPENROUTER_API_KEY", "secret-key") monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov-env.db'}") monkeypatch.setattr(OpenRouterLLMClient, "__init__", fake_init) - monkeypatch.setattr(OpenRouterLLMClient, "complete", lambda self, prompt: "ACCEPTED\nok") + monkeypatch.setattr( + OpenRouterLLMClient, + "complete", + lambda self, prompt: '{"verdict":"ACCEPTED","rationale":"ok"}', + ) runtime = build_runtime("agent-launch") @@ -150,6 +185,84 @@ def fake_init(self, config, *, fetch=None): assert result.judge_model == "openrouter:test-model" +def test_build_runtime_wires_env_judge_for_simple_coached_override(tmp_path, monkeypatch): + from legis.enforcement.llm_client import OpenRouterLLMClient + from legis.mcp import build_runtime + + def fake_init(self, config, *, fetch=None): + self.model_id = "openrouter:test-model" + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov-env.db'}") + monkeypatch.setenv("LEGIS_JUDGE_PROVIDER", "openrouter") + monkeypatch.setenv("OPENROUTER_API_KEY", "secret") + monkeypatch.setattr(OpenRouterLLMClient, "__init__", fake_init) + monkeypatch.setattr( + OpenRouterLLMClient, + "complete", + lambda self, prompt: '{"verdict":"ACCEPTED","rationale":"ok"}', + ) + runtime = build_runtime("agent-launch") + runtime.initialized = True + runtime.cell_registry = PolicyCellRegistry(default_cell="coached") + runtime.posture_ledger = _chill_posture_ledger(tmp_path) + + call = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "override_submit", + "arguments": { + "policy": "review.rationale", + "entity": "src/x.py:f", + "rationale": "specific rationale", + "idempotency_key": "coached-retry-1", + }, + }, + } + responses = _run( + _messages( + {**call, "id": 1}, + {**call, "id": 2}, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "override_rate_get", + "arguments": {}, + }, + }, + ), + runtime, + ) + + result = responses[0]["result"]["structuredContent"] + retried = responses[1]["result"]["structuredContent"] + rate = responses[2]["result"]["structuredContent"] + + assert result["outcome"] == "ACCEPTED_BY_JUDGE" + assert result["cell"] == "coached" + assert result["judge_model"] == "openrouter:test-model" + assert retried["seq"] == result["seq"] + assert rate["sample_size"] == 1 + + fresh_runtime = build_runtime("agent-fresh") + fresh_runtime.initialized = True + fresh_rate = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "override_rate_get", "arguments": {}}, + } + ), + fresh_runtime, + )[0]["result"]["structuredContent"] + assert fresh_rate["sample_size"] == 1 + + def test_initialize_and_tools_list_exposes_full_agent_surface(tmp_path): runtime, _store = _runtime(tmp_path) runtime.initialized = False @@ -532,7 +645,7 @@ def test_override_submit_entity_sei_binds_on_the_sei(tmp_path): "arguments": { "policy": "ordinary.policy", "entity": "src/x.py:f", - "entity_sei": "loomweave:eid:supplied", + "entity_sei": "loomweave:eid:abc123", "rationale": "generated file; lint is not applicable", }, }, @@ -546,12 +659,47 @@ def test_override_submit_entity_sei_binds_on_the_sei(tmp_path): assert result["structuredContent"]["outcome"] == "ACCEPTED_SELF" recorded = store.read_all()[0].payload assert recorded["entity_key"] == { - "value": "loomweave:eid:supplied", + "value": "loomweave:eid:abc123", "identity_stable": True, } assert recorded["identity_stable"] is True +def test_override_submit_rejects_entity_sei_for_unrelated_locator(tmp_path): + # Both identities are live, but they do not bind to the same entity. The + # authoring surface must reject instead of recording under the wrong SEI. + from legis.identity.resolver import IdentityResolver + + runtime, store = _runtime(tmp_path, agent_id="agent-launch") + runtime.cell_registry = PolicyCellRegistry(default_cell="chill") + runtime.identity = IdentityResolver(_FakeLoomweave(alive=True)) + + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "override_submit", + "arguments": { + "policy": "ordinary.policy", + "entity": "src/x.py:f", + "entity_sei": "loomweave:eid:attacker", + "rationale": "anything", + }, + }, + } + ), + runtime, + ) + + result = responses[0]["result"] + assert result["isError"] is True + assert result["structuredContent"]["error_code"] == "UNRESOLVED_INPUT" + assert store.read_all() == [] + + def test_override_submit_unresolvable_entity_sei_records_nothing_with_weft_reason(tmp_path): # A non-resolving entity_sei returns UNRESOLVED_INPUT (weft-reason # unresolved_input {cause, fix}) and creates NOTHING — never an @@ -596,15 +744,26 @@ def test_n3_acceptance_chill_is_reachable_keyless_via_build_runtime(tmp_path, mo # configured non-secret governance surface. Pins the claim our errors/docs # assert as fact — chill/coached are reachable WITHOUT LEGIS_HMAC_KEY — end to # end through the real launch path (build_runtime + the lazy keyless _engine), - # not via an injected engine. A future change making _engine need a key would - # fail HERE instead of silently falsifying the "reachable keyless" promise. + # not via an injected engine. The chill posture is explicit signed state here: + # a missing posture ledger fails closed to structured. + import hashlib + from legis.mcp import build_runtime, call_tool + from legis.posture.ledger import PostureLedger monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) monkeypatch.delenv("LEGIS_POLICY_CELLS", raising=False) monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path)) # no policy/cells.toml here monkeypatch.setenv("LEGIS_DEV_DEFAULT_CELLS", "1") # operator dev posture -> chill monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") + posture_db = tmp_path / "posture.db" + monkeypatch.setenv("LEGIS_POSTURE_DB", f"sqlite:///{posture_db}") + key = b"k" * 32 + PostureLedger(f"sqlite:///{posture_db}", initialize=True).genesis( + key_fingerprint=hashlib.sha256(key).hexdigest(), + agent_id="installer", + recorded_at="t0", + ) runtime = build_runtime("agent-1") assert runtime.protected_gate is None # genuinely keyless launch diff --git a/tests/posture/test_change_gate.py b/tests/posture/test_change_gate.py index ce087b1..354ab83 100644 --- a/tests/posture/test_change_gate.py +++ b/tests/posture/test_change_gate.py @@ -17,6 +17,8 @@ from __future__ import annotations import hashlib +import json +import time import pytest @@ -67,6 +69,35 @@ def sign(self, fields: dict) -> str: raise RuntimeError("signer backend exploded") +class _FakeFingerprintSigner: + """Claims the epoch fingerprint but cannot sign with the epoch key.""" + + def __init__(self, fingerprint: str): + self._fingerprint = fingerprint + + def fingerprint(self) -> str: + return self._fingerprint + + def sign(self, fields: dict) -> str: + return "hmac-sha256:v3:" + "0" * 64 + + +class _NonExtractableSigner: + """Signer that can verify internally but never exposes key material.""" + + def __init__(self, key: bytes): + self.__key = key + + def fingerprint(self) -> str: + return hashlib.sha256(self.__key).hexdigest() + + def sign(self, fields: dict) -> str: + return enf_signing.sign(fields, self.__key, version="v3") + + def verify(self, fields: dict, signature: str) -> bool: + return enf_signing.verify(fields, signature, self.__key) + + @pytest.fixture(autouse=True) def _session_dir(tmp_path, monkeypatch): """Point operator_session_path() at a per-test tmp dir. @@ -91,15 +122,36 @@ def _genesis(tmp_path, key: bytes): return ledger, fp -def _open_session(*, backend_id: str = "keychain", unlock_ref=None): +def _open_session(*, backend_id: str = "keychain", unlock_ref=None, signer=None): + if signer is None: + signer = _MemSigner(b"k" * 32) return session_mod.open_session( ttl=300, operator_id="operator@example", backend_id=backend_id, unlock_ref=unlock_ref, + signer=signer, ) +def _open_recorded_session( + ledger: PostureLedger, + *, + backend_id: str = "keychain", + unlock_ref=None, + signer=None, +): + sess = _open_session(backend_id=backend_id, unlock_ref=unlock_ref, signer=signer) + ledger.session_opened( + operator_id=sess.operator_id, + enabled_at="t-session", + ttl=sess.ttl, + keychain_auth_ref=sess.unlock_ref, + session_id=sess.session_id, + ) + return sess + + def test_set_refused_without_session(tmp_path): key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) @@ -118,10 +170,61 @@ def test_set_refused_without_session(tmp_path): assert ledger.read_floor() == "chill" +def test_set_refused_with_forged_session_file(tmp_path, _session_dir): + key = b"k" * 32 + ledger, fp = _genesis(tmp_path, key) + _session_dir.write_text( + json.dumps( + { + "session_id": "forged-session", + "operator_id": "attacker@example", + "opened_at": time.time(), + "ttl": 300, + "expires_at": time.time() + 300, + "backend_id": "keychain", + "unlock_ref": None, + } + ), + encoding="utf-8", + ) + + result = set_floor( + "structured", + ledger=ledger, + signer=_MemSigner(key), + agent_id="op", + rationale="tighten", + clock=FixedClock("t1"), + ) + + assert result.accepted is False + assert len(ledger.store.read_all()) == 1 + assert ledger.read_floor() == "chill" + + +def test_set_refused_when_signer_self_attests_fingerprint(tmp_path): + key = b"k" * 32 + ledger, fp = _genesis(tmp_path, key) + _open_recorded_session(ledger, signer=_FakeFingerprintSigner(fp)) + + result = set_floor( + "structured", + ledger=ledger, + signer=_FakeFingerprintSigner(fp), + agent_id="op", + rationale="tighten", + clock=FixedClock("t1"), + ) + + assert result.accepted is False + assert len(ledger.store.read_all()) == 2 + assert ledger.read_floor() == "chill" + + def test_set_refused_fingerprint_mismatch(tmp_path): key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) - _open_session() + _open_recorded_session(ledger) # Signer for a DIFFERENT key than the ledger's current epoch. other = _MemSigner(b"other-key-bytes-................") result = set_floor( @@ -133,14 +236,14 @@ def test_set_refused_fingerprint_mismatch(tmp_path): clock=FixedClock("t1"), ) assert result.accepted is False - assert len(ledger.store.read_all()) == 1 # no record + assert len(ledger.store.read_all()) == 2 # no transition assert ledger.read_floor() == "chill" def test_set_refused_on_signer_error(tmp_path): key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) - _open_session() + _open_recorded_session(ledger) result = set_floor( "structured", ledger=ledger, @@ -151,7 +254,7 @@ def test_set_refused_on_signer_error(tmp_path): ) assert result.accepted is False # No half-written record (append_signed not committed). - assert len(ledger.store.read_all()) == 1 + assert len(ledger.store.read_all()) == 2 assert ledger.read_floor() == "chill" @@ -162,7 +265,11 @@ def test_set_refused_on_wrong_passphrase(tmp_path): ledger = PostureLedger(_url(tmp_path), initialize=True) fp = key_fingerprint(key) ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") - _open_session(backend_id="age-file") + _open_recorded_session( + ledger, + backend_id="age-file", + signer=_MemSigner(bytes.fromhex(key)), + ) blob = wrap_key(key, "correct-passphrase") signer = AgeFileSigner(blob=blob, passphrase_cb=lambda: "WRONG-passphrase") @@ -184,7 +291,7 @@ def test_set_refused_on_wrong_passphrase(tmp_path): def test_set_accepted_with_valid_session(tmp_path): key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) - sess = _open_session() + sess = _open_recorded_session(ledger) result = set_floor( "structured", ledger=ledger, @@ -196,7 +303,7 @@ def test_set_accepted_with_valid_session(tmp_path): assert result.accepted is True # Exactly one TRANSITION appended. records = ledger.store.read_all() - assert len(records) == 2 + assert len(records) == 3 rec = records[-1] assert rec.payload["kind"] == "TRANSITION" assert rec.payload["floor"] == "structured" @@ -211,10 +318,50 @@ def test_set_accepted_with_valid_session(tmp_path): assert enf_signing.verify(fields, sig, key) is True -def test_every_signature_carries_session_id(tmp_path): +def test_set_refused_when_session_file_has_no_ledger_open_record(tmp_path): key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) sess = _open_session() + + result = set_floor( + "structured", + ledger=ledger, + signer=_MemSigner(key), + agent_id="op", + rationale="tighten", + clock=FixedClock("t1"), + ) + + assert result.accepted is False + assert result.reason == "session_not_recorded" + assert result.session_id == sess.session_id + assert len(ledger.store.read_all()) == 1 + assert ledger.read_floor() == "chill" + + +def test_set_accepts_non_extractable_signer_with_internal_verifier(tmp_path): + key = b"k" * 32 + ledger, _ = _genesis(tmp_path, key) + signer = _NonExtractableSigner(key) + _open_recorded_session(ledger, signer=signer) + + result = set_floor( + "structured", + ledger=ledger, + signer=signer, + agent_id="op", + rationale="tighten", + clock=FixedClock("t1"), + ) + + assert result.accepted is True + assert ledger.read_floor() == "structured" + + +def test_every_signature_carries_session_id(tmp_path): + key = b"k" * 32 + ledger, fp = _genesis(tmp_path, key) + sess = _open_recorded_session(ledger) set_floor( "coached", ledger=ledger, @@ -223,7 +370,7 @@ def test_every_signature_carries_session_id(tmp_path): rationale="r", clock=FixedClock("t1"), ) - rec = ledger.store.read_by_seq(2) + rec = ledger.store.read_by_seq(3) assert rec is not None assert rec.payload["session_id"] is not None assert rec.payload["session_id"] == sess.session_id @@ -239,7 +386,7 @@ def test_every_signature_carries_session_id(tmp_path): clock=FixedClock("t2"), ) assert result.accepted is False - assert len(ledger.store.read_all()) == 2 # still just genesis + one transition + assert len(ledger.store.read_all()) == 3 # genesis + session-open + one transition def test_exactly_one_record_per_outcome(tmp_path): @@ -260,7 +407,7 @@ def test_exactly_one_record_per_outcome(tmp_path): assert len(ledger.store.read_all()) == before # Success adds exactly 1. - _open_session() + _open_recorded_session(ledger) r2 = set_floor( "structured", ledger=ledger, @@ -270,7 +417,7 @@ def test_exactly_one_record_per_outcome(tmp_path): clock=FixedClock("t2"), ) assert r2.accepted is True - assert len(ledger.store.read_all()) == before + 1 + assert len(ledger.store.read_all()) == before + 2 def test_set_refused_fingerprint_checked_against_ledger_epoch(tmp_path): @@ -281,7 +428,7 @@ def test_set_refused_fingerprint_checked_against_ledger_epoch(tmp_path): """ key = b"k" * 32 ledger, fp = _genesis(tmp_path, key) - _open_session() + _open_recorded_session(ledger) # Wrong-epoch signer -> refused. refused = set_floor( "structured", @@ -292,7 +439,7 @@ def test_set_refused_fingerprint_checked_against_ledger_epoch(tmp_path): clock=FixedClock("t1"), ) assert refused.accepted is False - assert len(ledger.store.read_all()) == 1 + assert len(ledger.store.read_all()) == 2 # Right-epoch signer -> accepted. accepted = set_floor( "structured", diff --git a/tests/posture/test_floor.py b/tests/posture/test_floor.py index 28d65d7..2c8089a 100644 --- a/tests/posture/test_floor.py +++ b/tests/posture/test_floor.py @@ -11,8 +11,6 @@ import itertools -import pytest - from legis.policy.cells import ( CELL_TIER_ORDER, PolicyCellRegistry, @@ -53,24 +51,22 @@ def test_floor_only_raises(): assert FlooredRegistry(inner, floor="chill").cell_for("protp") == "protected" -def test_missing_floor_defers_to_registry(): - # An absent/empty ledger makes the floor a no-op (identity floor chill): the - # registry's OWN default stands. In production that default is fail-closed - # (structured); under the dev opt-in it is chill (N3 keyless-chill). The - # floor never forces structured over a deliberate registry default. +def test_missing_floor_fails_closed_structured(): + # An absent/empty/deleted ledger means no signed posture evidence is + # available. That is a fail-closed condition: it raises even a chill registry + # to structured instead of deferring to registry defaults. class _NoLedger: def read_floor(self): return None - # dev/chill registry -> absent floor defers to chill (keyless-chill holds). dev = floored_registry(PolicyCellRegistry(default_cell="chill"), _NoLedger()) - assert dev.floor == "chill" - assert dev.cell_for("anything") == "chill" + assert dev.floor == "structured" + assert dev.cell_for("anything") == "structured" - # production fail-closed registry -> absent floor still yields structured. prod = floored_registry( PolicyCellRegistry(default_cell="structured"), _NoLedger() ) + assert prod.floor == "structured" assert prod.cell_for("anything") == "structured" diff --git a/tests/posture/test_ledger.py b/tests/posture/test_ledger.py index 5f31df7..c29d82c 100644 --- a/tests/posture/test_ledger.py +++ b/tests/posture/test_ledger.py @@ -15,7 +15,7 @@ from legis.enforcement import signing as enf_signing from legis.posture.ledger import PostureLedger -from legis.posture.records import KIND_KEY_RESET +from legis.posture.records import KIND_KEY_RESET, KIND_SESSION_OPENED def _url(tmp_path): @@ -69,6 +69,62 @@ def test_read_floor_is_last_record(tmp_path): assert ledger.read_floor() == "structured" +def test_read_floor_skips_non_floor_tail(tmp_path): + ledger = PostureLedger(_url(tmp_path), initialize=True) + key = b"k" * 32 + fp = hashlib.sha256(key).hexdigest() + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + ledger.transition( + "protected", + signer=_MemSigner(key), + session_id="sess-1", + key_fingerprint=fp, + agent_id="op", + rationale="tighten", + recorded_at="t1", + ) + ledger.session_opened( + operator_id="alice", + enabled_at="t2", + ttl=300, + keychain_auth_ref=None, + session_id="sess-2", + ) + + assert ledger.store.read_all()[-1].payload["kind"] == KIND_SESSION_OPENED + assert ledger.read_floor() == "protected" + + +def test_read_floor_ignores_metadata_floor_field(tmp_path): + ledger = PostureLedger(_url(tmp_path), initialize=True) + key = b"k" * 32 + fp = hashlib.sha256(key).hexdigest() + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + ledger.transition( + "protected", + signer=_MemSigner(key), + session_id="sess-1", + key_fingerprint=fp, + agent_id="op", + rationale="tighten", + recorded_at="t1", + ) + ledger.store.append( + { + "kind": KIND_SESSION_OPENED, + "floor": "structured", + "operator_id": "alice", + "enabled_at": "t2", + "ttl": 300, + "keychain_auth_ref": None, + "session_id": "sess-2", + "operator_sig": None, + } + ) + + assert ledger.read_floor() == "protected" + + def test_read_floor_uses_tail_read(tmp_path, monkeypatch): ledger = PostureLedger(_url(tmp_path), initialize=True) ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0") @@ -80,6 +136,36 @@ def _boom(): assert ledger.read_floor() == "chill" +def test_read_floor_does_not_point_read_each_metadata_tail(tmp_path, monkeypatch): + ledger = PostureLedger(_url(tmp_path), initialize=True) + key = b"k" * 32 + fp = hashlib.sha256(key).hexdigest() + ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0") + ledger.transition( + "protected", + signer=_MemSigner(key), + session_id="sess-1", + key_fingerprint=fp, + agent_id="op", + rationale="tighten", + recorded_at="t1", + ) + for idx in range(3): + ledger.session_opened( + operator_id="alice", + enabled_at=f"t{idx + 2}", + ttl=300, + keychain_auth_ref=None, + session_id=f"sess-meta-{idx}", + ) + + def _boom(seq): + raise AssertionError("read_floor must not point-read each metadata tail") + + monkeypatch.setattr(ledger.store, "read_by_seq", _boom) + assert ledger.read_floor() == "protected" + + def test_chain_integrity(tmp_path): ledger = PostureLedger(_url(tmp_path), initialize=True) key = b"k" * 32 diff --git a/tests/posture/test_ledger_edges.py b/tests/posture/test_ledger_edges.py index 064ca30..8203b1a 100644 --- a/tests/posture/test_ledger_edges.py +++ b/tests/posture/test_ledger_edges.py @@ -97,14 +97,21 @@ def test_session_opened_implemented_in_phase3(tmp_path): def test_rekey_implemented_in_phase11(tmp_path): - # Phase 11 supersedes the Phase 1 stub: rekey() mints a fresh epoch, resets - # the floor to chill, and chains a keyless KEY_RESET onto preserved history + # Phase 11 supersedes the Phase 1 stub: rekey() mints a fresh epoch, keeps + # the standing floor, and chains a keyless KEY_RESET onto preserved history # (full coverage in test_rekey.py). ledger = PostureLedger(f"sqlite:///{tmp_path}/posture.db", initialize=True) ledger.genesis( key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0" ) - new_fp = ledger.rekey(agent_id="op", recorded_at="t1") + handed: list[tuple[str, str]] = [] + new_fp = ledger.rekey( + agent_id="op", + recorded_at="t1", + key_sink=lambda key_hex, backend: handed.append((key_hex, backend)), + backend="age-file", + ) assert ledger.read_floor() == "chill" assert ledger.store.read_all()[-1].payload["kind"] == "KEY_RESET" assert new_fp == ledger.current_epoch_fingerprint() != "ab" * 32 + assert len(handed) == 1 diff --git a/tests/posture/test_mcp_floor.py b/tests/posture/test_mcp_floor.py index 97ab716..d7578ec 100644 --- a/tests/posture/test_mcp_floor.py +++ b/tests/posture/test_mcp_floor.py @@ -51,11 +51,14 @@ def _posture_ledger(tmp_path, floor=None): if floor is not None and floor != "chill": class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self): return fp def sign(self, fields): - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") ledger.transition( floor, @@ -164,11 +167,14 @@ def test_mcp_floor_read_per_invocation(tmp_path): fp = hashlib.sha256(key).hexdigest() class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self): return fp def sign(self, fields): - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") runtime.posture_ledger.transition( "structured", @@ -207,11 +213,14 @@ def test_idempotent_replay_is_floor_exempt(tmp_path): fp = hashlib.sha256(key).hexdigest() class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self): return fp def sign(self, fields): - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") runtime.posture_ledger.transition( "structured", diff --git a/tests/posture/test_posture_get.py b/tests/posture/test_posture_get.py index 7a50d93..257ce0c 100644 --- a/tests/posture/test_posture_get.py +++ b/tests/posture/test_posture_get.py @@ -66,11 +66,14 @@ def _seeded_ledger(tmp_path, floor=None, *, genesis=True): if floor is not None and floor != "chill": class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self): return fp def sign(self, fields): - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") ledger.transition( floor, @@ -174,13 +177,14 @@ def test_posture_get_indicates_unacknowledged_key_reset(tmp_path): # A KEY_RESET with no follow-on signed transition -> the agent sees the same # pending-operator-action signal doctor surfaces (Quality medium). ledger, _key, fp = _seeded_ledger(tmp_path, floor="structured") - # Append a KEY_RESET directly (rekey() lands in Phase 11); it resets to chill - # and opens a new epoch, with no acknowledging transition after it. + # Append a KEY_RESET directly (rekey() lands in Phase 11); it preserves the + # standing floor while opening a new epoch, with no acknowledging transition + # after it. new_fp = hashlib.sha256(b"n" * 32).hexdigest() ledger.store.append( PostureRecord( kind=KIND_KEY_RESET, - floor="chill", + floor="structured", key_fingerprint=new_fp, agent_id="op", recorded_at="t2", @@ -192,8 +196,8 @@ def test_posture_get_indicates_unacknowledged_key_reset(tmp_path): runtime = _runtime(tmp_path, ledger=ledger) sc = _call(runtime, "posture_get", {})["structuredContent"] assert sc["epoch_reset_unacknowledged"] is True - # The floor itself reads back as the KEY_RESET's chill reset. - assert sc["floor"] == "chill" + # The floor itself reads back as the KEY_RESET's preserved standing floor. + assert sc["floor"] == "structured" def test_no_posture_set_over_mcp(tmp_path): diff --git a/tests/posture/test_rekey.py b/tests/posture/test_rekey.py index c2a28cf..1399999 100644 --- a/tests/posture/test_rekey.py +++ b/tests/posture/test_rekey.py @@ -1,9 +1,10 @@ """Phase 11 / Task 11.1 — posture rekey (lost-key / epoch-reset path). -Fail-closed/loud contract (design §8): a rekey resets the floor to ``chill``, -needs NO old key and NO open session, preserves all prior history, chains a -single ``KEY_RESET`` record carrying a fresh epoch fingerprint, and doctor -flags it non-zero until an acknowledging signed transition under the new epoch. +Fail-closed/loud contract (design §8): a rekey changes the key epoch without +lowering the standing floor, needs NO old key and NO open session, preserves all +prior history, chains a single ``KEY_RESET`` record carrying a fresh epoch +fingerprint, and doctor flags it non-zero until an acknowledging signed +transition under the new epoch. Unit tests construct the store with an explicit absolute sqlite URL (matching tests/store/test_audit_store.py / tests/posture/test_ledger.py), never via @@ -14,6 +15,9 @@ import hashlib +import pytest + +from legis.install import OperatorKeyCustodyError from legis.posture.ledger import PostureLedger from legis.posture.records import KIND_GENESIS, KIND_KEY_RESET @@ -30,22 +34,32 @@ def _genesis_ledger(tmp_path): return ledger, fp -def test_rekey_resets_to_chill(tmp_path): - ledger, fp0 = _genesis_ledger(tmp_path) - # Move the floor up first so the reset visibly drops it back to chill. - from legis.posture.ledger import _Signer # type: ignore # noqa: F401 +def _custody_sink(): + handed: list[tuple[str, str]] = [] + + def sink(key_hex: str, backend: str) -> None: + handed.append((key_hex, backend)) + + return handed, sink + + +class _MemSigner: + def __init__(self, key): + self._key = key - class _MemSigner: - def __init__(self, key): - self._key = key + def fingerprint(self): + return hashlib.sha256(self._key).hexdigest() - def fingerprint(self): - return hashlib.sha256(self._key).hexdigest() + def sign(self, fields): + from legis.enforcement import signing as enf_signing - def sign(self, fields): - from legis.enforcement import signing as enf_signing + return enf_signing.sign(fields, self._key, version="v3") - return enf_signing.sign(fields, self._key, version="v3") + +def test_rekey_preserves_existing_floor(tmp_path): + ledger, fp0 = _genesis_ledger(tmp_path) + # Move the floor up first so a reset-downgrade regression is visible. + from legis.posture.ledger import _Signer # type: ignore # noqa: F401 ledger.transition( "structured", @@ -58,34 +72,77 @@ def sign(self, fields): ) assert ledger.read_floor() == "structured" - ledger.rekey(agent_id="op", recorded_at="t2") - assert ledger.read_floor() == "chill" + _handed, sink = _custody_sink() + ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="age-file") + assert ledger.read_floor() == "structured" + reset = ledger.store.read_all()[-1].payload + assert reset["kind"] == KIND_KEY_RESET + assert reset["floor"] == "structured" -def test_rekey_mints_new_epoch(tmp_path): +def test_rekey_preserves_floor_after_session_opened_tail(tmp_path): ledger, fp0 = _genesis_ledger(tmp_path) - handed: list[tuple[str, str]] = [] + key = b"k" * 32 + ledger.transition( + "protected", + signer=_MemSigner(key), + session_id="sess-1", + key_fingerprint=fp0, + agent_id="op", + rationale="tighten", + recorded_at="t1", + ) + ledger.session_opened( + operator_id="alice", + enabled_at="t2", + ttl=300, + keychain_auth_ref=None, + session_id="sess-2", + ) - def sink(key_hex: str, backend: str) -> None: - handed.append((key_hex, backend)) + _handed, sink = _custody_sink() + ledger.rekey(agent_id="op", recorded_at="t3", key_sink=sink, backend="age-file") + + assert ledger.read_floor() == "protected" + reset = ledger.store.read_all()[-1].payload + assert reset["kind"] == KIND_KEY_RESET + assert reset["floor"] == "protected" - ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="env") + +def test_rekey_mints_new_epoch(tmp_path): + ledger, fp0 = _genesis_ledger(tmp_path) + handed, sink = _custody_sink() + + ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="age-file") new_fp = ledger.current_epoch_fingerprint() assert new_fp != fp0 # The freshly-minted key was handed to the backend (and only its fingerprint # is stored in the ledger). assert len(handed) == 1 minted_hex, backend = handed[0] - assert backend == "env" + assert backend == "age-file" assert hashlib.sha256(bytes.fromhex(minted_hex)).hexdigest() == new_fp +def test_rekey_refuses_without_custody_sink(tmp_path): + ledger, fp0 = _genesis_ledger(tmp_path) + + with pytest.raises(OperatorKeyCustodyError): + ledger.rekey(agent_id="op", recorded_at="t2") + + records = ledger.store.read_all() + assert len(records) == 1 + assert records[0].payload["kind"] == KIND_GENESIS + assert ledger.current_epoch_fingerprint() == fp0 + + def test_rekey_preserves_history(tmp_path): ledger, fp0 = _genesis_ledger(tmp_path) before = ledger.store.read_all() assert len(before) == 1 - ledger.rekey(agent_id="op", recorded_at="t2") + _handed, sink = _custody_sink() + ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="age-file") after = ledger.store.read_all() # KEY_RESET chained ONTO the existing history (not a fresh DB): the original @@ -101,18 +158,27 @@ def test_rekey_preserves_history(tmp_path): def test_rekey_needs_no_old_key(tmp_path): # No open session, no signer, no prior key available — rekey still succeeds. ledger, _ = _genesis_ledger(tmp_path) - ledger.rekey(agent_id="op", recorded_at="t2") + _handed, sink = _custody_sink() + ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="age-file") assert ledger.read_floor() == "chill" assert ledger.current_epoch_fingerprint() is not None def test_rekey_writes_key_reset_record(tmp_path): ledger, fp0 = _genesis_ledger(tmp_path) - ledger.rekey(agent_id="recovery-agent", recorded_at="t2") + _handed, sink = _custody_sink() + ledger.rekey( + agent_id="recovery-agent", + recorded_at="t2", + key_sink=sink, + backend="age-file", + ) records = ledger.store.read_all() resets = [r for r in records if r.payload["kind"] == KIND_KEY_RESET] assert len(resets) == 1 rec = resets[0].payload + # A genesis-only ledger is already chill; rekey records the standing floor + # instead of using the reset to downgrade a stricter floor. assert rec["floor"] == "chill" assert rec["key_fingerprint"] != fp0 assert rec["agent_id"] == "recovery-agent" @@ -134,7 +200,8 @@ def test_doctor_flags_rekey(tmp_path, monkeypatch): ledger = PostureLedger(url, initialize=True) ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0") - ledger.rekey(agent_id="op", recorded_at="t2") + _handed, sink = _custody_sink() + ledger.rekey(agent_id="op", recorded_at="t2", key_sink=sink, backend="age-file") check = check_posture_key_reset(pathlib.Path(".")) assert check.ok is False diff --git a/tests/posture/test_security_honesty.py b/tests/posture/test_security_honesty.py index 8f3d4d6..ae844e6 100644 --- a/tests/posture/test_security_honesty.py +++ b/tests/posture/test_security_honesty.py @@ -4,7 +4,7 @@ (design §6, §8, §9, §10): the operator key never reaches the caller and never lands in logs; every floor transition is accountable to an open elevation session; the env escape hatch is loud and explicit; the age-file backend fails -closed on a wrong/absent passphrase; and a rekey can never land above ``chill``. +closed on a wrong/absent passphrase; and a rekey cannot downgrade the floor. They are deliberately *behavioral*, not aspirational (per the Quality reviews): the key-never-leaks tests sign with a known key and assert that key's hex never @@ -81,15 +81,34 @@ def _genesis(tmp_path, *, key_hex: str): return ledger, fp -def _open_session(*, backend_id: str = "keychain", unlock_ref=None): +def _open_session(*, backend_id: str = "keychain", unlock_ref=None, signer): return session_mod.open_session( ttl=300, operator_id="operator@example", backend_id=backend_id, unlock_ref=unlock_ref, + signer=signer, ) +def _open_recorded_session( + ledger: PostureLedger, + *, + backend_id: str = "keychain", + unlock_ref=None, + signer, +): + sess = _open_session(backend_id=backend_id, unlock_ref=unlock_ref, signer=signer) + ledger.session_opened( + operator_id=sess.operator_id, + enabled_at="t-session", + ttl=sess.ttl, + keychain_auth_ref=sess.unlock_ref, + session_id=sess.session_id, + ) + return sess + + def _all_backends(key_hex: str): """Construct one of each custody backend over the SAME known key. @@ -124,7 +143,7 @@ def test_tty_session_expiry(tmp_path): ledger, _ = _genesis(tmp_path, key_hex=key_hex) sess_path = session_mod.operator_session_path() - _open_session() + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) # Force the window's expiry into the past without sleeping. data = json.loads(sess_path.read_text(encoding="utf-8")) data["expires_at"] = time.time() - 10 @@ -145,7 +164,7 @@ def test_tty_session_expiry(tmp_path): ) assert result.accepted is False assert result.reason == REFUSED_NO_SESSION - assert len(ledger.store.read_all()) == 1 # only GENESIS + assert len(ledger.store.read_all()) == 2 # GENESIS + recorded expired session assert ledger.read_floor() == "chill" @@ -183,19 +202,19 @@ def test_key_never_returned_to_caller(): assert signer.sign(fields) != key_hex -# -- test_rekey_resets_to_chill ---------------------------------------------- +# -- test_rekey_preserves_existing_floor -------------------------------------- -def test_rekey_resets_to_chill(tmp_path): - """(Cross-ref Phase 11) a rekey can never land above chill — even from an - elevated floor, the post-reset floor is chill (design §8). +def test_rekey_preserves_existing_floor(tmp_path): + """(Cross-ref Phase 11) a rekey changes the epoch without lowering an + elevated floor (security finding ab59c0bb). """ key_hex = mint_key() key_bytes = bytes.fromhex(key_hex) ledger, _ = _genesis(tmp_path, key_hex=key_hex) - # Elevate the floor first so the reset visibly drops it back to chill. - _open_session() + # Elevate the floor first so a reset-downgrade regression is visible. + _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) set_floor( "protected", ledger=ledger, @@ -206,13 +225,21 @@ def test_rekey_resets_to_chill(tmp_path): ) assert ledger.read_floor() == "protected" - # Rekey resets to chill regardless of the prior (elevated) floor. - ledger.rekey(agent_id="op", recorded_at="t2") - assert ledger.read_floor() == "chill" - # The KEY_RESET record itself carries floor="chill" (cannot land above). + # Rekey must not use lost-key recovery to downgrade the prior floor. + handed: list[tuple[str, str]] = [] + ledger.rekey( + agent_id="op", + recorded_at="t2", + key_sink=lambda key_hex, backend: handed.append((key_hex, backend)), + backend="age-file", + ) + assert ledger.read_floor() == "protected" + # The KEY_RESET record itself carries the standing floor because runtime + # floor reads use the tail payload. resets = [r for r in ledger.store.read_all() if r.payload["kind"] == KIND_KEY_RESET] assert len(resets) == 1 - assert resets[0].payload["floor"] == "chill" + assert resets[0].payload["floor"] == "protected" + assert len(handed) == 1 # -- test_every_signature_carries_session_id --------------------------------- @@ -227,7 +254,7 @@ def test_every_signature_carries_session_id(tmp_path): key_bytes = bytes.fromhex(key_hex) ledger, _ = _genesis(tmp_path, key_hex=key_hex) - sess = _open_session() + sess = _open_recorded_session(ledger, signer=_MemSigner(key_bytes)) set_floor( "structured", ledger=ledger, @@ -260,9 +287,9 @@ def test_every_signature_carries_session_id(tmp_path): os.environ[_OPERATOR_KEY_ENV] = key_hex try: - env_sess = _open_session(backend_id="env") with pytest.warns(InsecureEnvKeyWarning): env_signer = EnvSigner(insecure_env=True) + env_sess = _open_recorded_session(ledger, backend_id="env", signer=env_signer) env_result = set_floor( "structured", ledger=ledger, diff --git a/tests/posture/test_session.py b/tests/posture/test_session.py index 31ecc4b..6ef93ee 100644 --- a/tests/posture/test_session.py +++ b/tests/posture/test_session.py @@ -14,6 +14,7 @@ import pytest +from legis.enforcement import signing as enf_signing from legis.posture import session as session_mod from legis.posture.records import KIND_SESSION_OPENED @@ -28,11 +29,24 @@ def session_path(tmp_path, monkeypatch): return target +class _MemSigner: + def __init__(self, key: bytes = b"k" * 32): + self._key = key + + def sign(self, fields: dict) -> str: + return enf_signing.sign(fields, self._key, version="v3") + + +def _open_session(**kwargs): + kwargs.setdefault("signer", _MemSigner()) + return session_mod.open_session(**kwargs) + + # -- Task 3.1: persisted session-file model ---------------------------------- def test_enable_writes_session_file(session_path): - session_mod.open_session( + _open_session( ttl=300, operator_id="alice", backend_id="keychain", @@ -46,10 +60,11 @@ def test_enable_writes_session_file(session_path): "operator_id", "opened_at", "ttl", - "expires_at", - "backend_id", - "unlock_ref", - } + "expires_at", + "backend_id", + "unlock_ref", + "session_sig", + } assert "key" not in data assert "passphrase" not in data # No raw blob plaintext smuggled into any value. @@ -60,7 +75,7 @@ def test_enable_writes_session_file(session_path): def test_age_backend_unlock_ref_is_none(session_path): # D5: re-prompt is the unlock mechanism for age-file; only keychain stores # a non-null item id. - session_mod.open_session( + _open_session( ttl=300, operator_id="alice", backend_id="age-file", @@ -72,7 +87,7 @@ def test_age_backend_unlock_ref_is_none(session_path): def test_session_active_within_ttl(session_path): - session_mod.open_session( + _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) loaded = session_mod.load_session() @@ -81,7 +96,7 @@ def test_session_active_within_ttl(session_path): def test_session_expired_after_ttl(session_path): - session_mod.open_session( + _open_session( ttl=1, operator_id="alice", backend_id="age-file", unlock_ref=None ) # Force the file's expiry into the past without sleeping. @@ -94,7 +109,7 @@ def test_session_expired_after_ttl(session_path): def test_load_session_double_expire_is_safe(session_path): - session_mod.open_session( + _open_session( ttl=1, operator_id="alice", backend_id="age-file", unlock_ref=None ) data = json.loads(session_path.read_text(encoding="utf-8")) @@ -106,7 +121,7 @@ def test_load_session_double_expire_is_safe(session_path): def test_disable_ends_early(session_path): - session_mod.open_session( + _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) assert session_path.exists() @@ -117,20 +132,20 @@ def test_disable_ends_early(session_path): def test_unique_session_id(session_path): - s1 = session_mod.open_session( + s1 = _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) - s2 = session_mod.open_session( + s2 = _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) assert s1.session_id != s2.session_id def test_second_enable_replaces_first(session_path): - s1 = session_mod.open_session( + s1 = _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) - s2 = session_mod.open_session( + s2 = _open_session( ttl=300, operator_id="bob", backend_id="keychain", unlock_ref="kc-1" ) # Exactly one authoritative session file — the second overwrites the first. @@ -157,6 +172,26 @@ def test_load_missing_required_key_is_none(session_path): assert session_mod.load_session() is None +def test_load_wrong_typed_required_field_is_none(session_path): + session_path.parent.mkdir(parents=True, exist_ok=True) + session_path.write_text( + json.dumps( + { + "session_id": "x", + "operator_id": "alice", + "opened_at": 1000.0, + "ttl": 300, + "expires_at": "not-a-number", + "backend_id": "age-file", + "unlock_ref": None, + "session_sig": "bogus", + } + ), + encoding="utf-8", + ) + assert session_mod.load_session(now=1001.0) is None + + def test_load_missing_file_is_none(session_path): # No file at all -> None. assert session_mod.load_session() is None @@ -164,14 +199,14 @@ def test_load_missing_file_is_none(session_path): def test_is_active_module_helper(session_path): assert session_mod.is_active() is False - session_mod.open_session( + _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) assert session_mod.is_active() is True def test_session_is_active_explicit_now(session_path): - s = session_mod.open_session( + s = _open_session( ttl=300, operator_id="alice", backend_id="age-file", unlock_ref=None ) assert s.is_active(now=s.opened_at + 100) is True diff --git a/tests/service/test_governance.py b/tests/service/test_governance.py index 14cf4e2..f0ae3d4 100644 --- a/tests/service/test_governance.py +++ b/tests/service/test_governance.py @@ -127,8 +127,8 @@ def test_resolve_for_entry_without_sei_is_the_locator_path(): def test_resolve_for_entry_with_sei_keys_directly_on_verified_sei(): sei_key = EntityKey.from_sei("loomweave:eid:deadbeef") identity = _FakeIdentity( - _FakeResult(EntityKey.from_locator("ignored"), alive=False, - content_hash=None, lineage_snapshot=None), + _FakeResult(sei_key, alive=True, content_hash="h", + lineage_snapshot=["born"]), sei_result=_FakeResult(sei_key, alive=True, content_hash="h", lineage_snapshot=["born"]), ) @@ -141,6 +141,27 @@ def test_resolve_for_entry_with_sei_keys_directly_on_verified_sei(): assert ext["loomweave"]["content_hash"] == "h" +def test_resolve_for_entry_rejects_sei_for_unrelated_locator(): + locator_key = EntityKey.from_sei("loomweave:eid:locator-target") + supplied_key = EntityKey.from_sei("loomweave:eid:supplied") + identity = _FakeIdentity( + _FakeResult(locator_key, alive=True, content_hash="locator-h", + lineage_snapshot=["born"]), + sei_result=_FakeResult(supplied_key, alive=True, content_hash="supplied-h", + lineage_snapshot=["other"]), + ) + + with pytest.raises(UnresolvedInputError) as ei: + resolve_for_entry( + identity, + entity="src/foo.py:bar", + entity_sei="loomweave:eid:supplied", + ) + + assert "does not match" in ei.value.cause + assert "entity" in ei.value.fix + + def test_resolve_for_entry_unresolvable_sei_raises_and_records_nothing(): # The fake reports the supplied SEI does not resolve (None) → fail-closed. identity = _FakeIdentity( @@ -165,8 +186,7 @@ def test_resolve_for_entry_sei_without_resolver_raises_unresolved(): def test_submit_override_with_entity_sei_records_on_the_sei(tmp_path): sei_key = EntityKey.from_sei("loomweave:eid:abc") identity = _FakeIdentity( - _FakeResult(EntityKey.from_locator("loc"), alive=None, - content_hash=None, lineage_snapshot=None), + _FakeResult(sei_key, alive=True, content_hash="h", lineage_snapshot=[]), sei_result=_FakeResult(sei_key, alive=True, content_hash="h", lineage_snapshot=[]), ) engine = EnforcementEngine(AuditStore(f"sqlite:///{tmp_path / 'gov.db'}"), SystemClock()) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 594c4d9..de20d77 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -40,29 +40,38 @@ def test_release_publish_requires_live_loomweave_conformance(): env = live_job["env"] assert env["LOOMWEAVE_URL"] == "${{ vars.LOOMWEAVE_URL }}" assert env["LOOMWEAVE_LIVE_ORACLE_LOCATOR"] == "${{ vars.LOOMWEAVE_LIVE_ORACLE_LOCATOR }}" - assert env["LEGIS_LOOMWEAVE_HMAC_KEY"] == "${{ secrets.LEGIS_LOOMWEAVE_HMAC_KEY }}" + assert "LEGIS_LOOMWEAVE_HMAC_KEY" not in env commands = "\n".join(str(step.get("run", "")) for step in live_job["steps"]) - # Skip-not-fail contract (0dafc83 / f95036b): when the live-oracle release - # env is unprovisioned the job passes as a fast no-op so it never blocks the - # PyPI publish; when the env IS present, the oracle runs for real and a - # conformance failure blocks publish — the gate still bites where it can. - # (The old hard-fail "Missing required release conformance environment" - # guard was deliberately removed and must not be reintroduced.) - assert "Missing required release conformance environment" not in commands - assert "configured=false" in commands # the skip branch is present - assert "configured=true" in commands # the run branch is present - assert "not blocking publish" in commands # skip, not hard-fail + assert "Missing required release conformance environment" in commands + assert "configured=false" not in commands + assert "configured=true" not in commands + assert "not blocking publish" not in commands assert "tests/conformance/test_live_loomweave_oracle.py" in commands - # The real oracle run is gated on the live config being detected, so an - # unprovisioned environment skips it rather than erroring. - gated = [ + oracle_steps = [ step for step in live_job["steps"] if "test_live_loomweave_oracle.py" in str(step.get("run", "")) ] - assert gated + assert oracle_steps + assert all("if" not in step for step in oracle_steps) + oracle_step = oracle_steps[0] + assert oracle_step["env"] == { + "LEGIS_LOOMWEAVE_HMAC_KEY": "${{ secrets.LEGIS_LOOMWEAVE_HMAC_KEY }}" + } + assert "LEGIS_LOOMWEAVE_HMAC_KEY" in oracle_step["run"] + non_oracle_steps = [step for step in live_job["steps"] if step is not oracle_step] assert all( - step.get("if") == "steps.oracle_config.outputs.configured == 'true'" - for step in gated + "LEGIS_LOOMWEAVE_HMAC_KEY" not in step.get("env", {}) + for step in non_oracle_steps ) + + +def test_release_workflow_repeats_publication_quality_gates(): + steps = _release_jobs()["build"]["steps"] + commands = "\n".join(str(step.get("run", "")) for step in steps) + + assert "uv run ruff check src" in commands + assert "uv run mypy src/legis" in commands + assert "uv lock --check" in commands + assert "uv run legis policy-boundary-check --root src --repo-root ." in commands diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index f8b2602..6e6572f 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -139,6 +139,27 @@ def test_install_all_defers_posture_without_custody(tmp_path, monkeypatch, capsy assert led.store.read_all() == [] +def test_install_posture_only_fails_without_custody(tmp_path, monkeypatch, capsys): + # An explicit posture install is an automation readiness check: if key + # custody is not configured, rc must be nonzero and no GENESIS may be written. + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LEGIS_OPERATOR_KEY_AGE_PASSPHRASE", raising=False) + monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False) + + rc = main(["install", "--posture"]) + + assert rc == 1 + out = capsys.readouterr().out + assert "[FAIL] posture ledger:" in out + assert "custody" in out + db = tmp_path / ".weft" / "legis" / "legis-posture.db" + if db.exists(): + from legis.posture import PostureLedger + + led = PostureLedger(install.posture_db_url_for_install(), initialize=False) + assert led.store.read_all() == [] + + def test_install_posture_env_backend_opt_in(tmp_path, monkeypatch, capsys): # --insecure-key-in-env selects the env backend; the env sink is a no-op so # the GENESIS lands with no age blob and no custody refusal. @@ -155,6 +176,30 @@ def test_install_posture_env_backend_opt_in(tmp_path, monkeypatch, capsys): assert not (tmp_path / ".weft" / "legis" / "operator.age").exists() +def test_install_posture_recovers_metadata_only_ledger(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from legis.posture import PostureLedger + + ledger = PostureLedger(install.posture_db_url_for_install(), initialize=True) + ledger.session_opened( + operator_id="alice", + enabled_at="t0", + ttl=300, + keychain_auth_ref=None, + session_id="sess-orphan", + ) + monkeypatch.setenv("LEGIS_OPERATOR_KEY", "ab" * 32) + + rc = main(["install", "--posture", "--insecure-key-in-env"]) + + assert rc == 0 + records = PostureLedger(install.posture_db_url_for_install(), initialize=False).store.read_all() + assert [record.payload["kind"] for record in records] == [ + "OPERATOR_SESSION_OPENED", + "GENESIS", + ] + + # --------------------------------------------------------------------------- # MCP-boot refresh wiring # --------------------------------------------------------------------------- diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 8ed73c6..27017cf 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -371,6 +371,46 @@ def test_mcp_entry_is_current_rejects_repo_local_command(tmp_path): assert mcp_entry_is_current(tmp_path) is False +def test_mcp_entry_is_current_rejects_non_legis_executable(tmp_path): + fake = tmp_path.parent / f"{tmp_path.name}-external" / "fake-runner" + fake.parent.mkdir(parents=True, exist_ok=True) + fake.write_text("#!/bin/sh\n") + fake.chmod(0o755) + _write_mcp_entry( + tmp_path, + {"type": "stdio", "command": str(fake), "args": ["mcp", "--agent-id", "a"]}, + ) + assert mcp_entry_is_current(tmp_path) is False + + +def test_mcp_entry_is_current_rejects_python_module_without_safe_path(tmp_path): + _write_mcp_entry( + tmp_path, + { + "type": "stdio", + "command": sys.executable, + "args": ["-m", "legis", "mcp", "--agent-id", "a"], + }, + ) + assert mcp_entry_is_current(tmp_path) is False + + +def test_mcp_entry_is_current_rejects_fake_python_prefixed_executable(tmp_path): + fake = tmp_path.parent / f"{tmp_path.name}-external" / "python3-fake" + fake.parent.mkdir(parents=True, exist_ok=True) + fake.write_text("#!/bin/sh\n") + fake.chmod(0o755) + _write_mcp_entry( + tmp_path, + { + "type": "stdio", + "command": str(fake), + "args": ["-P", "-m", "legis", "mcp", "--agent-id", "a"], + }, + ) + assert mcp_entry_is_current(tmp_path) is False + + def test_mcp_entry_is_current_rejects_unsafe_or_secret_env(tmp_path): for env in ( {"LEGIS_UNSAFE_DEV_AUTH": "1"}, diff --git a/tests/test_hooks_floor.py b/tests/test_hooks_floor.py index ba6af98..8458721 100644 --- a/tests/test_hooks_floor.py +++ b/tests/test_hooks_floor.py @@ -26,11 +26,14 @@ def _seed_floor(db_url: str, floor: str) -> None: if floor != "chill": class _MemSigner: + def __init__(self, held_key=key): + self._key = held_key + def fingerprint(self) -> str: return fp def sign(self, fields: dict) -> str: - return enf_signing.sign(fields, key, version="v3") + return enf_signing.sign(fields, self._key, version="v3") ledger.transition( floor, diff --git a/tests/test_install.py b/tests/test_install.py index 2a56327..22fab0c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -6,6 +6,7 @@ import logging import os import stat +import sys import pytest @@ -905,13 +906,19 @@ def _touch_exe(path): return path -def _write_legis_mcp_entry(tmp_path, command, env=None, agent_id="claude-code"): +def _write_legis_mcp_entry( + tmp_path, + command, + env=None, + agent_id="claude-code", + args=None, +): (tmp_path / ".mcp.json").write_text( json.dumps( { "mcpServers": { "legis": { - "args": ["mcp", "--agent-id", agent_id], + "args": args or ["mcp", "--agent-id", agent_id], "command": str(command), "env": dict(env or {}), "type": "stdio", @@ -1007,6 +1014,78 @@ def test_register_mcp_json_keeps_usable_command(tmp_path, monkeypatch): assert _read_legis_mcp_entry(tmp_path)["command"] == str(exe) +def test_register_mcp_json_rewrites_non_legis_executable(tmp_path, monkeypatch): + from legis.install import register_mcp_json + + fake = _touch_exe(tmp_path.parent / f"{tmp_path.name}-external" / "fake-runner") + safe_legis = "/opt/bin/legis" + _write_legis_mcp_entry(tmp_path, fake, env={"LEGIS_WARDLINE_CELL": "surface_override"}) + monkeypatch.setattr(install, "_find_legis_command", lambda *_a, **_k: [safe_legis]) + + ok, msg = register_mcp_json(tmp_path) + + assert ok + assert "Registered" in msg + entry = _read_legis_mcp_entry(tmp_path) + assert entry["command"] == safe_legis + assert entry["env"] == {"LEGIS_WARDLINE_CELL": "surface_override"} + + +def test_register_mcp_json_rewrites_python_module_without_safe_path( + tmp_path, + monkeypatch, +): + from legis.install import register_mcp_json + + _write_legis_mcp_entry( + tmp_path, + sys.executable, + env={"LEGIS_WARDLINE_CELL": "surface_override"}, + args=["-m", "legis", "mcp", "--agent-id", "claude-code"], + ) + monkeypatch.setattr( + install, + "_find_legis_command", + lambda *_a, **_k: ["/usr/bin/python3", "-P", "-m", "legis"], + ) + + ok, _ = register_mcp_json(tmp_path) + + assert ok + entry = _read_legis_mcp_entry(tmp_path) + assert entry["command"] == "/usr/bin/python3" + assert entry["args"] == ["-P", "-m", "legis", "mcp", "--agent-id", "claude-code"] + assert entry["env"] == {"LEGIS_WARDLINE_CELL": "surface_override"} + + +def test_register_mcp_json_rewrites_fake_python_prefixed_executable( + tmp_path, + monkeypatch, +): + from legis.install import register_mcp_json + + fake = _touch_exe(tmp_path.parent / f"{tmp_path.name}-external" / "python3-fake") + _write_legis_mcp_entry( + tmp_path, + fake, + env={"LEGIS_WARDLINE_CELL": "surface_override"}, + args=["-P", "-m", "legis", "mcp", "--agent-id", "claude-code"], + ) + monkeypatch.setattr( + install, + "_find_legis_command", + lambda *_a, **_k: ["/usr/bin/python3", "-P", "-m", "legis"], + ) + + ok, _ = register_mcp_json(tmp_path) + + assert ok + entry = _read_legis_mcp_entry(tmp_path) + assert entry["command"] == "/usr/bin/python3" + assert entry["args"] == ["-P", "-m", "legis", "mcp", "--agent-id", "claude-code"] + assert entry["env"] == {"LEGIS_WARDLINE_CELL": "surface_override"} + + def test_register_mcp_json_refreshes_dead_command_but_keeps_env(tmp_path, monkeypatch): from legis.install import register_mcp_json diff --git a/tests/test_site_kit_fetch.py b/tests/test_site_kit_fetch.py new file mode 100644 index 0000000..1cc44fe --- /dev/null +++ b/tests/test_site_kit_fetch.py @@ -0,0 +1,23 @@ +import re +from pathlib import Path + + +def test_site_kit_fetch_is_pinned_to_reviewed_weft_commit(): + script = Path("site/scripts/fetch-site-kit.mjs").read_text(encoding="utf-8") + + match = re.search( + r"const WEFT_SITE_KIT_COMMIT = '([0-9a-f]{40})';", + script, + ) + assert match, "fetch-site-kit must pin @weft/site-kit to a reviewed commit" + assert "fetch', '--depth', '1', 'origin', WEFT_SITE_KIT_COMMIT" in script + assert "checkout', '--detach', 'FETCH_HEAD" in script + assert "rev-parse', 'HEAD" in script + assert "actualCommit !== WEFT_SITE_KIT_COMMIT" in script + + +def test_pages_workflow_fetches_site_kit_before_install(): + workflow = Path(".github/workflows/deploy-site.yml").read_text(encoding="utf-8") + + assert "npm run fetch-site-kit" in workflow + assert "npm install --no-audit --no-fund" in workflow diff --git a/uv.lock b/uv.lock index 37a37c9..367cd58 100644 --- a/uv.lock +++ b/uv.lock @@ -498,7 +498,7 @@ wheels = [ [[package]] name = "legis" -version = "1.0.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "cryptography" },