diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..91cc5a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --dev + - name: Run test suite + run: uv run pytest + - name: Run type check + run: uv run mypy src/legis + + override-rate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --dev + - name: Enforce override-rate gate + run: | + if [ -f legis-governance.db ]; then + uv run legis check-override-rate --db sqlite:///legis-governance.db + else + echo "::notice::No governance DB artifact found; override-rate gate skipped." + fi diff --git a/.github/workflows/override-rate.yml b/.github/workflows/override-rate.yml deleted file mode 100644 index 5d1b466..0000000 --- a/.github/workflows/override-rate.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: override-rate gate -on: - pull_request: - push: - branches: [main] -jobs: - override-rate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - run: pip install -e . - - name: Enforce override-rate gate - run: legis check-override-rate diff --git a/.gitignore b/.gitignore index 852a88b..ff50a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ __pycache__/ *.egg-info/ # Local audit/scratch databases (never commit audit data) *.db +.filigree +.filigree.conf diff --git a/AUDIT-2026-06-04-readonly.md b/AUDIT-2026-06-04-readonly.md new file mode 100644 index 0000000..f55ddd4 --- /dev/null +++ b/AUDIT-2026-06-04-readonly.md @@ -0,0 +1,612 @@ +# Legis Read-Only Codebase Audit + +Date: 2026-06-04 + +Repository: `/home/john/legis` + +Mode: strictly read-only audit of source/test/config surfaces. The only write performed was creation of this requested markdown artifact. + +## Method + +Seven specialized read-only subagents reviewed the codebase: + +- Architecture Critic +- Systems Thinker +- Python Engineer +- Quality Engineer +- Security Architect +- Static Tools Analyst +- MCP and CLI Specialist + +All subagents were instructed to operate with `enable_write_tools=false` and `enable_mcp_tools=false`, avoid write-generating commands, and avoid MCP tools. No test suite, mypy, formatter, server, or package build was run because those can create caches, sqlite files, or other artifacts. + +## Scope Notes + +- `src/legis/scanner/ast_primitives.py`, `src/legis/scanner/rules/`, PY-WL-101..111 rule implementations, SCC/Tarjan logic, and a trust-lattice engine are not present in this repository. The closest live surfaces are Wardline finding ingestion/routing, policy decorator/grammar, check facts, and governance records. +- A YAML policy surface is not present. The closest implementation is TOML exemption loading via `tomllib`. +- An MCP server implementation is not present. The repository has a transport-agnostic service layer and design notes for a future MCP adapter, but no stdio JSON-RPC server or MCP tool registry. + +## Executive Summary + +The highest risks are concentrated at trust boundaries where Legis records governance facts from request-body data supplied by the actor being governed. The main pattern is: caller-provided static-analysis payloads, caller-provided routing choices, caller-provided source bindings, and caller-provided identities become audit evidence without enough independent validation. + +The protected-cell cryptographic story also has material gaps. Some fields that readers will treat as audit evidence are not HMAC-bound, protected verification skips one malformed record class, and protected sign-off binding can use unsigned Clarion metadata. + +No source code was changed during this audit. + +## Findings By Severity + +### Critical + +#### C1. Wardline governance can be bypassed or distorted by caller-shaped scan and routing input + +Locations: + +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:110) lines 110-114 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:517) lines 517-560 +- [src/legis/wardline/ingest.py](/home/john/legis/src/legis/wardline/ingest.py:45) lines 45-65 +- [src/legis/wardline/governor.py](/home/john/legis/src/legis/wardline/governor.py:76) lines 76-88 + +Evidence: + +- `ScanResultsIn.scan` is an untyped `dict`. +- `/wardline/scan-results` accepts `cell` or `cell_by_severity` from the same request that supplies the scan. +- `active_defects()` trusts `kind` and `suppressed` fields and drops anything not `kind == "defect"` and `suppressed == "active"`. +- `WardlineFinding.from_wire()` indexes required fields directly, so malformed payloads can become uncaught `KeyError` or `TypeError` instead of controlled validation failures. +- Per-severity routing defaults unmapped severities to `SURFACE_OVERRIDE`. + +Impact: + +A caller can omit a finding, mark it suppressed, change its kind/severity, choose softer routing, or submit malformed data that crashes the endpoint. For a governance system, that means critical findings can disappear or become soft audit events without an independently verifiable Wardline artifact. + +Remediation: + +1. Replace `ScanResultsIn.scan: dict` with typed Pydantic models for scan, finding, severity, suppression, rule id, fingerprint, qualname, and properties. +2. Make routing policy server-owned. Request bodies should not decide whether a finding is `surface_only`, `surface_override`, or `block_escalate` unless the caller has an explicitly authenticated policy-management scope. +3. Require a signed, hash-pinned, or otherwise authenticated Wardline artifact with scanner identity, commit/tree identity, rule-set version, finding count, active count, and suppression proof. +4. Record a raw scan digest and filtered-count provenance in every routed batch. +5. Treat unknown or unsupported suppression states as fail-closed, either rejected or recorded as a provenance gap. +6. Require total `cell_by_severity` mappings or an explicit configured default. Do not silently map omitted severities to `SURFACE_OVERRIDE`. + +Acceptance tests: + +- Posting a CRITICAL finding with `suppressed: "waived"` and no suppression proof must not disappear; it should reject or create a provenance-gap/block-escalate record. +- Posting `findings: "bad"`, a missing `rule_id`, and severity `BOGUS` should return 422 and write no governance record. +- A request attempting to route CRITICAL to `surface_only` contrary to server policy should be rejected or overridden by server policy. +- A partial severity map containing only `CRITICAL: block_escalate` plus an ERROR finding must not silently route the ERROR to `surface_override`. + +#### C2. Protected verdicts sign caller-supplied source bindings that the judge never evaluated + +Locations: + +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:75) lines 75-81 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:343) lines 343-355 +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:48) lines 48-59 +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:183) lines 183-201 + +Evidence: + +- `ProtectedIn` accepts `file_fingerprint` and `ast_path` from the request body. +- `ProtectedGate.submit()` builds the `OverrideRecord` sent to the judge without `file_fingerprint`, `ast_path`, or Clarion extension context. +- `_record_signed()` later signs those fields into the stored payload. + +Impact: + +The HMAC proves Legis wrote a record containing those source-binding fields, but it does not prove the judge evaluated that source node or source bytes. A caller can bind a judge verdict to a different AST path or fingerprint and create misleading cryptographic audit evidence. + +Remediation: + +1. Stop treating `file_fingerprint` and `ast_path` as caller-authoritative fields. +2. Compute them inside Legis or accept them only as part of a trusted Clarion/Wardline artifact whose digest is verified. +3. Include source-binding context and Clarion lineage/content context in the `OverrideRecord` before `judge.evaluate()`. +4. Sign exactly the judged record and reject any mismatch between judged fields and persisted fields. +5. Add a typed `JudgedProtectedRecord` or equivalent value object so judge input and signed payload cannot drift. + +Acceptance tests: + +- A spy judge should receive the exact `file_fingerprint`, `ast_path`, and Clarion context later signed. +- A request with a fingerprint not matching trusted current content should fail before HMAC signing. +- Mutating source-binding fields between judge evaluation and persistence should be impossible by construction or detected by a unit test. + +#### C3. Protected tamper evidence omits audit fields and skips malformed protected records + +Locations: + +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:39) lines 39-59 +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:76) lines 76-117 +- [src/legis/enforcement/signoff.py](/home/john/legis/src/legis/enforcement/signoff.py:60) lines 60-70 +- [src/legis/records/override_record.py](/home/john/legis/src/legis/records/override_record.py:30) lines 30-38 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:181) lines 181-187 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:413) lines 413-420 + +Evidence: + +- Protected override payloads store `agent_id` and `extensions.judge_rationale`, but `signing_fields()` does not sign either field. +- Protected sign-off signatures omit `extensions.clarion`; later `/signoff/{seq}/bind-issue` reads `extensions.clarion.content_hash` from the stored sign-off request. +- `TrailVerifier.verify()` skips protected-policy records lacking `entity_key` before requiring a signature. +- `LEGIS_PROTECTED_POLICIES` defaults to an empty set; `/protected/overrides` can write a signed record whose policy the verifier later skips because the policy is not configured as protected. + +Impact: + +An attacker with DB-file access can edit attribution, judge rationale, or sign-off Clarion content hash, recompute the unkeyed hash chain, and still pass HMAC verification for some cases. A malformed protected record missing `entity_key` can be skipped entirely. This undermines the protected cell's non-repudiation and tamper-evidence guarantees. + +Remediation: + +1. Introduce `hmac-sha256:v2` signing fields for protected overrides that include `agent_id`, `judge_rationale`, source binding, Clarion content/lineage fields, policy, entity, verdict, model, rationale, and recorded timestamp. +2. Introduce matching v2 signing fields for protected sign-offs, including Clarion content hash and lineage snapshot where present. +3. For protected policies, missing required structural fields should raise `TamperError`; never `continue`. +4. Reject `/protected/overrides` for policies outside the configured protected set, or sign and verify an explicit protected-tier marker independent of policy-name configuration. +5. Before binding a sign-off to Filigree, verify the protected trail and verify the sign-off request payload whose content hash is being used. +6. Add migration/version handling so existing v1 records are either grandfathered explicitly or re-signed. + +Acceptance tests: + +- Tamper `agent_id` and `extensions.judge_rationale`, re-chain sqlite, and assert `TrailVerifier.verify()` plus `GET /overrides` fail closed. +- Remove `entity_key` and signature from a protected record, re-chain, and assert verified reads return an integrity error. +- Mutate a signed sign-off request's `extensions.clarion.content_hash`, re-chain, and assert binding fails without calling Filigree. +- With HMAC enabled and an empty protected-policy set, protected writes should fail startup or fail the request; tampering a protected-endpoint record must still fail verified reads. + +### High + +#### H1. Mutating HTTP endpoints are broadly unauthenticated, and actor identity is request-controlled + +Locations: + +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:52) lines 52-61 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:68) lines 68-114 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:301) lines 301-316 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:367) lines 367-379 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:446) lines 446-454 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:492) lines 492-507 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:517) lines 517-560 +- [src/legis/enforcement/signoff.py](/home/john/legis/src/legis/enforcement/signoff.py:93) lines 93-110 + +Evidence: + +- `verify_operator()` only checks a shared bearer token when `LEGIS_API_SECRET` is set, otherwise it returns `"operator"` without requiring credentials. +- Only operator override and sign-off signing use `verify_operator()`. +- `POST /checks`, `/overrides`, `/protected/overrides`, `/signoff/request`, `/signoff/{seq}/bind-issue`, `/policy/evaluate`, and `/wardline/scan-results` can write state or trigger integrations without required credentials. +- `agent_id` and `operator_id` come from request bodies and are recorded into audit records. +- The authenticated operator value returned by `verify_operator()` is ignored; the body `operator_id` is recorded instead. + +Impact: + +Any reachable client can pollute CI facts, append governance records, create sign-off requests, submit protected override attempts, bind cleared sign-offs to issues, or spoof agent/operator attribution. This weakens audit non-repudiation and lets the governed actor choose who appears responsible. + +Remediation: + +1. Require authentication for every mutating route by default. +2. Add separate scopes/roles for agent writes, operator authority, CI/check ingestion, policy evaluation, Wardline ingestion, and Filigree binding. +3. Derive `agent_id` and `operator_id` from authenticated claims, mTLS identity, launch context, or token mapping. Do not accept them as authoritative JSON fields. +4. Use `hmac.compare_digest()` for shared-token comparison while a shared-token mode exists. +5. If unauthenticated local development is required, gate it behind an explicit unsafe-dev flag and make it noisy at startup. + +Acceptance tests: + +- With `LEGIS_API_SECRET` set, unauthenticated POSTs to every mutating route return 401/403 and write nothing. +- A token/claim for `op-a` with body `operator_id: op-b` records `op-a` or rejects with 403. +- MCP or future adapter schemas must not expose `agent_id` or `operator_id` as ordinary tool arguments. + +#### H2. Clarion lineage failures silently degrade to clean-looking audit state + +Locations: + +- [src/legis/identity/resolver.py](/home/john/legis/src/legis/identity/resolver.py:38) lines 38-52 +- [src/legis/identity/resolver.py](/home/john/legis/src/legis/identity/resolver.py:55) lines 55-72 +- [src/legis/service/governance.py](/home/john/legis/src/legis/service/governance.py:21) lines 21-42 +- [src/legis/governance/gaps.py](/home/john/legis/src/legis/governance/gaps.py:56) lines 56-82 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:474) lines 474-488 + +Evidence: + +- Capability errors are cached as `_capable = False`. +- `IdentityResolver._snapshot()` returns `None` on lineage failure. +- `find_lineage_divergence()` skips records with no snapshot and catches lineage probe exceptions with `continue`. +- API lineage surfaces return empty lists when no client is configured and do not distinguish clean from unverified. + +Impact: + +A Clarion outage, malformed response, or lineage failure can produce locator-keyed records or SEI-keyed records without snapshots. Later integrity checks can report no divergences even though lineage custody was unavailable. + +Remediation: + +1. Add explicit `identity_resolution_status` and `lineage_snapshot_status` fields to recorded governance extensions. +2. For protected/complex writes, decide whether lineage custody is mandatory. If mandatory, fail closed when unavailable. +3. Change lineage APIs to return statuses such as `verified`, `unavailable`, `unverified`, and `divergent`; do not conflate unavailable with clean. +4. Avoid permanently caching transient capability failures as incapable without TTL or retry semantics. +5. Validate lineage snapshot shape before use. + +Acceptance tests: + +- A fake Clarion client resolving an alive SEI but raising on `lineage()` should cause protected writes to fail or record an explicit provenance gap. +- `/governance/lineage-integrity` should report an unavailable/unverified condition when lineage cannot be fetched, not `{"divergences": []}`. + +#### H3. Wardline block-escalate sign-offs drop Clarion and Wardline metadata + +Locations: + +- [src/legis/wardline/governor.py](/home/john/legis/src/legis/wardline/governor.py:18) lines 18-21 +- [src/legis/wardline/governor.py](/home/john/legis/src/legis/wardline/governor.py:83) lines 83-95 +- [src/legis/wardline/governor.py](/home/john/legis/src/legis/wardline/governor.py:96) lines 96-116 +- [src/legis/enforcement/signoff.py](/home/john/legis/src/legis/enforcement/signoff.py:75) lines 75-90 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:413) lines 413-420 + +Evidence: + +- `route_findings()` resolves `clarion_ext` and builds `wardline_ext`. +- `SURFACE_OVERRIDE` and `SURFACE_ONLY` merge and persist those extensions. +- `BLOCK_ESCALATE` calls `signoff.request()` without extensions even though `SignoffGate.request()` accepts them. +- The module docstring still says carrying Wardline tiers is deferred because `SignoffGate.request` has no extensions field, which is now stale. + +Impact: + +The highest-friction human sign-off path loses fingerprint, severity, trust tiers, Clarion content hash, and lineage snapshot. Later Filigree binding may fall back to an empty content hash, and lineage-integrity checks cannot inspect what was signed off. + +Remediation: + +1. Pass `extensions={**clarion_ext, "wardline": wardline_ext}` into the `BLOCK_ESCALATE` branch. +2. Update the stale docstring. +3. Require or explicitly record missing content hash when binding sign-offs to Filigree. +4. Add regression coverage at both unit and API levels. + +Acceptance tests: + +- Route a critical finding through `block_escalate` with an SEI/Clarion resolver and assert the pending sign-off contains `extensions.clarion` and `extensions.wardline`. +- Binding that sign-off should use the Clarion content hash from the signed record. + +#### H4. `check-override-rate` can create an empty database and pass + +Locations: + +- [src/legis/cli.py](/home/john/legis/src/legis/cli.py:43) lines 43-51 +- [src/legis/cli.py](/home/john/legis/src/legis/cli.py:84) lines 84-119 +- [src/legis/store/audit_store.py](/home/john/legis/src/legis/store/audit_store.py:53) lines 53-86 +- [src/legis/store/audit_store.py](/home/john/legis/src/legis/store/audit_store.py:88) lines 88-104 +- [src/legis/enforcement/lifecycle.py](/home/john/legis/src/legis/enforcement/lifecycle.py:73) lines 73-102 + +Evidence: + +- `AuditStore.__init__()` always creates tables and installs triggers. +- `check-override-rate` constructs `AuditStore(args.db)` before verifying and reading. +- Empty records verify cleanly and evaluate as `PASS_WITH_NOTICE`; CLI returns nonzero only for `FAIL`. + +Impact: + +A CI gate pointed at a missing or wrong SQLite path can silently create an empty governance trail and return success. This can make a misconfigured governance gate look clean. + +Remediation: + +1. Add an `AuditStore.open_existing_readonly(url)` mode that refuses missing DB files, missing schema, and write-capable side effects. +2. Use the read-only/open-existing mode in `check-override-rate` and read-only verification paths. +3. Make missing governance DBs a configuration error for CI. +4. Consider a distinct exit code for `PASS_WITH_NOTICE` in CI mode. + +Acceptance tests: + +- Running `check-override-rate` against a nonexistent sqlite URL should exit nonzero and not create a file. +- Running against an existing valid DB should preserve current evaluation behavior. + +#### H5. Policy honesty gate accepts string mentions as behavioral evidence + +Locations: + +- [src/legis/policy/decorator.py](/home/john/legis/src/legis/policy/decorator.py:188) lines 188-229 +- [tests/policy/test_honesty_gate.py](/home/john/legis/tests/policy/test_honesty_gate.py:9) lines 9-12 +- [tests/policy/test_honesty_gate.py](/home/john/legis/tests/policy/test_honesty_gate.py:33) lines 33-36 + +Evidence: + +- `check_policy_boundary()` treats `ast.Name` and string constants containing the function or policy name as evidence that a test exercises a boundary. +- The existing positive test only assigns a string containing `handler` and `no-eval` and asserts the string contains `no-eval`. + +Impact: + +A policy boundary can pass the honesty gate with a pinned test that never calls the decorated function and never asserts behavior at the boundary. That weakens the anti-vibe guarantee the decorator is intended to provide. + +Remediation: + +1. Remove string-constant fallback as positive proof for function calls. +2. Require an actual `ast.Call` to the decorated function or a configured helper known to exercise it. +3. Require at least one meaningful assertion path tied to the suppressed policy. +4. Keep fingerprint pinning, but treat it as freshness proof, not behavioral proof. + +Acceptance tests: + +- The current string-only fake test should fail. +- A real test that calls the decorated function and asserts the relevant policy behavior should pass. + +#### H6. MCP server is absent and the service layer is too narrow for MCP/HTTP parity + +Locations: + +- [pyproject.toml](/home/john/legis/pyproject.toml:15) lines 15-16 +- [src/legis/cli.py](/home/john/legis/src/legis/cli.py:11) lines 11-52 +- [CHANGELOG.md](/home/john/legis/CHANGELOG.md:40) lines 40-45 +- [src/legis/service/__init__.py](/home/john/legis/src/legis/service/__init__.py:1) lines 1-26 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:343) lines 343-455 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:492) lines 492-561 + +Evidence: + +- The only console script is `legis = "legis.cli:main"`. +- CLI subcommands are `serve` and `check-override-rate`; no `legis mcp` exists. +- No `src/legis/mcp.py` or `src/legis/mcp/` implementation exists. +- Changelog states MCP WP-M2..M6 are not yet built. +- The service layer exports only resolution, verified records, override rate, and simple submit override. Protected overrides, sign-off, binding, policy evaluation, Wardline routing, git/check reads, and many error mappings remain inline in FastAPI closures. + +Impact: + +There is no MCP-over-stdio protocol to audit: no `initialize`, `tools/list`, `tools/call`, tool schemas, launch-bound agent identity, or structured MCP error mapping. If implemented now by reusing HTTP route code directly, MCP would likely duplicate logic or expose a partial behavior surface. + +Remediation: + +1. Extract service functions for protected override, operator override, sign-off request/sign, binding, policy evaluation, Wardline scan routing, git reads, and check reads. +2. Make HTTP and MCP thin transport adapters over the same services. +3. Add `legis mcp` and a stdlib JSON-RPC server with an explicit tool registry and schemas. +4. Bind MCP `agent_id` at process launch or authenticated session context, not per tool-call JSON. +5. Add table-driven parity tests comparing service, HTTP, and MCP mapped outcomes. + +Acceptance tests: + +- Spawn `legis mcp --agent-id agent-1`, send `initialize` and `tools/list`, and assert expected tools appear while operator-authority tools do not. +- MCP tool schemas should not include `agent_id` or `operator_id`. +- Disabled protected cell, pending sign-off, unknown policy, invalid Wardline cell, and tampered audit trail should map consistently across service, HTTP, and MCP. + +### Medium + +#### M1. Static-analysis trust tiers and check facts are loosely validated mutable assertions + +Locations: + +- [src/legis/wardline/ingest.py](/home/john/legis/src/legis/wardline/ingest.py:15) lines 15-18 +- [src/legis/wardline/ingest.py](/home/john/legis/src/legis/wardline/ingest.py:53) lines 53-54 +- [src/legis/wardline/governor.py](/home/john/legis/src/legis/wardline/governor.py:87) lines 87-88 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:121) lines 121-132 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:281) lines 281-285 +- [src/legis/checks/surface.py](/home/john/legis/src/legis/checks/surface.py:50) lines 50-67 +- [src/legis/checks/surface.py](/home/john/legis/src/legis/checks/surface.py:101) lines 101-105 + +Evidence: + +- `TRUST_TIERS` is declared but not enforced. +- `properties` is copied verbatim and recorded as `wardline.tiers`. +- `/checks` accepts caller-supplied `commit_sha`, `run_id`, `rule_set`, and `policy_version`; `latest_state()` is last-write-wins by `check_name`. + +Impact: + +Governance records can contain non-lattice tier values, and check facts can be spoofed or overwritten unless the deployment adds external authentication and integrity controls. + +Remediation: + +1. Parse known Wardline trust fields into a typed structure. +2. Validate every tier against `TRUST_TIERS`; preserve unknown fields separately as untrusted metadata. +3. Authenticate CI/check writers and require run identity uniqueness. +4. Validate commit SHAs against the git surface where practical. +5. Model supersession explicitly instead of implicit last-write-wins. + +Acceptance tests: + +- `properties={"actual_return": "ROOT"}` should be rejected or recorded as invalid/untrusted, not as `extensions.wardline.tiers`. +- Unauthenticated duplicate `wardline` pass for a failed commit should be rejected or retained only as a non-authoritative separate event. + +#### M2. External URL, DB, secret, and response boundaries are weakly confined + +Locations: + +- [src/legis/cli.py](/home/john/legis/src/legis/cli.py:14) lines 14-40 +- [src/legis/cli.py](/home/john/legis/src/legis/cli.py:66) lines 66-79 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:157) lines 157-168 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:177) lines 177-224 +- [src/legis/identity/clarion_client.py](/home/john/legis/src/legis/identity/clarion_client.py:40) lines 40-55 +- [src/legis/filigree/client.py](/home/john/legis/src/legis/filigree/client.py:31) lines 31-45 + +Evidence: + +- CLI/env strings flow directly into SQLAlchemy URLs and urllib base URLs. +- Clarion and Filigree clients only `rstrip("/")` the base URL. +- `urlopen(...).read()` loads full response bodies. +- `--hmac-key` accepts a raw signing secret on the command line and copies it to `LEGIS_HMAC_KEY`. + +Impact: + +Misconfiguration or compromised launch environment can point Legis at unexpected services or DB locations. Large responses can consume memory. Raw HMAC keys can leak through shell history, process lists, or CI logs. + +Remediation: + +1. Validate URL scheme and host at startup; require HTTPS except explicit loopback/dev allowlist. +2. Add auth headers or mTLS for Clarion/Filigree where deployments are not strictly loopback. +3. Add response byte caps and content-type checks before JSON parsing. +4. Validate DB URLs and optionally confine state paths. +5. Remove `--hmac-key`; replace with env-only, secret file with strict permissions, or secret manager/KMS integration. + +Acceptance tests: + +- `file://` Clarion/Filigree URLs should fail at client construction. +- Non-allowlisted remote hosts should fail unless an explicit remote opt-in is set. +- Oversized responses should raise controlled client errors. +- Parser should reject `--hmac-key`; `--hmac-key-file` should reject group/world-readable files. + +#### M3. Git read error mapping and DoS controls are incomplete + +Locations: + +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:252) lines 252-265 +- [src/legis/git/surface.py](/home/john/legis/src/legis/git/surface.py:26) lines 26-31 +- [src/legis/git/surface.py](/home/john/legis/src/legis/git/surface.py:127) lines 127-130 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:68) lines 68-114 +- [src/legis/wardline/ingest.py](/home/john/legis/src/legis/wardline/ingest.py:58) lines 58-65 + +Evidence: + +- `/git/commits/{sha}` catches `GitError`, but `/git/branches` and `/git/renames` do not. +- `GitSurface.renames()` raises `GitError` for invalid revision ranges. +- Git subprocesses have no timeout. +- Request models have no string length, body size, or findings count limits. + +Impact: + +Invalid git inputs can become 500s instead of structured 4xx errors. Large scan bodies, broad rename ranges, or slow git operations can consume CPU, memory, sqlite writes, or process slots. + +Remediation: + +1. Catch `GitError` consistently for all git endpoints and map invalid refs/ranges to 400 or 422. +2. Add subprocess timeouts and, if needed, max output caps. +3. Add Pydantic `Field` length constraints and batch size limits. +4. Add request body limits and rate limits at the ASGI/server layer. +5. Ensure batch routing either prevalidates all findings or has transactional/no-partial-write semantics. + +Acceptance tests: + +- `GET /git/renames?rev_range=--version` should return a stable 4xx JSON error, not 500. +- Oversized Wardline scan input should return 413/422 without partial writes. +- A deliberately slow git command in a controlled test double should time out with a structured error. + +#### M4. Clarion and Filigree JSON response shapes are not validated at the transport seam + +Locations: + +- [src/legis/identity/clarion_client.py](/home/john/legis/src/legis/identity/clarion_client.py:40) lines 40-49 +- [src/legis/identity/clarion_client.py](/home/john/legis/src/legis/identity/clarion_client.py:71) lines 71-74 +- [src/legis/identity/resolver.py](/home/john/legis/src/legis/identity/resolver.py:59) lines 59-72 +- [src/legis/filigree/client.py](/home/john/legis/src/legis/filigree/client.py:31) lines 31-40 +- [src/legis/filigree/client.py](/home/john/legis/src/legis/filigree/client.py:56) lines 56-59 + +Evidence: + +- `_urllib_fetch()` is annotated as returning `dict`, but `json.loads()` can decode any JSON type. +- `resolve()` uses `res["sei"]` when `alive` is true without validating required fields. +- `lineage()` and `associations_for_entity()` call `.get()` without checking decoded body is a mapping. + +Impact: + +Malformed upstream responses can cause raw `AttributeError`, `KeyError`, or incorrect degradation instead of controlled client errors or documented fail-closed behavior. + +Remediation: + +1. Validate decoded JSON type immediately in `_urllib_fetch()`. +2. Add response-shape validators for capability, resolve, SEI resolve, lineage, attach, and associations. +3. Convert malformed responses into `ClarionError` or `FiligreeError`. +4. Decide which call sites should degrade and which should fail closed. + +Acceptance tests: + +- Fake fetch returning `[]`, `{"alive": true}`, and `{"lineage": "not-list"}` should produce controlled errors or explicit degradation, never raw key/attribute errors. + +#### M5. API composition is tightly coupled and uses private/internal state across layers + +Locations: + +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:26) lines 26-47 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:141) lines 141-236 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:301) lines 301-560 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:303) line 303 +- [src/legis/service/governance.py](/home/john/legis/src/legis/service/governance.py:63) lines 63-67 +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:72) lines 72-74 +- [src/legis/enforcement/protected.py](/home/john/legis/src/legis/enforcement/protected.py:120) lines 120-127 + +Evidence: + +- `api/app.py` imports and orchestrates almost every domain package. +- The API reads `trail_verifier._protected`. +- The service layer uses `getattr(protected_gate, "_store", None)` to verify hash-chain integrity. + +Impact: + +Future adapters can easily duplicate or bypass behavior. Fake or alternate gate/verifier implementations can satisfy visible methods but skip protected-policy rejection or hash-chain verification because those requirements live in private attributes. + +Remediation: + +1. Extract a runtime/application service layer that owns workflow orchestration. +2. Add public contracts such as `TrailVerifier.protected_policies`, `ProtectedGate.verify_integrity()`, or a `VerifiedTrail` protocol. +3. Keep HTTP route handlers limited to request parsing and transport error mapping. +4. Add import-boundary tests for API modules. + +Acceptance tests: + +- Fake gate/verifier implementations exposing only public protocols should still let `/overrides` reject protected policies and verified reads fail closed on hash-chain failure. +- Route tests should be able to use injected runtime/service fakes without importing concrete stores. + +#### M6. Public typing surface is not ready for `py.typed` + +Locations: + +- [pyproject.toml](/home/john/legis/pyproject.toml:18) lines 18-22 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:105) lines 105-114 +- [src/legis/service/governance.py](/home/john/legis/src/legis/service/governance.py:21) lines 21-49 +- [src/legis/checks/surface.py](/home/john/legis/src/legis/checks/surface.py:69) lines 69-83 + +Evidence: + +- `src/legis/py.typed` exists, but `pyproject.toml` has no mypy config or mypy dev dependency. +- Several boundaries use bare `dict`, bare `list`, untyped `records`, untyped `whereclause`, and untyped row parameters. + +Impact: + +Downstream consumers will treat Legis as a typed package, but important APIs leak implicit `Any` and strict checking will be noisy or unreliable. + +Remediation: + +1. Add mypy or pyright configuration and dev dependency. +2. Replace bare containers with `dict[str, Any]`, `Mapping[str, Any]`, `TypedDict`, Pydantic models, or Protocols. +3. Type store record iterables and SQLAlchemy row conversions. +4. Gate CI on the chosen type checker once baseline is clean. + +Acceptance tests: + +- `uv run mypy src/legis` or the chosen equivalent should pass under the agreed config. +- Built distributions should include `legis/py.typed`. + +#### M7. CI and test hygiene gaps reduce regression protection + +Locations: + +- [.github/workflows/override-rate.yml](/home/john/legis/.github/workflows/override-rate.yml:15) lines 15-17 +- [pyproject.toml](/home/john/legis/pyproject.toml:28) lines 28-36 +- [tests/enforcement/test_regressions.py](/home/john/legis/tests/enforcement/test_regressions.py:42) lines 42-58 +- [tests/enforcement/test_regressions.py](/home/john/legis/tests/enforcement/test_regressions.py:61) lines 61-139 +- [src/legis/api/app.py](/home/john/legis/src/legis/api/app.py:170) lines 170-211 +- [src/legis/store/audit_store.py](/home/john/legis/src/legis/store/audit_store.py:85) lines 85-86 + +Evidence: + +- The GitHub workflow installs the package and runs only `legis check-override-rate`. +- There is no CI `pytest`, lint, or static type job. +- Some tests mutate `os.environ` directly and `pop` keys instead of restoring prior values. +- One regression test enables HMAC without setting governance/binding DB env vars, so `create_app()` can fall back to repo-root DB files. +- Pytest has no marker split, while tests mix sqlite, threading, git subprocesses, and FastAPI clients. + +Impact: + +Substantial local test coverage is not a merge gate. Tests can become order-dependent, pollute local repo state, or hide regressions in skipped CI surfaces. + +Remediation: + +1. Add CI jobs for `uv run pytest` and selected static checks. +2. Use `monkeypatch` for environment changes. +3. Clear or isolate all `LEGIS_*`, `CLARION_API_URL`, and `FILIGREE_API_URL` settings per test. +4. Point default DBs to `tmp_path` in tests that create app state. +5. Add pytest markers such as `unit`, `integration`, `api`, and `contract`. + +Acceptance tests: + +- A PR with a deliberately failing test fails CI. +- Pre-seeded environment variables are restored after app setup tests. +- Running targeted app setup tests creates no repo-root `.db` files. +- Pytest collection can select pure unit tests separately from sqlite/git/API tests. + +## Remediation Roadmap + +1. Secure the evidence boundary first: type and authenticate Wardline scan ingestion, remove caller-owned routing, and record artifact digests/provenance. +2. Repair protected-cell HMAC semantics: v2 signing fields, no verifier skips for malformed protected records, sign-off Clarion metadata bound into signatures, and protected endpoint/config alignment. +3. Move actor identity out of request bodies: authenticated agent/operator/CI scopes, adapter launch context for MCP, and default-deny mutating endpoints. +4. Decide Clarion fail-closed policy: record explicit identity/lineage statuses, and make protected/complex writes fail or loudly mark provenance gaps when custody is unavailable. +5. Extract shared services before MCP implementation: protected override, sign-off, binding, policy evaluation, Wardline routing, git/check reads, and structured errors. +6. Add read-only store open modes and fix CI gate behavior for missing DB/schema. +7. Harden integration and transport inputs: URL allowlists, response caps, DB path validation, git subprocess timeouts, and request size limits. +8. Add missing regression tests and CI: pytest, type checking, API error mapping, HMAC tamper cases, Wardline metadata preservation, and MCP/HTTP parity tests once MCP lands. + +## Residual Risks + +- This audit did not run tests or dynamic probes by request, so execution-time behavior was inferred from source. +- Live Clarion and Filigree contract drift was not tested; current tests use fakes. +- The absent scanner/rules and MCP server mean those specific implementations could not be audited; only the closest present code and design seams were reviewed. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d40a64 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to Legis are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project +versions per [PEP 440](https://peps.python.org/pep-0440/) / +[SemVer](https://semver.org/) (pre-release: `1.0.0rc1`). + +## [1.0.0rc1] — 2026-06-03 + +First release candidate for 1.0. Everything built through Sprint 6 plus the +WP-M1 service-layer extraction, consolidated behind a stable version. + +### Added +- **git/CI surface** — stateless `GitSurface` (branches, commits, renames with + `-M`, merge-base) and a recorded CI `CheckSurface`, exposed over `/git/*` and + `/checks/*`; injectable `PullRequestSource` seam with `/git/pull-requests/{n}`. +- **Graded 2×2 enforcement engine** — chill / coached / structured / protected + cells; LLM judge behind an injected `LLMClient` seam (fail-closed verdict + parsing); HMAC-signed protected verdicts; decay sweep and the override-rate + gate (`legis check-override-rate`, exits 1 on FAIL). +- **Agent-programmable policy grammar** — `/policy/evaluate` returning + CLEAR / VIOLATION / UNKNOWN, with honest `provenance_gap` events (no silent + false-green); TOML-backed one-off exemptions. +- **SEI-keyed attestations** — `identity/clarion_client.py` + resolver + (resolve-then-key, honest degrade, lineage snapshot); all governance write + paths key on Stable Entity Identity when alive; `/governance/identity-gaps` + and `/governance/lineage-integrity` read surfaces. +- **Suite combinations** — Wardline findings route into the 2×2 via + `/wardline/scan-results`; governed SEI-keyed sign-off binding to Filigree + issues via a tamper-bound `BindingLedger`. +- **Console scripts** — `legis serve` (uvicorn factory) and + `legis check-override-rate`. +- **Transport-agnostic service layer (WP-M1)** — `legis.service` extracts the + cross-cutting governance logic (`resolve_for_record`, `verified_records`, + `compute_override_rate`, the `submit_override` seam) out of the FastAPI route + closures and raises domain errors (`ServiceError` subclasses) rather than + `HTTPException`, so both HTTP and the forthcoming MCP adapter drive one code + path. Behavior-preserving; FastAPI handlers are now thin adapters. + +### Known limitations +- The agent-facing **MCP surface** is designed and decomposed + (`docs/superpowers/specs/2026-06-03-legis-mcp-surface-design.md`) with WP-M1 + landed; WP-M2..M6 (registry + `legis_explain`, the MCP stdio server, the + write/governance tools, safety hardening, judge reason-classification) are not + yet built. +- The git-rename provider to Clarion is contract-locked but operatively gated on + Clarion driving a committed rev-range. +- `HttpClarion` runs loopback-unauthenticated; sibling-gated work packages + (Filigree signature column, live-Clarion oracle + HMAC auth, operative + git-rename feed) remain. + +[1.0.0rc1]: https://peps.python.org/pep-0440/ diff --git a/README.md b/README.md index b53b3bf..54343fc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Legis is the fourth Loom product: the git/CI and governance side of the suite's ## Status -Legis is **implemented through Sprint 6**. The standalone git/CI surfaces, the graded 2×2 enforcement engine, the agent-programmable policy grammar, SEI-keyed attestations, and the Wardline/Filigree suite combinations are all built and tested; the git-rename provider to Clarion is contract-locked, operative pending Clarion's committed-range driving. See the combination matrix below for per-pairing status. +Legis is at **`1.0.0rc1`** — the first release candidate. The standalone git/CI surfaces, the graded 2×2 enforcement engine, the agent-programmable policy grammar, SEI-keyed attestations, and the Wardline/Filigree suite combinations are all built and tested; the git-rename provider to Clarion is contract-locked, operative pending Clarion's committed-range driving. The transport-agnostic service layer (WP-M1) underpinning the forthcoming agent-facing MCP surface has landed. See the combination matrix below for per-pairing status and `CHANGELOG.md` for the release notes. ## The Loom suite diff --git a/pyproject.toml b/pyproject.toml index 14591cf..1df3026 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "legis" -version = "0.1.0" +version = "1.0.0rc2" description = "Legis — the git/CI + governance layer of the Loom suite" authors = [ - { name = "John Morrissey", email = "john@pgpl.net" } + { name = "John Morrissey", email = "john@foundryside.dev" } ] requires-python = ">=3.12" dependencies = [ @@ -19,6 +19,7 @@ legis = "legis.cli:main" dev = [ "pytest>=8.0", "httpx>=0.27", + "mypy>=1.19", ] [build-system] @@ -34,3 +35,9 @@ filterwarnings = [ # Matched by message (category is StarletteDeprecationWarning, not DeprecationWarning). "ignore:Using `httpx` with `starlette.testclient` is deprecated", ] + +[tool.mypy] +python_version = "3.12" +files = ["src/legis"] +show_error_codes = true +warn_unused_configs = true diff --git a/src/legis/__init__.py b/src/legis/__init__.py index 9f64484..6de7aac 100644 --- a/src/legis/__init__.py +++ b/src/legis/__init__.py @@ -1,3 +1,3 @@ """Legis — the git/CI + governance layer of the Loom suite.""" -__version__ = "0.1.0" +__version__ = "1.0.0rc2" diff --git a/src/legis/api/app.py b/src/legis/api/app.py index 989773a..ab12ea8 100644 --- a/src/legis/api/app.py +++ b/src/legis/api/app.py @@ -15,34 +15,124 @@ from __future__ import annotations import os +import hmac from dataclasses import asdict from pathlib import Path +from typing import Any -from fastapi import FastAPI, HTTPException, Query, Response +from fastapi import Depends, FastAPI, HTTPException, Query, Response, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel from legis import __version__ from legis.checks.models import CheckOutcome, CheckRun from legis.checks.surface import CheckSurface from legis.enforcement.engine import EnforcementEngine -from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.protected import ProtectedGate, TamperError, TrailVerifier from legis.enforcement.signoff import SignoffGate from legis.git.pull_request import PullRequestSource from legis.git.surface import GitError, GitSurface -from legis.governance.gaps import find_lineage_divergence, find_orphan_gaps +from legis.governance.gaps import find_lineage_integrity, find_orphan_gaps from legis.filigree.client import FiligreeClient from legis.governance.binding_ledger import BindingError, BindingLedger from legis.governance.signoff_binding import bind_signoff_to_issue from legis.identity.entity_key import EntityKey from legis.identity.resolver import IdentityResolver -from legis.service.errors import AuditIntegrityError +from legis.service.errors import AuditIntegrityError, InvalidArgumentError, NotEnabledError from legis.service.governance import compute_override_rate as _compute_override_rate +from legis.service.governance import evaluate_policy as _evaluate_policy from legis.service.governance import resolve_for_record as _resolve_for_record +from legis.service.governance import submit_operator_override as _submit_operator_override from legis.service.governance import submit_override as _submit_override +from legis.service.governance import submit_protected_override as _submit_protected_override from legis.service.governance import verified_records as _verified_records -from legis.policy.grammar import PolicyGrammar, PolicyResult, default_grammar -from legis.wardline.governor import WardlineCellPolicy, route_findings -from legis.wardline.ingest import WardlineSeverity, active_defects +from legis.service.wardline import route_wardline_scan as _route_wardline_scan +from legis.policy.grammar import PolicyGrammar, default_grammar +from legis.wardline.governor import WardlineCellPolicy +from legis.wardline.ingest import WardlinePayloadError, WardlineSeverity + +security = HTTPBearer(auto_error=False) + + +def _token_actor_from_mapping( + credentials: HTTPAuthorizationCredentials | None, + default_actor: str, + required_scope: str, +) -> str | None: + mapping = os.environ.get("LEGIS_API_TOKEN_ACTORS") + if not mapping: + return None + if not credentials: + raise HTTPException( + status_code=401, + detail="Invalid or missing API secret token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + for entry in mapping.split(","): + actor_spec, sep, token = entry.partition("=") + if not sep: + continue + if hmac.compare_digest(credentials.credentials, token): + actor, scope_sep, scope_raw = actor_spec.partition(":") + scopes = {scope.strip() for scope in scope_raw.split("|") if scope.strip()} + if scope_sep and required_scope not in scopes: + raise HTTPException( + status_code=403, + detail=f"Token is not authorized for {required_scope!r} operations.", + ) + return actor.strip() or default_actor + raise HTTPException( + status_code=401, + detail="Invalid or missing API secret token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _verify_secret( + credentials: HTTPAuthorizationCredentials | None, + default_actor: str, + required_scope: str, +) -> str: + mapped_actor = _token_actor_from_mapping(credentials, default_actor, required_scope) + if mapped_actor is not None: + return mapped_actor + secret = os.environ.get("LEGIS_API_SECRET") + if secret: + if not credentials or not hmac.compare_digest(credentials.credentials, secret): + raise HTTPException( + status_code=401, + detail="Invalid or missing API secret token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return os.environ.get("LEGIS_API_ACTOR", default_actor) + if _unsafe_dev_auth_enabled(): + return default_actor + raise HTTPException( + status_code=401, + detail="Authentication is required; set LEGIS_UNSAFE_DEV_AUTH=1 only for local development.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _authenticated_actor_configured() -> bool: + return bool(os.environ.get("LEGIS_API_SECRET") or os.environ.get("LEGIS_API_TOKEN_ACTORS")) + + +def _unsafe_dev_auth_enabled() -> bool: + return os.environ.get("LEGIS_UNSAFE_DEV_AUTH") == "1" + + +def _recorded_actor(authenticated_actor: str, body_actor: str | None) -> str: + return authenticated_actor if _authenticated_actor_configured() else (body_actor or authenticated_actor) + + +def verify_writer(credentials: HTTPAuthorizationCredentials | None = Security(security)) -> str: + return _verify_secret(credentials, "agent", "writer") + + +def verify_operator(credentials: HTTPAuthorizationCredentials | None = Security(security)) -> str: + return _verify_secret(credentials, "operator", "operator") + DEFAULT_CHECK_DB = "sqlite:///legis-checks.db" DEFAULT_GOVERNANCE_DB = "sqlite:///legis-governance.db" @@ -52,14 +142,14 @@ class OverrideIn(BaseModel): policy: str entity: str # a locator today (pre-SEI); identity_stable=False rationale: str - agent_id: str + agent_id: str | None = None class ProtectedIn(BaseModel): policy: str entity: str rationale: str - agent_id: str + agent_id: str | None = None file_fingerprint: str ast_path: str @@ -68,7 +158,7 @@ class OperatorOverrideIn(BaseModel): policy: str entity: str rationale: str - operator_id: str + operator_id: str | None = None file_fingerprint: str ast_path: str @@ -77,11 +167,11 @@ class SignoffRequestIn(BaseModel): policy: str entity: str rationale: str - agent_id: str + agent_id: str | None = None class SignoffSignIn(BaseModel): - operator_id: str + operator_id: str | None = None rationale: str = "" @@ -91,7 +181,7 @@ class PolicyEvalIn(BaseModel): class ScanResultsIn(BaseModel): - agent_id: str + agent_id: str | None = None scan: dict cell: str | None = None cell_by_severity: dict[str, str] | None = None @@ -115,6 +205,22 @@ class CheckRunIn(BaseModel): finished_at: str | None = None +def _parse_wardline_cell_map(raw: str) -> dict[WardlineSeverity, WardlineCellPolicy]: + mapping: dict[WardlineSeverity, WardlineCellPolicy] = {} + for part in raw.split(","): + if not part.strip(): + continue + severity_raw, sep, cell_raw = part.partition("=") + if not sep: + raise ValueError("cell map entries must be SEVERITY=cell") + mapping[WardlineSeverity[severity_raw.strip()]] = WardlineCellPolicy( + cell_raw.strip() + ) + if not mapping: + raise ValueError("cell map must not be empty") + return mapping + + def _check_to_dict(run: CheckRun) -> dict: d = asdict(run) d["outcome"] = run.outcome.value @@ -135,7 +241,65 @@ def create_app( pull_requests: PullRequestSource | None = None, ) -> FastAPI: app = FastAPI(title="legis", version=__version__) - state: dict[str, object | None] = { + source_root = Path(repo_path) if repo_path is not None else Path(os.getcwd()) + + # Fallback configuration loaders from environment variables if not injected + if identity is None: + clarion_url = os.environ.get("CLARION_API_URL") + if clarion_url: + from legis.identity.clarion_client import HttpClarionIdentity + from legis.identity.resolver import IdentityResolver + identity = IdentityResolver(HttpClarionIdentity(clarion_url)) + + if filigree is None: + filigree_url = os.environ.get("FILIGREE_API_URL") + if filigree_url: + from legis.filigree.client import HttpFiligreeClient + filigree = HttpFiligreeClient(filigree_url) + + hmac_key_str = os.environ.get("LEGIS_HMAC_KEY") + hmac_key = hmac_key_str.encode("utf-8") if hmac_key_str else None + + if hmac_key: + from legis.clock import SystemClock + from legis.store.audit_store import AuditStore + + gov_db_url = os.environ.get("LEGIS_GOVERNANCE_DB", DEFAULT_GOVERNANCE_DB) + gov_store = AuditStore(gov_db_url) + clock = SystemClock() + + if trail_verifier is None: + from legis.enforcement.protected import TrailVerifier + protected_policies_str = os.environ.get("LEGIS_PROTECTED_POLICIES", "") + protected_policies = frozenset( + p.strip() for p in protected_policies_str.split(",") if p.strip() + ) + trail_verifier = TrailVerifier(hmac_key, protected_policies) + + if protected_gate is None: + from legis.enforcement.protected import ProtectedGate + from legis.enforcement.verdict import JudgeOpinion, Verdict + from legis.records.override_record import OverrideRecord + + class FailClosedJudge: + def evaluate(self, record: OverrideRecord) -> JudgeOpinion: + return JudgeOpinion( + verdict=Verdict.BLOCKED, + model="fail-closed-fallback", + rationale="No LLM judge client is configured on this server.", + ) + + protected_gate = ProtectedGate(gov_store, clock, FailClosedJudge(), hmac_key) + + if signoff_gate is None: + from legis.enforcement.signoff import SignoffGate + signoff_gate = SignoffGate(gov_store, clock, signer=True, key=hmac_key) + + if binding_ledger is None: + from legis.governance.binding_ledger import BindingLedger + bind_db_url = os.environ.get("LEGIS_BINDING_DB", "sqlite:///legis-binding.db") + binding_ledger = BindingLedger(AuditStore(bind_db_url), clock, hmac_key) + state: dict[str, Any] = { "checks": check_surface, "enforcement": enforcement, "grammar": grammar, @@ -146,7 +310,8 @@ def git() -> GitSurface: def checks() -> CheckSurface: if state["checks"] is None: - state["checks"] = CheckSurface(DEFAULT_CHECK_DB) + check_db = os.environ.get("LEGIS_CHECK_DB", DEFAULT_CHECK_DB) + state["checks"] = CheckSurface(check_db) return state["checks"] def engine() -> EnforcementEngine: @@ -154,8 +319,9 @@ def engine() -> EnforcementEngine: from legis.clock import SystemClock from legis.store.audit_store import AuditStore + gov_db_url = os.environ.get("LEGIS_GOVERNANCE_DB", DEFAULT_GOVERNANCE_DB) state["enforcement"] = EnforcementEngine( - AuditStore(DEFAULT_GOVERNANCE_DB), SystemClock() + AuditStore(gov_db_url), SystemClock() ) return state["enforcement"] @@ -175,7 +341,10 @@ def health() -> dict[str, str]: @app.get("/git/branches") def git_branches() -> list[dict]: - return [asdict(b) for b in git().branches()] + try: + return [asdict(b) for b in git().branches()] + except GitError as exc: + raise HTTPException(status_code=400, detail=str(exc)) @app.get("/git/commits/{sha}") def git_commit(sha: str) -> dict: @@ -186,7 +355,10 @@ def git_commit(sha: str) -> dict: @app.get("/git/renames") def git_renames(rev_range: str = Query(...)) -> list[dict]: - return [asdict(r) for r in git().renames(rev_range)] + try: + return [asdict(r) for r in git().renames(rev_range)] + except GitError as exc: + raise HTTPException(status_code=400, detail=str(exc)) @app.get("/git/pull-requests/{number}") def get_pull_request(number: int) -> dict: @@ -203,7 +375,7 @@ def get_pull_request(number: int) -> dict: # --- CI/check surface (WP-1.2) --- @app.post("/checks", status_code=201) - def post_check(run: CheckRunIn) -> dict: + def post_check(run: CheckRunIn, actor: str = Depends(verify_writer)) -> dict: cr = CheckRun(**run.model_dump()) checks().record(cr) return _check_to_dict(cr) @@ -223,14 +395,22 @@ def checks_for_pr(pr: int) -> list[dict]: # --- simple-tier enforcement surface (WP-2.1 chill / WP-2.2 coached) --- @app.post("/overrides") - def post_override(body: OverrideIn, response: Response) -> dict: + def post_override(body: OverrideIn, response: Response, actor: str = Depends(verify_writer)) -> dict: + protected_set = ( + trail_verifier.protected_policies if trail_verifier is not None else frozenset() + ) + if body.policy in protected_set: + raise HTTPException( + status_code=403, + detail=f"Policy {body.policy!r} is protected; use the protected overrides endpoint instead." + ) result = _submit_override( engine(), identity=identity, policy=body.policy, entity=body.entity, rationale=body.rationale, - agent_id=body.agent_id, + agent_id=_recorded_actor(actor, body.agent_id), ) # ACCEPTED → 201 (the override took effect); BLOCKED → 409 (it did not, # the agent must correct or convince). Full body either way so the agent @@ -259,19 +439,25 @@ def get_overrides() -> list[dict]: # --- complex-tier enforcement surface (WP-3.1 structured / WP-3.2 protected) --- @app.post("/protected/overrides") - def post_protected_override(body: ProtectedIn, response: Response) -> dict: - if protected_gate is None: - raise HTTPException(status_code=404, detail="protected cell not enabled") - entity_key, ext = resolve_for_record(body.entity) - result = protected_gate.submit( - policy=body.policy, - entity_key=entity_key, - rationale=body.rationale, - agent_id=body.agent_id, - file_fingerprint=body.file_fingerprint, - ast_path=body.ast_path, - extensions=ext, - ) + def post_protected_override( + body: ProtectedIn, response: Response, actor: str = Depends(verify_writer) + ) -> dict: + try: + result = _submit_protected_override( + protected_gate, + identity=identity, + policy=body.policy, + entity=body.entity, + rationale=body.rationale, + agent_id=_recorded_actor(actor, body.agent_id), + file_fingerprint=body.file_fingerprint, + ast_path=body.ast_path, + source_root=source_root, + ) + except NotEnabledError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except InvalidArgumentError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc response.status_code = 201 if result.accepted else 409 return { "accepted": result.accepted, @@ -283,19 +469,23 @@ def post_protected_override(body: ProtectedIn, response: Response) -> dict: } @app.post("/protected/operator-override", status_code=201) - def post_operator_override(body: OperatorOverrideIn) -> dict: - if protected_gate is None: - raise HTTPException(status_code=404, detail="protected cell not enabled") - entity_key, ext = resolve_for_record(body.entity) - result = protected_gate.operator_override( - policy=body.policy, - entity_key=entity_key, - rationale=body.rationale, - operator_id=body.operator_id, - file_fingerprint=body.file_fingerprint, - ast_path=body.ast_path, - extensions=ext, - ) + def post_operator_override(body: OperatorOverrideIn, operator: str = Depends(verify_operator)) -> dict: + try: + result = _submit_operator_override( + protected_gate, + identity=identity, + policy=body.policy, + entity=body.entity, + rationale=body.rationale, + operator_id=_recorded_actor(operator, body.operator_id), + file_fingerprint=body.file_fingerprint, + ast_path=body.ast_path, + source_root=source_root, + ) + except NotEnabledError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except InvalidArgumentError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc return { "accepted": result.accepted, "seq": result.seq, @@ -304,7 +494,7 @@ def post_operator_override(body: OperatorOverrideIn) -> dict: } @app.post("/signoff/request", status_code=202) - def post_signoff_request(body: SignoffRequestIn) -> dict: + def post_signoff_request(body: SignoffRequestIn, actor: str = Depends(verify_writer)) -> dict: if signoff_gate is None: raise HTTPException(status_code=404, detail="structured cell not enabled") entity_key, ext = resolve_for_record(body.entity) @@ -312,17 +502,32 @@ def post_signoff_request(body: SignoffRequestIn) -> dict: policy=body.policy, entity_key=entity_key, rationale=body.rationale, - agent_id=body.agent_id, + agent_id=_recorded_actor(actor, body.agent_id), extensions=ext, ) return {"seq": result.seq, "cleared": result.cleared} @app.post("/signoff/{request_seq}/bind-issue", status_code=201) - def bind_issue(request_seq: int, body: BindIssueIn) -> dict: + def bind_issue( + request_seq: int, body: BindIssueIn, actor: str = Depends(verify_writer) + ) -> dict: if filigree is None: raise HTTPException(status_code=404, detail="filigree binding not enabled") if signoff_gate is None: raise HTTPException(status_code=404, detail="structured cell not enabled") + if not signoff_gate.verify_integrity(): + raise HTTPException( + status_code=500, + detail="sign-off trail integrity failure: database hash chain verification failed", + ) + if trail_verifier is not None: + try: + trail_verifier.verify(signoff_gate.records()) + except TamperError as exc: + raise HTTPException( + status_code=500, + detail=f"sign-off trail integrity failure: {exc}", + ) from exc req = signoff_gate.request_record(request_seq) if req is None: raise HTTPException( @@ -362,12 +567,12 @@ def get_binding(request_seq: int) -> dict: return binding @app.post("/signoff/{request_seq}/sign") - def post_signoff_sign(request_seq: int, body: SignoffSignIn) -> dict: + def post_signoff_sign(request_seq: int, body: SignoffSignIn, operator: str = Depends(verify_operator)) -> dict: if signoff_gate is None: raise HTTPException(status_code=404, detail="structured cell not enabled") result = signoff_gate.sign_off( request_seq=request_seq, - operator_id=body.operator_id, + operator_id=_recorded_actor(operator, body.operator_id), rationale=body.rationale, ) return {"seq": result.seq, "cleared": result.cleared} @@ -399,27 +604,33 @@ def identity_gaps() -> list[dict]: @app.get("/governance/lineage-integrity") def lineage_integrity() -> dict: if identity is None or identity.client is None: - return {"divergences": []} - divs = find_lineage_divergence(verified_governance_records(), identity.client) - return {"divergences": [ - {"sei": d.sei, "recorded_length": d.recorded_length, - "current_length": d.current_length} for d in divs]} + return { + "status": "unavailable", + "divergences": [], + "unavailable": [{"reason": "clarion client not configured"}], + } + integrity = find_lineage_integrity(verified_governance_records(), identity.client) + return { + "status": "unverified" if integrity.unavailable else "verified", + "divergences": [ + {"sei": d.sei, "recorded_length": d.recorded_length, + "current_length": d.current_length} for d in integrity.divergences + ], + "unavailable": [ + {"sei": u.sei, "reason": u.reason} for u in integrity.unavailable + ], + } # --- agent-programmable policy grammar (WP-4.1) --- @app.post("/policy/evaluate") - def policy_evaluate(body: PolicyEvalIn) -> dict: - ev = grammar_().evaluate(body.policy, body.target) - if ev.result is PolicyResult.UNKNOWN: - # Honest event + provenance gap — never a silent false-green. - engine().record_event( - { - "event": "UNKNOWN_POLICY", - "policy": ev.policy, - "detail": ev.detail, - "provenance_gap": True, - } - ) + def policy_evaluate(body: PolicyEvalIn, actor: str = Depends(verify_writer)) -> dict: + ev = _evaluate_policy( + grammar_(), + engine=engine(), + policy=body.policy, + target=body.target, + ) return { "policy": ev.policy, "result": ev.result.value, @@ -430,26 +641,39 @@ def policy_evaluate(body: PolicyEvalIn) -> dict: # --- wardline suite-combination surface (WP-6.1) --- @app.post("/wardline/scan-results") - def wardline_scan_results(body: ScanResultsIn) -> dict: - if (body.cell is None) == (body.cell_by_severity is None): - raise HTTPException(status_code=422, - detail="provide exactly one of cell or cell_by_severity") - if body.cell_by_severity is not None and not body.cell_by_severity: - raise HTTPException(status_code=422, detail="cell_by_severity must not be empty") - - def resolve(qualname: str | None) -> tuple[EntityKey, dict]: - # Use the one resolve-then-key boundary so a wardline-routed override - # captures the clarion lineage snapshot like every other write path. - if qualname: - return resolve_for_record(qualname) - return EntityKey.from_locator("unknown"), {} - + def wardline_scan_results(body: ScanResultsIn, actor: str = Depends(verify_writer)) -> dict: + server_cell = os.environ.get("LEGIS_WARDLINE_CELL") + server_cell_by_severity = os.environ.get("LEGIS_WARDLINE_CELL_BY_SEVERITY") + if server_cell and server_cell_by_severity: + raise HTTPException(status_code=500, detail="server Wardline routing is misconfigured") + server_routing = server_cell is not None or server_cell_by_severity is not None + if server_routing and (body.cell is not None or body.cell_by_severity is not None): + raise HTTPException(status_code=403, detail="Wardline routing is server-owned") + if not server_routing: + if os.environ.get("LEGIS_UNSAFE_WARDLINE_REQUEST_ROUTING") != "1": + raise HTTPException( + status_code=403, + detail="Wardline routing is server-owned; configure LEGIS_WARDLINE_CELL or LEGIS_WARDLINE_CELL_BY_SEVERITY", + ) + if (body.cell is None) == (body.cell_by_severity is None): + raise HTTPException(status_code=422, + detail="provide exactly one of cell or cell_by_severity") + if body.cell_by_severity is not None and not body.cell_by_severity: + raise HTTPException(status_code=422, detail="cell_by_severity must not be empty") + + policy: WardlineCellPolicy | None = None + cell_map: dict[WardlineSeverity, WardlineCellPolicy] | None = None try: - if body.cell_by_severity is not None: + if server_cell_by_severity is not None: + cell_map = _parse_wardline_cell_map(server_cell_by_severity) + cells = set(cell_map.values()) + elif server_cell is not None: + policy = WardlineCellPolicy(server_cell) + cells = {policy} + elif body.cell_by_severity is not None: cell_map = {WardlineSeverity[sev]: WardlineCellPolicy(cell) for sev, cell in body.cell_by_severity.items()} - # SURFACE_OVERRIDE is always reachable via the unmapped-severity fallback. - cells = set(cell_map.values()) | {WardlineCellPolicy.SURFACE_OVERRIDE} + cells = set(cell_map.values()) else: policy = WardlineCellPolicy(body.cell) cells = {policy} @@ -461,16 +685,23 @@ def resolve(qualname: str | None) -> tuple[EntityKey, dict]: # must not touch it. signoff_gate is an injected param (no side effect). needs_engine = bool(cells & {WardlineCellPolicy.SURFACE_OVERRIDE, WardlineCellPolicy.SURFACE_ONLY}) - kwargs: dict = {"agent_id": body.agent_id, "resolve": resolve, - "engine": engine() if needs_engine else None, - "signoff": signoff_gate} - if body.cell_by_severity is not None: - kwargs["cell_map"] = cell_map - else: - kwargs["policy"] = policy - try: - routed = route_findings(active_defects(body.scan), **kwargs) + routed = _route_wardline_scan( + body.scan, + agent_id=_recorded_actor(actor, body.agent_id), + identity=identity, + engine=engine() if needs_engine else None, + signoff=signoff_gate, + policy=policy, + cell_map=cell_map, + artifact_key=( + os.environ["LEGIS_WARDLINE_ARTIFACT_KEY"].encode("utf-8") + if os.environ.get("LEGIS_WARDLINE_ARTIFACT_KEY") + else None + ), + ) + except WardlinePayloadError as exc: + raise HTTPException(status_code=422, detail=f"invalid Wardline scan: {exc}") except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) return {"routed": routed} diff --git a/src/legis/checks/surface.py b/src/legis/checks/surface.py index 04121ef..2eb46b0 100644 --- a/src/legis/checks/surface.py +++ b/src/legis/checks/surface.py @@ -64,7 +64,10 @@ def record(self, run: CheckRun) -> int: finished_at=run.finished_at, ) ) - return int(result.inserted_primary_key[0]) + primary_key = result.inserted_primary_key + if primary_key is None: + raise RuntimeError("check run insert did not return a primary key") + return int(primary_key[0]) def _select(self, whereclause) -> list[tuple[int, CheckRun]]: with self._engine.begin() as conn: diff --git a/src/legis/cli.py b/src/legis/cli.py index b0d162d..eccdb87 100644 --- a/src/legis/cli.py +++ b/src/legis/cli.py @@ -1,5 +1,6 @@ import argparse import sys +from pathlib import Path import uvicorn @@ -11,21 +12,73 @@ def build_parser() -> argparse.ArgumentParser: serve = subparsers.add_parser("serve", help="Run the Legis API server") serve.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") serve.add_argument("--port", default=8000, type=int, help="Bind port (default: 8000)") + serve.add_argument( + "--governance-db", + help="Governance store URL (falls back to LEGIS_GOVERNANCE_DB env var)", + ) + serve.add_argument( + "--check-db", + help="Check store URL (falls back to LEGIS_CHECK_DB env var)", + ) + serve.add_argument( + "--protected-policies", + help="Comma-separated protected policy list (falls back to LEGIS_PROTECTED_POLICIES env var)", + ) + serve.add_argument( + "--clarion-url", + help="Clarion identity API URL (falls back to CLARION_API_URL env var)", + ) + serve.add_argument( + "--filigree-url", + help="Filigree issue-tracker API URL (falls back to FILIGREE_API_URL env var)", + ) + serve.add_argument( + "--binding-db", + help="Signoff-binding ledger URL (falls back to LEGIS_BINDING_DB env var)", + ) + mcp = subparsers.add_parser("mcp", help="Run the Legis MCP stdio server") + mcp.add_argument("--agent-id", required=True, help="Launch-bound agent identity") + mcp.add_argument( + "--governance-db", + help="Governance store URL (falls back to LEGIS_GOVERNANCE_DB env var)", + ) + mcp.add_argument( + "--protected-policies", + help="Comma-separated protected policy list (falls back to LEGIS_PROTECTED_POLICIES env var)", + ) + mcp.add_argument( + "--clarion-url", + help="Clarion identity API URL (falls back to CLARION_API_URL env var)", + ) + + import os + gov_db_default = os.environ.get("LEGIS_GOVERNANCE_DB", "sqlite:///legis-governance.db") rate = subparsers.add_parser( "check-override-rate", help="Fail (exit 1) if the override-rate gate is FAIL — for CI", ) rate.add_argument( - # Literal duplicates api.app.DEFAULT_GOVERNANCE_DB deliberately: importing it - # would pull FastAPI at CLI load time, defeating the deferred-import decoupling. - "--db", default="sqlite:///legis-governance.db", + "--db", default=gov_db_default, help="Governance store URL (mirrors the server's DEFAULT_GOVERNANCE_DB)", ) return parser +def _missing_sqlite_db(url: str) -> Path | None: + from sqlalchemy.engine import make_url + + parsed = make_url(url) + if parsed.get_backend_name() != "sqlite": + return None + database = parsed.database + if not database or database == ":memory:": + return None + path = Path(database) + return path if not path.exists() else None + + def main(argv: list[str] | None = None, *, run=uvicorn.run) -> int: if argv is None: argv = sys.argv[1:] @@ -34,16 +87,57 @@ def main(argv: list[str] | None = None, *, run=uvicorn.run) -> int: args = parser.parse_args(argv) if args.command == "serve": + import os + if args.governance_db: + os.environ["LEGIS_GOVERNANCE_DB"] = args.governance_db + if args.check_db: + os.environ["LEGIS_CHECK_DB"] = args.check_db + if args.protected_policies: + os.environ["LEGIS_PROTECTED_POLICIES"] = args.protected_policies + if args.clarion_url: + os.environ["CLARION_API_URL"] = args.clarion_url + if args.filigree_url: + os.environ["FILIGREE_API_URL"] = args.filigree_url + if args.binding_db: + os.environ["LEGIS_BINDING_DB"] = args.binding_db + run("legis.api.app:create_app", host=args.host, port=args.port, factory=True) return 0 if args.command == "check-override-rate": + import os from legis.enforcement.lifecycle import GateStatus, evaluate_override_rate from legis.governance import params from legis.store.audit_store import AuditStore + missing_db = _missing_sqlite_db(args.db) + if missing_db is not None: + print(f"Error: Governance database is missing: {missing_db}", file=sys.stderr) + return 1 + + store = AuditStore(args.db) + if not store.verify_integrity(): + print("Error: Database hash chain integrity check failed!", file=sys.stderr) + return 1 + + records = store.read_all() + + hmac_key_str = os.environ.get("LEGIS_HMAC_KEY") + if hmac_key_str: + from legis.enforcement.protected import TrailVerifier, TamperError + protected_policies_str = os.environ.get("LEGIS_PROTECTED_POLICIES", "") + protected_policies = frozenset( + p.strip() for p in protected_policies_str.split(",") if p.strip() + ) + verifier = TrailVerifier(hmac_key_str.encode("utf-8"), protected_policies) + try: + verifier.verify(records) + except TamperError as exc: + print(f"Error: Protected audit trail verification failed: {exc}", file=sys.stderr) + return 1 + res = evaluate_override_rate( - AuditStore(args.db).read_all(), + records, threshold=params.OVERRIDE_RATE_THRESHOLD, window=params.OVERRIDE_RATE_WINDOW, min_sample=params.OVERRIDE_RATE_MIN_SAMPLE, @@ -52,5 +146,18 @@ def main(argv: list[str] | None = None, *, run=uvicorn.run) -> int: f"(rate={res.rate:.3f}, sample={res.sample_size})") return 1 if res.status is GateStatus.FAIL else 0 + if args.command == "mcp": + import os + if args.governance_db: + os.environ["LEGIS_GOVERNANCE_DB"] = args.governance_db + if args.protected_policies: + os.environ["LEGIS_PROTECTED_POLICIES"] = args.protected_policies + if args.clarion_url: + os.environ["CLARION_API_URL"] = args.clarion_url + + from legis.mcp import main as mcp_main + + return mcp_main(args.agent_id) + parser.print_help(sys.stderr) return 2 diff --git a/src/legis/enforcement/judge.py b/src/legis/enforcement/judge.py index 5d1f528..ebd98ef 100644 --- a/src/legis/enforcement/judge.py +++ b/src/legis/enforcement/judge.py @@ -24,7 +24,14 @@ def parse_verdict(raw: str) -> Verdict: BLOCKED wins on ambiguity; anything that is not an explicit, unambiguous ACCEPTED is BLOCKED. The judge never accepts on a response it cannot read. """ - tokens = set(_TOKEN.findall(raw.upper())) + first_line = "" + for line in raw.splitlines(): + if line.strip(): + first_line = line + break + tokens = set(_TOKEN.findall(first_line.upper())) + if "NOT" in tokens or "NO" in tokens or "NEVER" in tokens or "UNACCEPTED" in tokens: + return Verdict.BLOCKED if Verdict.BLOCKED.value in tokens: return Verdict.BLOCKED if Verdict.ACCEPTED.value in tokens: @@ -50,7 +57,8 @@ def build_prompt(record: OverrideRecord) -> str: "actually addresses why the policy fired.\n\n" f"policy: {record.policy}\n" f"entity: {record.entity_key.value}\n" - f"rationale: {record.rationale}\n" + "rationale: [UNTRUSTED AGENT INPUT]\n" + f"{record.rationale}\n" ) diff --git a/src/legis/enforcement/protected.py b/src/legis/enforcement/protected.py index 9bea8e0..73d622e 100644 --- a/src/legis/enforcement/protected.py +++ b/src/legis/enforcement/protected.py @@ -15,7 +15,8 @@ from legis.clock import Clock from legis.enforcement.judge import Judge -from legis.enforcement.signing import sign, verify +from legis.enforcement.signing import SIG_PREFIX_V1, sign, verify +from legis.enforcement.signoff import signoff_signing_fields from legis.enforcement.verdict import Verdict from legis.identity.entity_key import EntityKey from legis.records.override_record import OverrideRecord @@ -42,14 +43,50 @@ def signing_fields(payload: dict[str, Any]) -> dict[str, Any]: Binds entity + policy in addition to the roadmap's six fields, so a signed verdict cannot be transplanted to another entity. """ - ext = payload["extensions"] + ext = payload.get("extensions") or {} + clar = ext.get("clarion") or {} + snap = clar.get("lineage_snapshot") or {} + fields = { + "policy": payload.get("policy"), + "entity": payload.get("entity_key"), + "verdict": ext.get("judge_verdict"), + "model": ext.get("judge_model"), + "recorded_at": payload.get("recorded_at"), + "rationale": payload.get("rationale"), + "agent_id": payload.get("agent_id"), + "protected_cell": ext.get("protected_cell") is True, + "file_fingerprint": ext.get("file_fingerprint"), + "ast_path": ext.get("ast_path"), + "judge_rationale": ext.get("judge_rationale"), + "clarion_content_hash": clar.get("content_hash"), + "clarion_lineage_hash": snap.get("hash"), + "clarion_lineage_len": snap.get("length"), + } + source_binding = ext.get("source_binding") + if isinstance(source_binding, dict) and source_binding: + fields.update( + { + "source_binding_status": source_binding.get("status"), + "source_binding_reason": source_binding.get("reason"), + "source_binding_source_path": source_binding.get("source_path"), + "source_binding_current_fingerprint": source_binding.get( + "current_fingerprint" + ), + } + ) + return fields + + +def legacy_signing_fields(payload: dict[str, Any]) -> dict[str, Any]: + """Protected override fields signed by legacy ``hmac-sha256:v1`` records.""" + ext = payload.get("extensions") or {} return { - "policy": payload["policy"], - "entity": payload["entity_key"], - "verdict": ext["judge_verdict"], + "policy": payload.get("policy"), + "entity": payload.get("entity_key"), + "verdict": ext.get("judge_verdict"), "model": ext.get("judge_model"), - "recorded_at": payload["recorded_at"], - "rationale": payload["rationale"], + "recorded_at": payload.get("recorded_at"), + "rationale": payload.get("rationale"), "file_fingerprint": ext.get("file_fingerprint"), "ast_path": ext.get("ast_path"), } @@ -68,20 +105,61 @@ def __init__(self, key: bytes, protected_policies: frozenset[str]) -> None: self._key = key self._protected = protected_policies + @property + def protected_policies(self) -> frozenset[str]: + return self._protected + + def _requires_verification(self, payload: dict[str, Any]) -> bool: + ext = payload.get("extensions", {}) or {} + return ( + payload.get("policy") in self._protected + or ext.get("protected_cell") is True + or "judge_metadata_signature" in ext + or "signoff_signature" in ext + or "file_fingerprint" in ext + or "ast_path" in ext + ) + def verify(self, records) -> None: for rec in records: - if rec.payload.get("policy") not in self._protected: + if not self._requires_verification(rec.payload): continue - ext = rec.payload.get("extensions", {}) - sig = ext.get("judge_metadata_signature") - if not sig: - raise TamperError( - f"protected record seq={rec.seq} is missing its signature" - ) - if not verify(signing_fields(rec.payload), sig, self._key): + if "entity_key" not in rec.payload: raise TamperError( - f"protected record seq={rec.seq} signature does not verify" + f"protected record seq={rec.seq} is missing entity_key" ) + ext = rec.payload.get("extensions", {}) + if "signoff_state" in ext: + sig = ext.get("signoff_signature") + if not sig: + raise TamperError( + f"protected sign-off record seq={rec.seq} is missing its signature" + ) + fields = signoff_signing_fields(rec.payload) + if not verify(fields, sig, self._key): + raise TamperError( + f"protected sign-off record seq={rec.seq} signature does not verify" + ) + else: + sig = ext.get("judge_metadata_signature") + if not sig: + raise TamperError( + f"protected override record seq={rec.seq} is missing its signature" + ) + try: + fields = signing_fields(rec.payload) + except (KeyError, AttributeError, TypeError) as exc: + raise TamperError( + f"protected record seq={rec.seq} is structurally malformed: {exc}" + ) from exc + if not verify(fields, sig, self._key) and not ( + isinstance(sig, str) + and sig.startswith(SIG_PREFIX_V1) + and verify(legacy_signing_fields(rec.payload), sig, self._key) + ): + raise TamperError( + f"protected record seq={rec.seq} signature does not verify" + ) class ProtectedGate: @@ -109,6 +187,7 @@ def _record_signed( ) -> ProtectedResult: ext: dict[str, Any] = { **(extensions or {}), + "protected_cell": True, "judge_verdict": verdict.value, "judge_model": model, "judge_rationale": judge_rationale, @@ -147,12 +226,18 @@ def submit( ast_path: str, extensions: dict[str, Any] | None = None, ) -> ProtectedResult: + proposed_ext = { + **(extensions or {}), + "file_fingerprint": file_fingerprint, + "ast_path": ast_path, + } proposed = OverrideRecord( policy=policy, entity_key=entity_key, rationale=rationale, agent_id=agent_id, recorded_at=self._clock.now_iso(), + extensions=proposed_ext, ) opinion = self._judge.evaluate(proposed) return self._record_signed( @@ -197,3 +282,7 @@ def operator_override( def records(self): """The governance trail this gate writes to — for verified reads.""" return self._store.read_all() + + def verify_integrity(self) -> bool: + """Verify the underlying append-only hash chain before HMAC checks.""" + return self._store.verify_integrity() diff --git a/src/legis/enforcement/signing.py b/src/legis/enforcement/signing.py index a0f4330..8f99d2e 100644 --- a/src/legis/enforcement/signing.py +++ b/src/legis/enforcement/signing.py @@ -3,7 +3,8 @@ The Sprint 0 hash chain detects edits by an actor who *cannot* recompute it; an actor with DB-file access can re-chain a forged record. The HMAC closes that: without the key, a forged record cannot carry a valid signature. Versioned -(`v1` pins canonical-JSON v1) so an RFC-8785 upgrade is a clean `v2`. +(`v2` pins the expanded audit field set and canonical-JSON v1) so future +canonicalisation or field-set upgrades can be introduced without ambiguity. """ from __future__ import annotations @@ -13,17 +14,34 @@ from legis.canonical import canonical_json -SIG_PREFIX = "hmac-sha256:v1:" +SIG_PREFIX_V1 = "hmac-sha256:v1:" +SIG_PREFIX_V2 = "hmac-sha256:v2:" +SIG_PREFIX = SIG_PREFIX_V2 -def sign(fields: dict, key: bytes) -> str: +def _prefix_for(version: str) -> str: + if version == "v1": + return SIG_PREFIX_V1 + if version == "v2": + return SIG_PREFIX_V2 + raise ValueError(f"unsupported signature version: {version}") + + +def _signed(fields: dict, key: bytes, prefix: str) -> str: mac = hmac.new( key, canonical_json(fields).encode("utf-8"), hashlib.sha256 ).hexdigest() - return f"{SIG_PREFIX}{mac}" + return f"{prefix}{mac}" + + +def sign(fields: dict, key: bytes, *, version: str = "v2") -> str: + return _signed(fields, key, _prefix_for(version)) def verify(fields: dict, signature: str, key: bytes) -> bool: - if not signature.startswith(SIG_PREFIX): + if signature.startswith(SIG_PREFIX_V2): + return hmac.compare_digest(_signed(fields, key, SIG_PREFIX_V2), signature) + if signature.startswith(SIG_PREFIX_V1): + return hmac.compare_digest(_signed(fields, key, SIG_PREFIX_V1), signature) + else: return False - return hmac.compare_digest(sign(fields, key), signature) diff --git a/src/legis/enforcement/signoff.py b/src/legis/enforcement/signoff.py index f05f78b..77ebd44 100644 --- a/src/legis/enforcement/signoff.py +++ b/src/legis/enforcement/signoff.py @@ -25,6 +25,24 @@ class SignoffResult: cleared: bool +def signoff_signing_fields(payload: dict[str, Any]) -> dict[str, Any]: + ext = payload.get("extensions") or {} + clar = ext.get("clarion") or {} + snap = clar.get("lineage_snapshot") or {} + return { + "policy": payload.get("policy"), + "entity": payload.get("entity_key"), + "recorded_at": payload.get("recorded_at"), + "rationale": payload.get("rationale"), + "actor": payload.get("agent_id"), + "signoff_state": ext.get("signoff_state"), + "request_seq": ext.get("request_seq"), + "clarion_content_hash": clar.get("content_hash"), + "clarion_lineage_hash": snap.get("hash"), + "clarion_lineage_len": snap.get("length"), + } + + class SignoffGate: def __init__( self, @@ -59,16 +77,7 @@ def _append( payload = rec.to_payload() if self._sign and self._key is not None: payload["extensions"]["signoff_signature"] = sign( - { - "policy": payload["policy"], - "entity": payload["entity_key"], - "recorded_at": payload["recorded_at"], - "rationale": payload["rationale"], - "operator": actor_id, - "signoff_state": ext.get("signoff_state"), - "request_seq": ext.get("request_seq"), - }, - self._key, + signoff_signing_fields(payload), self._key ) return self._store.append(payload) @@ -93,7 +102,11 @@ def request( def sign_off( self, *, request_seq: int, operator_id: str, rationale: str = "" ) -> SignoffResult: - req = self._store.read_all()[request_seq - 1].payload + req = self.request_record(request_seq) + if req is None: + raise ValueError(f"No pending sign-off request found at sequence {request_seq}") + if self.is_cleared(request_seq): + raise ValueError(f"Request at sequence {request_seq} has already been signed off") seq = self._append( policy=req["policy"], entity_key=EntityKey.from_dict(req["entity_key"]), @@ -108,10 +121,10 @@ def sign_off( def request_record(self, request_seq: int) -> dict | None: """The recorded PENDING_SIGNOFF request payload at this seq, or None.""" - records = self._store.read_all() - if not (1 <= request_seq <= len(records)): + rec = self._store.read_by_seq(request_seq) + if rec is None: return None - payload = records[request_seq - 1].payload + payload = rec.payload if payload.get("extensions", {}).get("signoff_state") != SignoffState.PENDING.value: return None return payload @@ -125,3 +138,11 @@ def is_cleared(self, request_seq: int) -> bool: ): return True return False + + def records(self): + """The sign-off trail this gate writes to — for verified consumers.""" + return self._store.read_all() + + def verify_integrity(self) -> bool: + """Verify the underlying append-only hash chain before HMAC checks.""" + return self._store.verify_integrity() diff --git a/src/legis/filigree/client.py b/src/legis/filigree/client.py index 0ec8ac9..4eefdad 100644 --- a/src/legis/filigree/client.py +++ b/src/legis/filigree/client.py @@ -9,6 +9,8 @@ from __future__ import annotations import json +import ipaddress +import os import urllib.error import urllib.parse import urllib.request @@ -21,6 +23,9 @@ class FiligreeError(RuntimeError): """A Filigree call failed at the transport or decode layer.""" +MAX_RESPONSE_BYTES = 1_000_000 + + @runtime_checkable class FiligreeClient(Protocol): def attach(self, issue_id: str, entity_id: str, content_hash: str, @@ -34,25 +39,74 @@ def _urllib_fetch(method: str, url: str, body: dict | None) -> dict: if data is not None: req.add_header("Content-Type", "application/json") try: - with urllib.request.urlopen(req) as resp: # noqa: S310 (trusted Filigree URL) - return json.loads(resp.read().decode("utf-8")) + with urllib.request.urlopen(req, timeout=10.0) as resp: # noqa: S310 (trusted Filigree URL) + decoded = _decode_json_response(resp, f"{method} {url}") except (urllib.error.URLError, ValueError) as exc: raise FiligreeError(f"{method} {url} failed: {exc}") from exc + return _require_dict(decoded, f"{method} {url}") + + +def _decode_json_response(resp: Any, context: str) -> Any: + headers = getattr(resp, "headers", {}) or {} + content_type = headers.get("Content-Type", "application/json") + if "json" not in content_type.lower(): + raise FiligreeError(f"{context} returned non-JSON content type: {content_type}") + raw = resp.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + raise FiligreeError(f"{context} response too large") + return json.loads(raw.decode("utf-8")) + + +def _require_dict(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise FiligreeError(f"{context} returned {type(value).__name__}, expected object") + return value + + +def _is_loopback(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _validate_base_url(base_url: str) -> str: + parsed = urllib.parse.urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise FiligreeError("Filigree base URL must be an http(s) URL with a host") + allow_insecure_remote = os.environ.get("LEGIS_ALLOW_INSECURE_REMOTE_HTTP") == "1" + if parsed.scheme == "http" and not _is_loopback(parsed.hostname) and not allow_insecure_remote: + raise FiligreeError("Filigree base URL must use HTTPS unless it is loopback") + return base_url.rstrip("/") class HttpFiligreeClient: def __init__(self, base_url: str, *, fetch: Fetch | None = None) -> None: - self._base = base_url.rstrip("/") + self._base = _validate_base_url(base_url) self._fetch = fetch or _urllib_fetch def attach(self, issue_id: str, entity_id: str, content_hash: str, *, actor: str) -> dict[str, Any]: - return self._fetch( - "POST", f"{self._base}/api/issue/{issue_id}/entity-associations", - {"entity_id": entity_id, "content_hash": content_hash, "actor": actor}, + quoted_issue_id = urllib.parse.quote(issue_id, safe="") + return _require_dict( + self._fetch( + "POST", f"{self._base}/api/issue/{quoted_issue_id}/entity-associations", + {"entity_id": entity_id, "content_hash": content_hash, "actor": actor}, + ), + "Filigree attach", ) def associations_for_entity(self, entity_id: str) -> list[dict[str, Any]]: q = urllib.parse.urlencode({"entity_id": entity_id}) - body = self._fetch("GET", f"{self._base}/api/entity-associations?{q}", None) - return list(body.get("associations", [])) + body = _require_dict( + self._fetch("GET", f"{self._base}/api/entity-associations?{q}", None), + "Filigree associations_for_entity", + ) + associations = body.get("associations", []) + if not isinstance(associations, list) or not all( + isinstance(item, dict) for item in associations + ): + raise FiligreeError("Filigree returned malformed associations list") + return list(associations) diff --git a/src/legis/git/surface.py b/src/legis/git/surface.py index 6a415a9..a1d3aa4 100644 --- a/src/legis/git/surface.py +++ b/src/legis/git/surface.py @@ -20,15 +20,23 @@ class GitError(RuntimeError): class GitSurface: + _TIMEOUT_SECONDS = 10.0 + def __init__(self, repo_path: str | Path) -> None: self._repo = str(repo_path) def _run_raw(self, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", self._repo, *args], - capture_output=True, - text=True, - ) + try: + return subprocess.run( + ["git", "-C", self._repo, *args], + capture_output=True, + text=True, + timeout=self._TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise GitError( + f"git {' '.join(args)} timed out after {self._TIMEOUT_SECONDS:g}s" + ) from exc def _run(self, *args: str) -> str: result = self._run_raw(*args) @@ -68,6 +76,9 @@ def branches(self) -> list[BranchInfo]: return branches def commit(self, sha: str) -> CommitInfo: + import re + if sha.startswith("-") or not re.match(r"^[a-zA-Z0-9_/.~^-]+$", sha): + raise GitError(f"invalid commit ref/SHA: {sha}") meta_fmt = US.join(["%H", "%an", "%ae", "%cI", "%P", "%B"]) meta = self._run("show", "-s", f"--format={meta_fmt}", sha) # Body (%B) is last and may contain newlines/spaces — split with a cap. @@ -103,10 +114,18 @@ def _numstat(self, sha: str) -> tuple[int, int, int]: return files, insertions, deletions def commits(self, ref: str = "HEAD", limit: int = 50) -> list[CommitInfo]: + import re + if ref.startswith("-") or not re.match(r"^[a-zA-Z0-9_/.~^-]+$", ref): + raise GitError(f"invalid commit ref: {ref}") out = self._run("rev-list", f"--max-count={limit}", ref) return [self.commit(sha) for sha in out.split()] def merge_base(self, a: str, b: str) -> str | None: + import re + if a.startswith("-") or not re.match(r"^[a-zA-Z0-9_/.~^-]+$", a): + raise GitError(f"invalid ref: {a}") + if b.startswith("-") or not re.match(r"^[a-zA-Z0-9_/.~^-]+$", b): + raise GitError(f"invalid ref: {b}") result = self._run_raw("merge-base", a, b) if result.returncode != 0: return None # no common ancestor (or a bad ref) → honest None @@ -114,6 +133,9 @@ def merge_base(self, a: str, b: str) -> str | None: return sha or None def renames(self, rev_range: str) -> list[RenameEvidence]: + import re + if rev_range.startswith("-") or not re.match(r"^[a-zA-Z0-9_/.~^-]+(\.\.[a-zA-Z0-9_/.~^-]+)?$", rev_range): + raise GitError(f"invalid revision range: {rev_range}") out = self._run( "log", "-M", diff --git a/src/legis/governance/gaps.py b/src/legis/governance/gaps.py index e7c0f32..d62b455 100644 --- a/src/legis/governance/gaps.py +++ b/src/legis/governance/gaps.py @@ -33,6 +33,18 @@ class LineageDivergence: current_length: int +@dataclass(frozen=True) +class LineageUnavailable: + sei: str + reason: str + + +@dataclass(frozen=True) +class LineageIntegrity: + divergences: list[LineageDivergence] + unavailable: list[LineageUnavailable] + + def _stable_seis(records: list[AuditRecord]) -> list[str]: seen: dict[str, None] = {} # ordered, de-duplicated for rec in records: @@ -53,26 +65,51 @@ def find_orphan_gaps( return gaps -def find_lineage_divergence( +def find_lineage_integrity( records: list[AuditRecord], client: ClarionIdentity -) -> list[LineageDivergence]: +) -> LineageIntegrity: divergences: list[LineageDivergence] = [] - seen: set[str] = set() + unavailable: dict[str, LineageUnavailable] = {} + lineages: dict[str, list[dict[str, Any]]] = {} for rec in records: ek = rec.payload.get("entity_key", {}) sei = ek.get("value") - if not (ek.get("identity_stable") and sei) or sei in seen: + if not (ek.get("identity_stable") and sei): continue - snap = (rec.payload.get("extensions", {}).get("clarion", {}) or {}).get( - "lineage_snapshot" - ) + clarion = (rec.payload.get("extensions", {}).get("clarion", {}) or {}) + snap = clarion.get("lineage_snapshot") if not snap: + unavailable.setdefault( + sei, + LineageUnavailable( + sei=sei, + reason=clarion.get("lineage_snapshot_status") or "missing_snapshot", + ), + ) continue - seen.add(sei) - current = client.lineage(sei) + if not isinstance(snap, dict) or "length" not in snap or "hash" not in snap: + unavailable.setdefault( + sei, LineageUnavailable(sei=sei, reason="malformed_snapshot") + ) + continue + if sei not in lineages: + try: + lineages[sei] = client.lineage(sei) + except Exception: + unavailable.setdefault( + sei, LineageUnavailable(sei=sei, reason="lineage_fetch_failed") + ) + continue + current = lineages[sei] n = snap["length"] if len(current) < n or content_hash(current[:n]) != snap["hash"]: divergences.append( LineageDivergence(sei=sei, recorded_length=n, current_length=len(current)) ) - return divergences + return LineageIntegrity(divergences=divergences, unavailable=list(unavailable.values())) + + +def find_lineage_divergence( + records: list[AuditRecord], client: ClarionIdentity +) -> list[LineageDivergence]: + return find_lineage_integrity(records, client).divergences diff --git a/src/legis/identity/clarion_client.py b/src/legis/identity/clarion_client.py index 910f1d0..ec555fc 100644 --- a/src/legis/identity/clarion_client.py +++ b/src/legis/identity/clarion_client.py @@ -17,7 +17,10 @@ from __future__ import annotations import json +import ipaddress +import os import urllib.error +import urllib.parse import urllib.request from typing import Any, Callable, Protocol, runtime_checkable @@ -28,6 +31,9 @@ class ClarionError(RuntimeError): """A Clarion identity call failed at the transport or decode layer.""" +MAX_RESPONSE_BYTES = 1_000_000 + + @runtime_checkable class ClarionIdentity(Protocol): def capability(self) -> bool: ... @@ -42,30 +48,84 @@ def _urllib_fetch(method: str, url: str, body: dict | None) -> dict: if data is not None: req.add_header("Content-Type", "application/json") try: - with urllib.request.urlopen(req) as resp: # noqa: S310 (trusted Clarion URL) - return json.loads(resp.read().decode("utf-8")) + with urllib.request.urlopen(req, timeout=10.0) as resp: # noqa: S310 (trusted Clarion URL) + decoded = _decode_json_response(resp, f"{method} {url}") except (urllib.error.URLError, ValueError) as exc: raise ClarionError(f"{method} {url} failed: {exc}") from exc + return _require_dict(decoded, f"{method} {url}") + + +def _decode_json_response(resp: Any, context: str) -> Any: + headers = getattr(resp, "headers", {}) or {} + content_type = headers.get("Content-Type", "application/json") + if "json" not in content_type.lower(): + raise ClarionError(f"{context} returned non-JSON content type: {content_type}") + raw = resp.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + raise ClarionError(f"{context} response too large") + return json.loads(raw.decode("utf-8")) + + +def _require_dict(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ClarionError(f"{context} returned {type(value).__name__}, expected object") + return value + + +def _is_loopback(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _validate_base_url(base_url: str) -> str: + parsed = urllib.parse.urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ClarionError("Clarion base URL must be an http(s) URL with a host") + allow_insecure_remote = os.environ.get("LEGIS_ALLOW_INSECURE_REMOTE_HTTP") == "1" + if parsed.scheme == "http" and not _is_loopback(parsed.hostname) and not allow_insecure_remote: + raise ClarionError("Clarion base URL must use HTTPS unless it is loopback") + return base_url.rstrip("/") class HttpClarionIdentity: def __init__(self, base_url: str, *, fetch: Fetch | None = None) -> None: - self._base = base_url.rstrip("/") + self._base = _validate_base_url(base_url) self._fetch = fetch or _urllib_fetch def capability(self) -> bool: - body = self._fetch("GET", f"{self._base}/api/v1/_capabilities", None) + body = _require_dict( + self._fetch("GET", f"{self._base}/api/v1/_capabilities", None), + "Clarion capability", + ) sei = body.get("sei") if isinstance(body, dict) else None return isinstance(sei, dict) and sei.get("supported") is True def resolve_locator(self, locator: str) -> dict[str, Any]: - return self._fetch( - "POST", f"{self._base}/api/v1/identity/resolve", {"locator": locator} + return _require_dict( + self._fetch( + "POST", f"{self._base}/api/v1/identity/resolve", {"locator": locator} + ), + "Clarion resolve_locator", ) def resolve_sei(self, sei: str) -> dict[str, Any]: - return self._fetch("GET", f"{self._base}/api/v1/identity/sei/{sei}", None) + quoted_sei = urllib.parse.quote(sei, safe="") + return _require_dict( + self._fetch("GET", f"{self._base}/api/v1/identity/sei/{quoted_sei}", None), + "Clarion resolve_sei", + ) def lineage(self, sei: str) -> list[dict[str, Any]]: - body = self._fetch("GET", f"{self._base}/api/v1/identity/lineage/{sei}", None) - return list(body.get("lineage", [])) + quoted_sei = urllib.parse.quote(sei, safe="") + body = _require_dict( + self._fetch("GET", f"{self._base}/api/v1/identity/lineage/{quoted_sei}", None), + "Clarion lineage", + ) + lineage = body.get("lineage", []) + if not isinstance(lineage, list) or not all(isinstance(item, dict) for item in lineage): + raise ClarionError("Clarion lineage returned malformed lineage list") + return list(lineage) diff --git a/src/legis/identity/resolver.py b/src/legis/identity/resolver.py index e67e02c..28b274b 100644 --- a/src/legis/identity/resolver.py +++ b/src/legis/identity/resolver.py @@ -23,6 +23,8 @@ class IdentityResolution: alive: bool | None # identity axis; None when no capability/decision content_hash: str | None # content axis; None when unavailable lineage_snapshot: dict[str, Any] | None # {"length": N, "hash": ...} or None + identity_resolution_status: str + lineage_snapshot_status: str class IdentityResolver: @@ -42,32 +44,53 @@ def _capability(self) -> bool: try: self._capable = bool(self._client.capability()) except Exception: - self._capable = False # honest degrade — never raise + return False # honest transient degrade — retry on next resolve return self._capable - def _snapshot(self, sei: str) -> dict[str, Any] | None: + def _snapshot(self, sei: str) -> tuple[dict[str, Any] | None, str]: try: lineage = self._client.lineage(sei) # type: ignore[union-attr] except Exception: - return None - return {"length": len(lineage), "hash": content_hash(lineage)} + return None, "unavailable" + return {"length": len(lineage), "hash": content_hash(lineage)}, "verified" def resolve(self, locator: str) -> IdentityResolution: - degraded = IdentityResolution(EntityKey.from_locator(locator), None, None, None) + degraded = IdentityResolution( + EntityKey.from_locator(locator), + None, + None, + None, + "unavailable", + "not_applicable", + ) if not self._capability(): return degraded try: res = self._client.resolve_locator(locator) # type: ignore[union-attr] except Exception: return degraded + if not isinstance(res, dict): + return degraded if not res.get("alive"): # Capability present but this locator has no alive SEI — honest: no # stable identity, and we know it (alive recorded False, not None). - return IdentityResolution(EntityKey.from_locator(locator), False, None, None) - sei = res["sei"] + return IdentityResolution( + EntityKey.from_locator(locator), + False, + None, + None, + "not_alive", + "not_applicable", + ) + sei = res.get("sei") + if not isinstance(sei, str) or not sei: + return degraded + snapshot, snapshot_status = self._snapshot(sei) return IdentityResolution( EntityKey.from_sei(sei), True, res.get("content_hash"), - self._snapshot(sei), + snapshot, + "resolved", + snapshot_status, ) diff --git a/src/legis/mcp.py b/src/legis/mcp.py new file mode 100644 index 0000000..cafd09b --- /dev/null +++ b/src/legis/mcp.py @@ -0,0 +1,432 @@ +"""Minimal MCP-over-stdio adapter for Legis. + +The adapter is deliberately stdlib-only: one JSON-RPC object per line on stdin, +one response per line on stdout. Tool calls are thin transport mappings over the +service layer and the launch-bound ``agent_id``; tool schemas never accept actor +identity from call arguments. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import sys +from typing import Any, TextIO + +from legis import __version__ +from legis.clock import SystemClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.policy.grammar import default_grammar +from legis.records.override_record import OverrideRecord +from legis.service.errors import ( + AuditIntegrityError, + InvalidArgumentError, + NotEnabledError, + NotFoundError, + ServiceError, +) +from legis.service.governance import ( + compute_override_rate, + evaluate_policy, + request_signoff, + submit_override, + submit_protected_override, + verified_records, +) +from legis.service.wardline import route_wardline_scan +from legis.store.audit_store import AuditStore +from legis.wardline.governor import WardlineCellPolicy +from legis.wardline.ingest import WardlineSeverity + + +@dataclass +class McpRuntime: + agent_id: str + engine: EnforcementEngine | None = None + identity: Any | None = None + protected_gate: ProtectedGate | None = None + signoff_gate: Any | None = None + trail_verifier: TrailVerifier | None = None + grammar: Any | None = None + source_root: str | Path | None = None + wardline_artifact_key: bytes | None = None + wardline_cell: str | None = None + wardline_cell_by_severity: str | None = None + + +def build_runtime(agent_id: str) -> McpRuntime: + from legis.api.app import DEFAULT_GOVERNANCE_DB + + clock = SystemClock() + store = AuditStore(os.environ.get("LEGIS_GOVERNANCE_DB", DEFAULT_GOVERNANCE_DB)) + engine = EnforcementEngine(store, clock) + identity = None + clarion_url = os.environ.get("CLARION_API_URL") + if clarion_url: + from legis.identity.clarion_client import HttpClarionIdentity + from legis.identity.resolver import IdentityResolver + + identity = IdentityResolver(HttpClarionIdentity(clarion_url)) + + protected_gate = None + signoff_gate = None + trail_verifier = None + hmac_key = os.environ.get("LEGIS_HMAC_KEY") + if hmac_key: + key = hmac_key.encode("utf-8") + protected_policies = frozenset( + p.strip() + for p in os.environ.get("LEGIS_PROTECTED_POLICIES", "").split(",") + if p.strip() + ) + + class FailClosedJudge: + def evaluate(self, record: OverrideRecord) -> JudgeOpinion: + return JudgeOpinion( + verdict=Verdict.BLOCKED, + model="fail-closed-fallback", + rationale="No LLM judge client is configured on this MCP server.", + ) + + from legis.enforcement.signoff import SignoffGate + + protected_gate = ProtectedGate(store, clock, FailClosedJudge(), key) + signoff_gate = SignoffGate(store, clock, signer=True, key=key) + trail_verifier = TrailVerifier(key, protected_policies) + + return McpRuntime( + agent_id=agent_id, + engine=engine, + identity=identity, + protected_gate=protected_gate, + signoff_gate=signoff_gate, + trail_verifier=trail_verifier, + grammar=default_grammar(), + source_root=os.environ.get("LEGIS_SOURCE_ROOT") or os.getcwd(), + wardline_artifact_key=( + os.environ["LEGIS_WARDLINE_ARTIFACT_KEY"].encode("utf-8") + if os.environ.get("LEGIS_WARDLINE_ARTIFACT_KEY") + else None + ), + wardline_cell=os.environ.get("LEGIS_WARDLINE_CELL"), + wardline_cell_by_severity=os.environ.get("LEGIS_WARDLINE_CELL_BY_SEVERITY"), + ) + + +def _schema(required: list[str], properties: dict[str, dict[str, Any]]) -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "required": required, + "properties": properties, + } + + +def tool_definitions() -> list[dict[str, Any]]: + string = {"type": "string"} + object_schema = {"type": "object"} + return [ + { + "name": "submit_override", + "description": "Submit a simple-tier governance override as the launch-bound agent.", + "inputSchema": _schema( + ["policy", "entity", "rationale"], + {"policy": string, "entity": string, "rationale": string}, + ), + }, + { + "name": "protected_override", + "description": "Submit a protected-cell override as the launch-bound agent.", + "inputSchema": _schema( + ["policy", "entity", "rationale", "file_fingerprint", "ast_path"], + { + "policy": string, + "entity": string, + "rationale": string, + "file_fingerprint": string, + "ast_path": string, + }, + ), + }, + { + "name": "signoff_request", + "description": "Open a structured sign-off request as the launch-bound agent.", + "inputSchema": _schema( + ["policy", "entity", "rationale"], + {"policy": string, "entity": string, "rationale": string}, + ), + }, + { + "name": "policy_evaluate", + "description": "Evaluate a policy grammar boundary.", + "inputSchema": _schema( + ["policy", "target"], + {"policy": string, "target": object_schema}, + ), + }, + { + "name": "wardline_scan_results", + "description": "Route a Wardline scan using server-owned routing policy.", + "inputSchema": _schema(["scan"], {"scan": object_schema}), + }, + { + "name": "list_overrides", + "description": "Read the verified governance trail.", + "inputSchema": _schema([], {}), + }, + { + "name": "override_rate", + "description": "Evaluate the configured override-rate gate.", + "inputSchema": _schema([], {}), + }, + ] + + +def _tool_result(value: dict[str, Any]) -> dict[str, Any]: + return { + "content": [{"type": "text", "text": json.dumps(value, sort_keys=True)}], + "structuredContent": value, + } + + +def _tool_error(code: str, message: str) -> dict[str, Any]: + return { + "isError": True, + "content": [{"type": "text", "text": f"{code}: {message}"}], + "structuredContent": {"error_code": code, "message": message}, + } + + +def _service_error(exc: Exception) -> dict[str, Any]: + if isinstance(exc, AuditIntegrityError): + return _tool_error("AUDIT_INTEGRITY_FAILURE", str(exc)) + if isinstance(exc, NotEnabledError): + return _tool_error("NOT_ENABLED", str(exc)) + if isinstance(exc, NotFoundError): + return _tool_error("NOT_FOUND", str(exc)) + if isinstance(exc, InvalidArgumentError): + return _tool_error("INVALID_ARGUMENT", str(exc)) + if isinstance(exc, ServiceError): + return _tool_error("SERVICE_ERROR", str(exc)) + if isinstance(exc, ValueError): + return _tool_error("INVALID_ARGUMENT", str(exc)) + return _tool_error("INTERNAL_ERROR", str(exc)) + + +def _arguments(params: dict[str, Any]) -> tuple[str, dict[str, Any]]: + name = params.get("name") + arguments = params.get("arguments", {}) + if not isinstance(name, str): + raise ValueError("tools/call requires a string tool name") + if not isinstance(arguments, dict): + raise ValueError("tools/call arguments must be an object") + return name, arguments + + +def _parse_wardline_cell_map(raw: str) -> dict[WardlineSeverity, WardlineCellPolicy]: + mapping: dict[WardlineSeverity, WardlineCellPolicy] = {} + for part in raw.split(","): + if not part.strip(): + continue + severity_raw, sep, cell_raw = part.partition("=") + if not sep: + raise ValueError("Wardline cell map entries must be SEVERITY=cell") + mapping[WardlineSeverity[severity_raw.strip()]] = WardlineCellPolicy( + cell_raw.strip() + ) + if not mapping: + raise ValueError("Wardline cell map must not be empty") + return mapping + + +def _require(args: dict[str, Any], key: str) -> str: + value = args.get(key) + if not isinstance(value, str) or not value: + raise ValueError(f"argument {key!r} must be a non-empty string") + return value + + +def call_tool(runtime: McpRuntime, name: str, args: dict[str, Any]) -> dict[str, Any]: + try: + if name == "submit_override": + if runtime.engine is None: + raise NotEnabledError("governance engine not enabled") + override_result = submit_override( + runtime.engine, + identity=runtime.identity, + policy=_require(args, "policy"), + entity=_require(args, "entity"), + rationale=_require(args, "rationale"), + agent_id=runtime.agent_id, + ) + return _tool_result( + { + "accepted": override_result.accepted, + "seq": override_result.seq, + "verdict": override_result.verdict.value if override_result.verdict else None, + "judge_model": override_result.judge_model, + "judge_rationale": override_result.judge_rationale, + } + ) + if name == "protected_override": + protected_result = submit_protected_override( + runtime.protected_gate, + identity=runtime.identity, + policy=_require(args, "policy"), + entity=_require(args, "entity"), + rationale=_require(args, "rationale"), + agent_id=runtime.agent_id, + file_fingerprint=_require(args, "file_fingerprint"), + ast_path=_require(args, "ast_path"), + source_root=runtime.source_root, + ) + return _tool_result( + { + "accepted": protected_result.accepted, + "seq": protected_result.seq, + "verdict": protected_result.verdict.value, + "judge_model": protected_result.judge_model, + "judge_rationale": protected_result.judge_rationale, + "signature": protected_result.signature, + } + ) + if name == "signoff_request": + signoff_result = request_signoff( + runtime.signoff_gate, + identity=runtime.identity, + policy=_require(args, "policy"), + entity=_require(args, "entity"), + rationale=_require(args, "rationale"), + agent_id=runtime.agent_id, + ) + return _tool_result({"seq": signoff_result.seq, "cleared": signoff_result.cleared}) + if name == "policy_evaluate": + grammar = runtime.grammar or default_grammar() + target = args.get("target") + if not isinstance(target, dict): + raise ValueError("argument 'target' must be an object") + policy_result = evaluate_policy( + grammar, + engine=runtime.engine, + policy=_require(args, "policy"), + target=target, + ) + return _tool_result( + { + "policy": policy_result.policy, + "result": policy_result.result.value, + "detail": policy_result.detail, + "provenance_gap": policy_result.provenance_gap, + } + ) + if name == "wardline_scan_results": + scan = args.get("scan") + if not isinstance(scan, dict): + raise ValueError("argument 'scan' must be an object") + if runtime.wardline_cell and runtime.wardline_cell_by_severity: + raise ValueError("server Wardline routing is misconfigured") + if runtime.wardline_cell_by_severity: + routed = route_wardline_scan( + scan, + agent_id=runtime.agent_id, + identity=runtime.identity, + engine=runtime.engine, + signoff=runtime.signoff_gate, + cell_map=_parse_wardline_cell_map(runtime.wardline_cell_by_severity), + artifact_key=runtime.wardline_artifact_key, + ) + elif runtime.wardline_cell: + routed = route_wardline_scan( + scan, + agent_id=runtime.agent_id, + identity=runtime.identity, + engine=runtime.engine, + signoff=runtime.signoff_gate, + policy=WardlineCellPolicy(runtime.wardline_cell), + artifact_key=runtime.wardline_artifact_key, + ) + else: + raise NotEnabledError("Wardline MCP routing is not configured") + return _tool_result({"routed": routed}) + if name == "list_overrides": + records = verified_records( + runtime.protected_gate, + runtime.trail_verifier, + lambda: runtime.engine.records() if runtime.engine is not None else [], + ) + return _tool_result({"records": [record.payload for record in records]}) + if name == "override_rate": + records = verified_records( + runtime.protected_gate, + runtime.trail_verifier, + lambda: runtime.engine.records() if runtime.engine is not None else [], + ) + rate_result = compute_override_rate(records) + return _tool_result( + { + "status": rate_result.status.value, + "rate": rate_result.rate, + "sample_size": rate_result.sample_size, + } + ) + return _tool_error("UNKNOWN_TOOL", f"unknown tool: {name}") + except Exception as exc: + return _service_error(exc) + + +def handle_request(request: dict[str, Any], runtime: McpRuntime) -> dict[str, Any] | None: + request_id = request.get("id") + method = request.get("method") + if request_id is None: + return None + result: dict[str, Any] + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "legis", "version": __version__}, + } + elif method == "tools/list": + result = {"tools": tool_definitions()} + elif method == "tools/call": + try: + name, args = _arguments(request.get("params", {})) + result = call_tool(runtime, name, args) + except Exception as exc: + result = _service_error(exc) + else: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def run_jsonrpc(input_stream: TextIO, output_stream: TextIO, runtime: McpRuntime) -> None: + for line in input_stream: + if not line.strip(): + continue + try: + request = json.loads(line) + if not isinstance(request, dict): + raise ValueError("JSON-RPC request must be an object") + response = handle_request(request, runtime) + except Exception as exc: + response = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": str(exc)}, + } + if response is not None: + output_stream.write(json.dumps(response, separators=(",", ":")) + "\n") + output_stream.flush() + + +def main(agent_id: str) -> int: + run_jsonrpc(sys.stdin, sys.stdout, build_runtime(agent_id)) + return 0 diff --git a/src/legis/policy/decorator.py b/src/legis/policy/decorator.py index 87c7783..3eb11dd 100644 --- a/src/legis/policy/decorator.py +++ b/src/legis/policy/decorator.py @@ -32,7 +32,7 @@ # distinguish a real root-level file from a coincidental ``word.ext``. The bar # this enforces is rejecting multi-word / whitespace vibe strings, not proving # the path exists. -_CITATION_RE = re.compile(r"^(https?://\S+|[0-9a-f]{7,40}|[\w./-]+\.[A-Za-z0-9]+(:\d+)?)$") +_CITATION_RE = re.compile(r"^(https?://\S+|[0-9a-fA-F]{7,64}|[\w./-]+\.[A-Za-z0-9]+(:\d+)?)$") def _is_citation(source: str) -> bool: @@ -90,6 +90,19 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return decorator +def get_normalized_ast_str(source: str) -> str: + import ast + parsed = ast.parse(source) + # Strip docstrings + for node in ast.walk(parsed): + if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Module)): + if node.body and isinstance(node.body[0], ast.Expr): + val = node.body[0].value + if isinstance(val, ast.Constant) and isinstance(val.value, str): + node.body.pop(0) + return ast.dump(parsed) + + def fingerprint(test_fn: Callable[..., Any]) -> str: """Content hash of a test function's source — the gate's anti-vibe teeth. @@ -97,7 +110,21 @@ def fingerprint(test_fn: Callable[..., Any]) -> str: test, unchanged since review. (This proves the test is *pinned*, not that it *meaningfully* exercises the boundary — see the plan's known limitations.) """ - return content_hash(inspect.getsource(test_fn)) + try: + source = inspect.getsource(test_fn) + except (OSError, TypeError) as exc: + raise OSError(f"Source code not available for test: {exc}") from exc + + # Normalize CRLF to LF to handle platform line ending differences + source = source.replace("\r\n", "\n") + + try: + import textwrap + source = textwrap.dedent(source) + normalized = get_normalized_ast_str(source) + return content_hash(normalized) + except Exception: + return content_hash(source) @dataclass(frozen=True) @@ -108,10 +135,18 @@ class GateFinding: def check_policy_boundary(func: Callable[..., Any], resolver) -> GateFinding: """Honesty gate. The decorator's evidence must be real and current.""" + if hasattr(func, "__func__"): + func = func.__func__ + meta = getattr(func, "__policy_boundary__", None) if meta is None: return GateFinding(False, "not a @policy_boundary function") + # Scope / metadata-integrity: the record must belong to this function. + wrapped = getattr(func, "__wrapped__", func) + if wrapped is not meta.func: + return GateFinding(False, "metadata transplant detected: function object identity mismatch") + if meta.qualname != func.__qualname__: return GateFinding(False, f"scope/qualname mismatch: {meta.qualname!r}") if not meta.source: @@ -130,11 +165,56 @@ def check_policy_boundary(func: Callable[..., Any], resolver) -> GateFinding: test_fn = resolver(meta.test_ref) if test_fn is None: return GateFinding(False, f"test_ref {meta.test_ref!r} points to no test") - if fingerprint(test_fn) != meta.test_fingerprint: + + try: + fp = fingerprint(test_fn) + except OSError as exc: + return GateFinding(False, str(exc)) + + if fp != meta.test_fingerprint: return GateFinding(False, "test drifted: fingerprint does not match") - src = inspect.getsource(test_fn) - if func.__name__ not in src: + + try: + src = inspect.getsource(test_fn) + except (OSError, TypeError) as exc: + return GateFinding(False, f"source code not available for test: {exc}") + + import ast + try: + parsed_test = ast.parse(src) + except Exception: + parsed_test = None + + func_called = False + if parsed_test is not None: + for node in ast.walk(parsed_test): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in (func.__name__, wrapped.__name__): + func_called = True + break + elif isinstance(node.func, ast.Attribute) and node.func.attr in (func.__name__, wrapped.__name__): + func_called = True + break + else: + func_called = False + + if not func_called: return GateFinding(False, "test does not appear to exercise the boundary") - if not any(p in src for p in meta.suppresses): + + policy_referenced = False + if parsed_test is not None: + for node in ast.walk(parsed_test): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if any(re.search(r'\b' + re.escape(p) + r'\b', node.value) for p in meta.suppresses): + policy_referenced = True + break + elif isinstance(node, ast.Name) and node.id in meta.suppresses: + policy_referenced = True + break + else: + policy_referenced = any(p in src for p in meta.suppresses) + + if not policy_referenced: return GateFinding(False, "test does not assert any suppressed policy") + return GateFinding(True, f"ok (invariant: {meta.invariant})") diff --git a/src/legis/policy/exemptions.py b/src/legis/policy/exemptions.py index a6c8c9a..ca776bc 100644 --- a/src/legis/policy/exemptions.py +++ b/src/legis/policy/exemptions.py @@ -52,10 +52,17 @@ def load_exemptions(path: str | Path) -> ExemptionRegistry: f"exemption[{i}] is malformed: expected a table ([[exemption]]), " f"got {type(entry).__name__!r}" ) - missing = [k for k in ("policy", "value", "reason") if not entry.get(k)] + missing = [] + for k in ("policy", "value", "reason"): + if k not in entry: + missing.append(k) + else: + val = entry[k] + if val is None or (isinstance(val, str) and not val.strip()): + missing.append(k) if missing: raise ValueError( f"exemption[{i}] is malformed: missing/empty {', '.join(missing)}" ) - exemptions.append(Exemption(entry["policy"], entry["value"], entry["reason"])) + exemptions.append(Exemption(str(entry["policy"]), str(entry["value"]), str(entry["reason"]))) return ExemptionRegistry(exemptions) diff --git a/src/legis/policy/grammar.py b/src/legis/policy/grammar.py index ed6ef18..124cacc 100644 --- a/src/legis/policy/grammar.py +++ b/src/legis/policy/grammar.py @@ -12,7 +12,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Hashable, Mapping from dataclasses import dataclass from enum import Enum from typing import Any, Protocol, runtime_checkable @@ -87,6 +87,7 @@ def evaluate(self, policy: str, target: Mapping[str, Any]) -> PolicyEvaluation: result is PolicyResult.VIOLATION and self._exemptions is not None and "value" in target + and isinstance(target["value"], str) ): ex = self._exemptions.is_exempt(policy, target["value"]) if ex is not None: diff --git a/src/legis/service/__init__.py b/src/legis/service/__init__.py index c411963..357709d 100644 --- a/src/legis/service/__init__.py +++ b/src/legis/service/__init__.py @@ -8,19 +8,36 @@ from legis.service.errors import ( AuditIntegrityError, + InvalidArgumentError, NotEnabledError, NotFoundError, ServiceError, ) -from legis.service.governance import compute_override_rate, resolve_for_record, submit_override, verified_records +from legis.service.governance import ( + compute_override_rate, + evaluate_policy, + request_signoff, + resolve_for_record, + submit_override, + submit_operator_override, + submit_protected_override, + verified_records, +) +from legis.service.wardline import route_wardline_scan __all__ = [ "ServiceError", "AuditIntegrityError", + "InvalidArgumentError", "NotEnabledError", "NotFoundError", "compute_override_rate", + "evaluate_policy", + "request_signoff", "resolve_for_record", "submit_override", + "submit_operator_override", + "submit_protected_override", + "route_wardline_scan", "verified_records", ] diff --git a/src/legis/service/errors.py b/src/legis/service/errors.py index 885566c..8ec8af0 100644 --- a/src/legis/service/errors.py +++ b/src/legis/service/errors.py @@ -22,3 +22,7 @@ class NotEnabledError(ServiceError): class NotFoundError(ServiceError): """A referenced resource (record, request, PR) does not exist.""" + + +class InvalidArgumentError(ServiceError): + """Caller input is structurally valid for the transport but invalid for Legis.""" diff --git a/src/legis/service/governance.py b/src/legis/service/governance.py index a82d3f5..2645e04 100644 --- a/src/legis/service/governance.py +++ b/src/legis/service/governance.py @@ -8,14 +8,19 @@ from __future__ import annotations from collections.abc import Callable +from pathlib import Path +from typing import Any from legis.enforcement.engine import EnforcementEngine, EnforcementResult from legis.enforcement.lifecycle import evaluate_override_rate -from legis.enforcement.protected import TamperError +from legis.enforcement.protected import ProtectedGate, ProtectedResult, TamperError +from legis.enforcement.signoff import SignoffGate, SignoffResult from legis.governance import params from legis.identity.entity_key import EntityKey from legis.identity.resolver import IdentityResolver -from legis.service.errors import AuditIntegrityError +from legis.policy.grammar import PolicyEvaluation, PolicyGrammar, PolicyResult +from legis.service.errors import AuditIntegrityError, NotEnabledError +from legis.service.source_binding import verify_current_source_binding def resolve_for_record( @@ -34,10 +39,20 @@ def resolve_for_record( res = identity.resolve(locator) ext: dict = {} if res.alive is not None: + identity_status = getattr( + res, "identity_resolution_status", "resolved" if res.alive else "not_alive" + ) + lineage_status = getattr( + res, + "lineage_snapshot_status", + "verified" if res.lineage_snapshot is not None else "not_applicable", + ) ext["clarion"] = { "alive": res.alive, "content_hash": res.content_hash, "lineage_snapshot": res.lineage_snapshot, + "identity_resolution_status": identity_status, + "lineage_snapshot_status": lineage_status, } return res.entity_key, ext @@ -62,6 +77,9 @@ def verified_records( """ if protected_gate is not None: records = protected_gate.records() + verify_integrity = getattr(protected_gate, "verify_integrity", None) + if verify_integrity is not None and not verify_integrity(): + raise AuditIntegrityError("audit integrity failure: database hash chain verification failed") if trail_verifier is not None: try: trail_verifier.verify(records) @@ -113,3 +131,110 @@ def submit_override( agent_id=agent_id, extensions=ext, ) + + +def submit_protected_override( + protected_gate: ProtectedGate | None, + *, + identity: IdentityResolver | None, + policy: str, + entity: str, + rationale: str, + agent_id: str, + file_fingerprint: str, + ast_path: str, + source_root: str | Path | None = None, +) -> ProtectedResult: + """Submit a protected-cell override using transport-bound agent identity.""" + if protected_gate is None: + raise NotEnabledError("protected cell not enabled") + entity_key, ext = resolve_for_record(identity, entity) + source_binding = verify_current_source_binding( + entity=entity, + file_fingerprint=file_fingerprint, + source_root=source_root, + ) + return protected_gate.submit( + policy=policy, + entity_key=entity_key, + rationale=rationale, + agent_id=agent_id, + file_fingerprint=file_fingerprint, + ast_path=ast_path, + extensions={**ext, "source_binding": source_binding}, + ) + + +def submit_operator_override( + protected_gate: ProtectedGate | None, + *, + identity: IdentityResolver | None, + policy: str, + entity: str, + rationale: str, + operator_id: str, + file_fingerprint: str, + ast_path: str, + source_root: str | Path | None = None, +) -> ProtectedResult: + """Submit a protected-cell operator override with current-source binding.""" + if protected_gate is None: + raise NotEnabledError("protected cell not enabled") + entity_key, ext = resolve_for_record(identity, entity) + source_binding = verify_current_source_binding( + entity=entity, + file_fingerprint=file_fingerprint, + source_root=source_root, + ) + return protected_gate.operator_override( + policy=policy, + entity_key=entity_key, + rationale=rationale, + operator_id=operator_id, + file_fingerprint=file_fingerprint, + ast_path=ast_path, + extensions={**ext, "source_binding": source_binding}, + ) + + +def request_signoff( + signoff_gate: SignoffGate | None, + *, + identity: IdentityResolver | None, + policy: str, + entity: str, + rationale: str, + agent_id: str, +) -> SignoffResult: + """Open a structured sign-off request for a launch-bound agent.""" + if signoff_gate is None: + raise NotEnabledError("structured cell not enabled") + entity_key, ext = resolve_for_record(identity, entity) + return signoff_gate.request( + policy=policy, + entity_key=entity_key, + rationale=rationale, + agent_id=agent_id, + extensions=ext, + ) + + +def evaluate_policy( + grammar: PolicyGrammar, + *, + engine: EnforcementEngine | None, + policy: str, + target: dict[str, Any], +) -> PolicyEvaluation: + """Evaluate policy grammar and optionally record UNKNOWN provenance gaps.""" + ev = grammar.evaluate(policy, target) + if ev.result is PolicyResult.UNKNOWN and engine is not None: + engine.record_event( + { + "event": "UNKNOWN_POLICY", + "policy": ev.policy, + "detail": ev.detail, + "provenance_gap": True, + } + ) + return ev diff --git a/src/legis/service/source_binding.py b/src/legis/service/source_binding.py new file mode 100644 index 0000000..a76a5d6 --- /dev/null +++ b/src/legis/service/source_binding.py @@ -0,0 +1,79 @@ +"""Current-source binding checks for protected governance submissions.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from legis.service.errors import InvalidArgumentError + + +def _source_path_from_entity(entity: str) -> str | None: + locator = entity.strip() + if not locator: + return None + candidate = locator.split(":", 1)[0] + if not candidate.endswith(".py"): + return None + if Path(candidate).is_absolute(): + raise InvalidArgumentError("source path must be relative to the configured source root") + return candidate + + +def _relative_to_root(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError as exc: + raise InvalidArgumentError("source path escapes configured source root") from exc + + +def verify_current_source_binding( + *, + entity: str, + file_fingerprint: str, + source_root: str | Path | None, +) -> dict[str, Any]: + """Return signed provenance, rejecting stale hashes when source is available. + + Entity keys remain opaque to downstream consumers, but protected override + requests currently use locators such as ``src/x.py:f``. When such a Python + source locator resolves to an existing file under ``source_root``, the caller + supplied fingerprint must match the current bytes exactly before the judge is + consulted or an HMAC signature is produced. + """ + source_path = _source_path_from_entity(entity) + if source_path is None: + return { + "status": "unverified", + "reason": "entity is not a Python source locator", + } + if source_root is None: + return { + "status": "unverified", + "reason": "source root not configured", + "source_path": source_path, + } + + root = Path(source_root).resolve() + candidate = (root / source_path).resolve() + rel = _relative_to_root(candidate, root) + if not candidate.exists(): + return { + "status": "unverified", + "reason": "source file not found", + "source_path": rel, + } + if not candidate.is_file(): + raise InvalidArgumentError("source path is not a regular file") + + current_fingerprint = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest() + if file_fingerprint != current_fingerprint: + raise InvalidArgumentError( + f"fingerprint does not match current source for {rel}" + ) + return { + "status": "verified", + "source_path": rel, + "current_fingerprint": current_fingerprint, + } diff --git a/src/legis/service/wardline.py b/src/legis/service/wardline.py new file mode 100644 index 0000000..c2c4ded --- /dev/null +++ b/src/legis/service/wardline.py @@ -0,0 +1,58 @@ +"""Transport-agnostic Wardline governance routing.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from legis.canonical import content_hash +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.signoff import SignoffGate +from legis.identity.entity_key import EntityKey +from legis.identity.resolver import IdentityResolver +from legis.service.governance import resolve_for_record +from legis.wardline.governor import WardlineCellPolicy, route_findings +from legis.wardline.ingest import ( + WardlineSeverity, + active_defects, + verify_wardline_artifact, + wardline_artifact_fields, +) + + +def route_wardline_scan( + scan: Mapping[str, Any], + *, + agent_id: str, + identity: IdentityResolver | None, + engine: EnforcementEngine | None, + signoff: SignoffGate | None, + policy: WardlineCellPolicy | None = None, + cell_map: dict[WardlineSeverity, WardlineCellPolicy] | None = None, + artifact_key: bytes | None = None, +) -> list[dict[str, Any]]: + artifact_provenance = verify_wardline_artifact(scan, artifact_key) + findings = active_defects(scan) + + def resolve(qualname: str | None) -> tuple[EntityKey, dict[str, Any]]: + if qualname: + return resolve_for_record(identity, qualname) + return EntityKey.from_locator("unknown"), {} + + raw_findings = scan.get("findings", []) + batch_provenance = { + "scan_digest": f"sha256:{content_hash(wardline_artifact_fields(scan))}", + "finding_count": len(raw_findings) if isinstance(raw_findings, list) else 0, + "active_count": len(findings), + **artifact_provenance, + } + return route_findings( + findings, + policy=policy, + cell_map=cell_map, + agent_id=agent_id, + resolve=resolve, + engine=engine, + signoff=signoff, + batch_provenance=batch_provenance, + ) diff --git a/src/legis/store/audit_store.py b/src/legis/store/audit_store.py index e0aad13..15a5681 100644 --- a/src/legis/store/audit_store.py +++ b/src/legis/store/audit_store.py @@ -55,6 +55,23 @@ def __init__(self, url: str) -> None: # NullPool: hold no connection between operations — an append-only # audit store wants no lingering locks and clean resource lifecycle. self._engine = create_engine(url, future=True, poolclass=NullPool) + + from sqlalchemy import event + @event.listens_for(self._engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + if "sqlite" in url: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA busy_timeout=5000") + except Exception: + pass + finally: + cursor.close() + + # Remove the global force_immediate_transaction event listener to prevent locking on read-only queries. + self._md = MetaData() self._log = Table( "audit_log", @@ -69,25 +86,28 @@ def __init__(self, url: str) -> None: self._install_append_only_triggers() def _install_append_only_triggers(self) -> None: - with self._engine.begin() as conn: - conn.execute( - text( - "CREATE TRIGGER IF NOT EXISTS audit_log_no_update " - "BEFORE UPDATE ON audit_log BEGIN " - "SELECT RAISE(ABORT, 'audit_log is append-only'); END;" + if self._engine.dialect.name == "sqlite": + with self._engine.begin() as conn: + conn.execute( + text( + "CREATE TRIGGER IF NOT EXISTS audit_log_no_update " + "BEFORE UPDATE ON audit_log BEGIN " + "SELECT RAISE(ABORT, 'audit_log is append-only'); END;" + ) ) - ) - conn.execute( - text( - "CREATE TRIGGER IF NOT EXISTS audit_log_no_delete " - "BEFORE DELETE ON audit_log BEGIN " - "SELECT RAISE(ABORT, 'audit_log is append-only'); END;" + conn.execute( + text( + "CREATE TRIGGER IF NOT EXISTS audit_log_no_delete " + "BEFORE DELETE ON audit_log BEGIN " + "SELECT RAISE(ABORT, 'audit_log is append-only'); END;" + ) ) - ) def append(self, payload: dict[str, Any]) -> int: c_hash = content_hash(payload) with self._engine.begin() as conn: + if conn.dialect.name == "sqlite": + conn.execute(text("BEGIN IMMEDIATE")) prev = conn.execute( select(self._log.c.chain_hash) .order_by(self._log.c.seq.desc()) @@ -102,7 +122,10 @@ def append(self, payload: dict[str, Any]) -> int: chain_hash=_chain(prev_hash, c_hash), ) ) - return int(result.inserted_primary_key[0]) + primary_key = result.inserted_primary_key + if primary_key is None: + raise RuntimeError("audit_log insert did not return a primary key") + return int(primary_key[0]) def read_all(self) -> list[AuditRecord]: with self._engine.begin() as conn: @@ -120,6 +143,21 @@ def read_all(self) -> list[AuditRecord]: for r in rows ] + def read_by_seq(self, seq: int) -> AuditRecord | None: + with self._engine.begin() as conn: + row = conn.execute( + select(self._log).where(self._log.c.seq == seq) + ).first() + if row is None: + return None + return AuditRecord( + seq=row.seq, + payload=json.loads(row.payload), + content_hash=row.content_hash, + prev_hash=row.prev_hash, + chain_hash=row.chain_hash, + ) + def verify_integrity(self) -> bool: prev_hash = GENESIS for rec in self.read_all(): @@ -131,3 +169,14 @@ def verify_integrity(self) -> bool: return False prev_hash = rec.chain_hash return True + + def get_latest_sequence_and_hash(self) -> tuple[int, str]: + with self._engine.begin() as conn: + row = conn.execute( + select(self._log.c.seq, self._log.c.chain_hash) + .order_by(self._log.c.seq.desc()) + .limit(1) + ).first() + if row is None: + return 0, GENESIS + return row.seq, row.chain_hash diff --git a/src/legis/wardline/governor.py b/src/legis/wardline/governor.py index d718b3a..6534a41 100644 --- a/src/legis/wardline/governor.py +++ b/src/legis/wardline/governor.py @@ -4,9 +4,8 @@ The cell is configured either for the whole scan via ``policy`` (single cell, the proven Wardline handshake path) or per-severity via ``cell_map`` (``dict[WardlineSeverity, WardlineCellPolicy]``). Exactly one must be given. -With ``cell_map``, each finding's cell is ``cell_map.get(f.severity, -SURFACE_OVERRIDE)`` — unmapped severities fall back to SURFACE_OVERRIDE so -routing is always attributable, never a silent hard gate. +With ``cell_map``, every severity present in the scan must be mapped explicitly; +an omission is a configuration error, not an implicit downgrade. The finding's ``rule_id`` is the policy; its ``qualname`` is the entity to key on (resolved to an SEI via the same Sprint-5 resolver when available); its @@ -16,9 +15,8 @@ ``fingerprint``, trust ``tiers`` (the one shared vocabulary, verbatim from ``properties``), and ``severity`` in the record's ``extensions``. * **block+escalate** opens a sign-off request carrying ``rule_id`` (policy), - the resolved entity, and the seeded rationale. Carrying the Wardline tiers - onto the sign-off record is deferred: ``SignoffGate.request`` has no - ``extensions`` field yet. + the resolved entity, the seeded rationale, and the same Clarion/Wardline + evidence extensions the surface paths preserve. * **surface+only** records a non-gating ``wardline_surfaced`` governance event via ``EnforcementEngine.record_event``; no judge, no sign-off gate. Carries ``entity_key`` + ``clarion`` and ``wardline`` extensions so it is @@ -29,7 +27,7 @@ from collections.abc import Callable from enum import Enum -from typing import Any +from typing import Any, Mapping from legis.enforcement.engine import EnforcementEngine from legis.enforcement.signoff import SignoffGate @@ -52,6 +50,7 @@ def route_findings( resolve: Callable[[str | None], tuple[EntityKey, dict[str, Any]]], engine: EnforcementEngine | None = None, signoff: SignoffGate | None = None, + batch_provenance: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: if (policy is None) == (cell_map is None): raise ValueError("exactly one of policy or cell_map must be given") @@ -63,10 +62,17 @@ def route_findings( # transactional atomicity: a successful mixed batch spans two append-only # stores (engine and signoff), and a mid-loop runtime failure leaves any # prior writes in those stores permanently persisted. - # With cell_map, SURFACE_OVERRIDE is always reachable (unmapped severity - # falls back to it), so engine is effectively required. - cells_needed = (set(cell_map.values()) | {WardlineCellPolicy.SURFACE_OVERRIDE} - if cell_map is not None else {policy}) + if cell_map is not None: + missing = {f.severity for f in findings} - set(cell_map) + if missing: + names = ", ".join(sorted(sev.value for sev in missing)) + raise ValueError(f"unmapped severity in cell_map: {names}") + + if cell_map is not None: + cells_needed = set(cell_map.values()) + else: + assert policy is not None + cells_needed = {policy} if engine is None and (WardlineCellPolicy.SURFACE_OVERRIDE in cells_needed or WardlineCellPolicy.SURFACE_ONLY in cells_needed): raise ValueError("surface cell(s) require an engine") @@ -75,7 +81,7 @@ def route_findings( def cell_for(f: WardlineFinding) -> WardlineCellPolicy: if cell_map is not None: - return cell_map.get(f.severity, WardlineCellPolicy.SURFACE_OVERRIDE) + return cell_map[f.severity] assert policy is not None return policy @@ -84,15 +90,21 @@ def cell_for(f: WardlineFinding) -> WardlineCellPolicy: cell = cell_for(f) entity_key, clarion_ext = resolve(f.qualname) rationale = f"[wardline {f.rule_id}] {f.message}" - wardline_ext = {"fingerprint": f.fingerprint, "tiers": dict(f.properties), - "severity": f.severity.value} + wardline_ext = { + "fingerprint": f.fingerprint, + "tiers": dict(f.properties), + "severity": f.severity.value, + **dict(batch_provenance or {}), + } if cell is WardlineCellPolicy.BLOCK_ESCALATE: if signoff is None: raise ValueError("block_escalate cell requires a signoff gate") - res = signoff.request(policy=f.rule_id, entity_key=entity_key, - rationale=rationale, agent_id=agent_id) + ext = {**clarion_ext, "wardline": wardline_ext} + signoff_result = signoff.request(policy=f.rule_id, entity_key=entity_key, + rationale=rationale, agent_id=agent_id, + extensions=ext) results.append({"mode": cell.value, "fingerprint": f.fingerprint, - "seq": res.seq, "cleared": res.cleared}) + "seq": signoff_result.seq, "cleared": signoff_result.cleared}) elif cell is WardlineCellPolicy.SURFACE_OVERRIDE: if engine is None: raise ValueError("surface_override cell requires an engine") @@ -100,11 +112,11 @@ def cell_for(f: WardlineFinding) -> WardlineCellPolicy: # so a wardline-routed override carries the same lineage snapshot a # /overrides override would. ext = {**clarion_ext, "wardline": wardline_ext} - res = engine.submit_override(policy=f.rule_id, entity_key=entity_key, - rationale=rationale, agent_id=agent_id, - extensions=ext) + override_result = engine.submit_override(policy=f.rule_id, entity_key=entity_key, + rationale=rationale, agent_id=agent_id, + extensions=ext) results.append({"mode": cell.value, "fingerprint": f.fingerprint, - "seq": res.seq, "accepted": res.accepted}) + "seq": override_result.seq, "accepted": override_result.accepted}) elif cell is WardlineCellPolicy.SURFACE_ONLY: # recorded, non-gating if engine is None: diff --git a/src/legis/wardline/ingest.py b/src/legis/wardline/ingest.py index 34eb803..384f146 100644 --- a/src/legis/wardline/ingest.py +++ b/src/legis/wardline/ingest.py @@ -11,23 +11,100 @@ from enum import Enum from typing import Any, Mapping +from legis.enforcement.signing import verify + # The shared trust vocabulary (Wardline taints.py) — carried, never re-derived. TRUST_TIERS: frozenset[str] = frozenset({ "INTEGRAL", "ASSURED", "GUARDED", "EXTERNAL_RAW", "UNKNOWN_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED", "MIXED_RAW", }) +SUPPRESSION_PROOF_KEYS: frozenset[str] = frozenset({ + "suppression_proof", + "suppression_ticket", + "suppression_reason", +}) +MAX_FINDINGS = 500 +ARTIFACT_SIGNATURE_FIELD = "artifact_signature" +ARTIFACT_PROVENANCE_FIELDS: tuple[str, ...] = ( + "scanner_identity", + "rule_set_version", + "commit_sha", + "tree_sha", +) + +class WardlineSeverity(str, Enum): + rank: int -class WardlineSeverity(Enum): CRITICAL = ("CRITICAL", 4) ERROR = ("ERROR", 3) WARN = ("WARN", 2) INFO = ("INFO", 1) NONE = ("NONE", 0) - def __init__(self, value: str, rank: int) -> None: - self._value_ = value - self.rank = rank + def __new__(cls, value: str, rank: int) -> "WardlineSeverity": + obj = str.__new__(cls, value) + obj._value_ = value + obj.rank = rank + return obj + + +class WardlinePayloadError(ValueError): + """A Wardline scan payload is not shaped like the trusted wire contract.""" + + +def wardline_artifact_fields(scan: Mapping[str, Any]) -> dict[str, Any]: + """The Wardline artifact payload covered by ``artifact_signature``.""" + if not isinstance(scan, Mapping): + raise WardlinePayloadError("scan must be an object") + return { + str(key): value + for key, value in scan.items() + if key != ARTIFACT_SIGNATURE_FIELD + } + + +def verify_wardline_artifact( + scan: Mapping[str, Any], + artifact_key: bytes | None, +) -> dict[str, Any]: + """Validate optional server-required artifact authentication. + + When ``artifact_key`` is configured, the scan must carry signed scanner, + rule-set, commit, and tree provenance. Without a configured key we still + record any supplied metadata, but mark it explicitly unverified. + """ + fields = wardline_artifact_fields(scan) + provenance = { + "artifact_status": "unverified", + } + for key in ARTIFACT_PROVENANCE_FIELDS: + value = scan.get(key) + if isinstance(value, str) and value: + provenance[key] = value + + if artifact_key is None: + return provenance + + missing = [ + key for key in ARTIFACT_PROVENANCE_FIELDS + if not isinstance(scan.get(key), str) or not scan[key] + ] + if missing: + raise WardlinePayloadError( + f"Wardline artifact missing required field(s): {', '.join(missing)}" + ) + + signature = scan.get(ARTIFACT_SIGNATURE_FIELD) + if not isinstance(signature, str) or not signature: + raise WardlinePayloadError("Wardline artifact signature is required") + if not verify(fields, signature, artifact_key): + raise WardlinePayloadError("Wardline artifact signature does not verify") + return { + "artifact_status": "verified", + **{key: scan[key] for key in ARTIFACT_PROVENANCE_FIELDS}, + "artifact_signature": signature, + } @dataclass(frozen=True) @@ -43,23 +120,94 @@ class WardlineFinding: @classmethod def from_wire(cls, d: Mapping[str, Any]) -> "WardlineFinding": + missing = [ + key + for key in ("rule_id", "message", "severity", "kind", "fingerprint") + if key not in d + ] + if missing: + raise WardlinePayloadError( + f"finding missing required field(s): {', '.join(missing)}" + ) + severity_raw = d["severity"] + if not isinstance(severity_raw, str): + raise WardlinePayloadError("finding severity must be a string") + try: + severity = WardlineSeverity[severity_raw] + except KeyError as exc: + raise WardlinePayloadError(f"unknown Wardline severity: {severity_raw}") from exc + properties = d.get("properties", {}) + if not isinstance(properties, Mapping): + raise WardlinePayloadError("finding properties must be an object") + _validate_trust_properties(properties) + qualname = d.get("qualname") + if qualname is not None and not isinstance(qualname, str): + raise WardlinePayloadError("finding qualname must be a string or null") + suppressed = d.get("suppressed", "active") + if not isinstance(suppressed, str): + raise WardlinePayloadError("finding suppressed must be a string") + for key in ("rule_id", "message", "kind", "fingerprint"): + if not isinstance(d[key], str) or not d[key]: + raise WardlinePayloadError(f"finding {key} must be a non-empty string") return cls( rule_id=d["rule_id"], message=d["message"], - severity=WardlineSeverity[d["severity"]], + severity=severity, kind=d["kind"], fingerprint=d["fingerprint"], - qualname=d.get("qualname"), - properties=dict(d.get("properties", {})), - suppressed=d.get("suppressed", "active"), + qualname=qualname, + properties=dict(properties), + suppressed=suppressed, ) +def _validate_trust_properties(properties: Mapping[str, Any]) -> None: + for key, value in properties.items(): + if key in SUPPRESSION_PROOF_KEYS: + if not isinstance(value, str) or not value.strip(): + raise WardlinePayloadError( + f"finding {key} must be a non-empty suppression proof string" + ) + continue + if not isinstance(value, str) or value not in TRUST_TIERS: + raise WardlinePayloadError( + f"finding property {key} has invalid trust tier: {value!r}" + ) + + +def _has_suppression_proof(properties: Mapping[str, Any]) -> bool: + return any( + isinstance(properties.get(key), str) and bool(properties[key].strip()) + for key in SUPPRESSION_PROOF_KEYS + ) + + def active_defects(scan: Mapping[str, Any]) -> list[WardlineFinding]: """The gate population: active (non-suppressed) DEFECT findings.""" + if not isinstance(scan, Mapping): + raise WardlinePayloadError("scan must be an object") + raw_findings = scan.get("findings", []) + if not isinstance(raw_findings, list): + raise WardlinePayloadError("scan findings must be a list") + if len(raw_findings) > MAX_FINDINGS: + raise WardlinePayloadError(f"scan findings exceeds maximum batch size {MAX_FINDINGS}") out: list[WardlineFinding] = [] - for raw in scan.get("findings", []): + for raw in raw_findings: + if not isinstance(raw, Mapping): + raise WardlinePayloadError("each finding must be an object") f = WardlineFinding.from_wire(raw) - if f.kind == "defect" and f.suppressed == "active": + if f.kind != "defect": + continue + if f.suppressed == "active": out.append(f) + continue + if f.suppressed in {"waived", "suppressed"}: + if not _has_suppression_proof(f.properties): + raise WardlinePayloadError( + "suppressed defect must carry suppression proof" + ) + continue + raise WardlinePayloadError( + f"unsupported suppression state for defect: {f.suppressed}" + ) return out diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..490dba2 --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,172 @@ +import pytest +from fastapi.testclient import TestClient + +from legis.api.app import create_app + + +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) + monkeypatch.delenv("LEGIS_API_TOKEN_ACTORS", raising=False) + client = TestClient(create_app()) + + resp = client.post( + "/overrides", + json={ + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + "agent_id": "agent-1", + }, + ) + + assert resp.status_code == 401 + + +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()) + + resp = client.post( + "/overrides", + json={ + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + "agent_id": "agent-1", + }, + ) + + assert resp.status_code == 201 + + +@pytest.mark.parametrize( + ("method", "path", "json"), + [ + ("post", "/checks", { + "check_name": "wardline", + "run_id": "run-1", + "commit_sha": "a" * 40, + "outcome": "pass", + }), + ("post", "/overrides", { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + "agent_id": "agent-1", + }), + ("post", "/protected/overrides", { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + "agent_id": "agent-1", + "file_fingerprint": "fp", + "ast_path": "ap", + }), + ("post", "/signoff/request", { + "policy": "prod-deploy", + "entity": "svc/api", + "rationale": "needs release manager", + "agent_id": "agent-1", + }), + ("post", "/signoff/1/bind-issue", {"issue_id": "ISSUE-1"}), + ("post", "/policy/evaluate", {"policy": "unknown", "target": {}}), + ("post", "/wardline/scan-results", { + "cell": "surface_only", + "agent_id": "agent-1", + "scan": {"findings": []}, + }), + ], +) +def test_mutating_routes_require_secret_when_configured(monkeypatch, method, path, json): + monkeypatch.setenv("LEGIS_API_SECRET", "super-secret") + client = TestClient(create_app()) + + resp = getattr(client, method)(path, json=json) + + assert resp.status_code == 401 + + +def test_scoped_tokens_separate_writer_and_operator_authority(monkeypatch, tmp_path): + monkeypatch.setenv( + "LEGIS_API_TOKEN_ACTORS", + "agent-a:writer=agent-token,op-a:operator=op-token", + ) + monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") + client = TestClient(create_app()) + + writer = {"Authorization": "Bearer agent-token"} + operator = {"Authorization": "Bearer op-token"} + protected_body = { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "override", + "operator_id": "spoofed-op", + "file_fingerprint": "fp", + "ast_path": "ap", + } + + assert client.post( + "/overrides", + json={ + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + "agent_id": "spoofed-agent", + }, + headers=writer, + ).status_code == 201 + assert client.post( + "/protected/operator-override", json=protected_body, headers=writer + ).status_code == 403 + assert client.post( + "/protected/operator-override", json=protected_body, headers=operator + ).status_code == 201 + + +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()) + + resp = client.post( + "/overrides", + json={ + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "local exception", + }, + headers={"Authorization": "Bearer agent-token"}, + ) + + assert resp.status_code == 201 + trail = client.get("/overrides").json() + assert trail[0]["agent_id"] == "agent-a" + + +def test_authenticated_operator_identity_does_not_require_body_operator_id( + monkeypatch, tmp_path +): + monkeypatch.setenv("LEGIS_API_TOKEN_ACTORS", "op-a:operator=op-token") + monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") + client = TestClient(create_app()) + + resp = client.post( + "/protected/operator-override", + json={ + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "operator exception", + "file_fingerprint": "fp", + "ast_path": "ap", + }, + headers={"Authorization": "Bearer op-token"}, + ) + + assert resp.status_code == 201 + trail = client.get("/overrides").json() + assert trail[0]["agent_id"] == "op-a" diff --git a/tests/api/test_combinations_api.py b/tests/api/test_combinations_api.py index a233bf6..3165193 100644 --- a/tests/api/test_combinations_api.py +++ b/tests/api/test_combinations_api.py @@ -1,11 +1,18 @@ +import json +import sqlite3 + from fastapi.testclient import TestClient from legis.api.app import create_app +from legis.canonical import canonical_json, content_hash from legis.clock import FixedClock from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import TrailVerifier from legis.enforcement.signoff import SignoffGate +from legis.enforcement.signing import sign from legis.identity.entity_key import EntityKey -from legis.store.audit_store import AuditStore +from legis.store.audit_store import GENESIS, AuditStore, _chain +from legis.wardline.ingest import wardline_artifact_fields def _client(tmp_path, **kw): @@ -28,6 +35,29 @@ def associations_for_entity(self, entity_id): return [] +def _tamper_first_record(db, mutate): + con = sqlite3.connect(db) + con.execute("DROP TRIGGER IF EXISTS audit_log_no_update") + seq, payload = con.execute( + "SELECT seq, payload FROM audit_log ORDER BY seq ASC LIMIT 1" + ).fetchone() + p = json.loads(payload) + mutate(p) + con.execute("UPDATE audit_log SET payload=? WHERE seq=?", (canonical_json(p), seq)) + prev = GENESIS + for s, pl in con.execute( + "SELECT seq, payload FROM audit_log ORDER BY seq ASC" + ).fetchall(): + ch = content_hash(json.loads(pl)) + con.execute( + "UPDATE audit_log SET content_hash=?, prev_hash=?, chain_hash=? WHERE seq=?", + (ch, prev, _chain(prev, ch), s), + ) + prev = _chain(prev, ch) + con.commit() + con.close() + + def test_scan_results_route_surface_override(tmp_path): c = _client(tmp_path) body = {"cell": "surface_override", "agent_id": "agent-1", "scan": {"findings": [ @@ -127,6 +157,41 @@ def test_bind_issue_records_to_ledger_and_binding_is_verifiable(tmp_path): assert got.json()["content_hash"] == "blake3" +def test_bind_issue_fails_closed_on_tampered_signed_signoff_request(tmp_path): + fil = _FakeFiligree() + db = tmp_path / "sg.db" + gate = SignoffGate( + AuditStore(f"sqlite:///{db}"), + FixedClock("2026-06-02T12:00:00+00:00"), + signer=True, + key=b"k", + ) + req = gate.request( + policy="prod-deploy", + entity_key=EntityKey.from_sei("clarion:eid:abc"), + rationale="r", + agent_id="a", + extensions={"clarion": {"content_hash": "blake3", "alive": True, + "lineage_snapshot": {"length": 1, "hash": "lh"}}}, + ) + gate.sign_off(request_seq=req.seq, operator_id="op-1") + _tamper_first_record( + db, + lambda p: p["extensions"]["clarion"].update({"content_hash": "forged"}), + ) + c = _client( + tmp_path, + signoff_gate=gate, + filigree=fil, + trail_verifier=TrailVerifier(b"k", frozenset()), + ) + + resp = c.post(f"/signoff/{req.seq}/bind-issue", json={"issue_id": "ISSUE-1"}) + + assert resp.status_code == 500 + assert fil.attached == [] + + def test_binding_read_404_when_no_ledger(tmp_path): c = _client(tmp_path) assert c.get("/signoff/1/binding").status_code == 404 @@ -157,6 +222,9 @@ def test_scan_results_surface_only_records_non_gating(tmp_path): assert resp.json()["routed"][0]["mode"] == "surface_only" trail = c.get("/overrides").json() assert trail[0]["kind"] == "wardline_surfaced" + assert trail[0]["extensions"]["wardline"]["finding_count"] == 1 + assert trail[0]["extensions"]["wardline"]["active_count"] == 1 + assert trail[0]["extensions"]["wardline"]["scan_digest"].startswith("sha256:") def test_scan_results_cell_by_severity_routes_per_finding(tmp_path): @@ -218,6 +286,152 @@ def test_scan_results_empty_cell_by_severity_is_422(tmp_path): assert c.post("/wardline/scan-results", json=body).status_code == 422 +def test_scan_results_rejects_malformed_findings_without_writing(tmp_path): + c = _client(tmp_path) + for scan in ( + {"findings": "not-a-list"}, + {"findings": [{"message": "missing rule", "severity": "ERROR", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f"}]}, + {"findings": [{"rule_id": "R", "message": "bad severity", "severity": "BOGUS", + "kind": "defect", "fingerprint": "fp", "qualname": "m.f"}]}, + ): + resp = c.post("/wardline/scan-results", + json={"cell": "surface_override", "agent_id": "agent-1", "scan": scan}) + assert resp.status_code == 422 + assert c.get("/overrides").json() == [] + + +def test_scan_results_rejects_suppressed_defect_without_proof(tmp_path): + c = _client(tmp_path) + scan = {"findings": [ + {"rule_id": "R-C", "message": "m", "severity": "CRITICAL", "kind": "defect", + "fingerprint": "c", "qualname": "m.f", "properties": {}, "suppressed": "waived"} + ]} + resp = c.post("/wardline/scan-results", + json={"cell": "surface_only", "agent_id": "a", "scan": scan}) + assert resp.status_code == 422 + assert c.get("/overrides").json() == [] + + +def test_scan_results_rejects_invalid_trust_tier_without_writing(tmp_path): + c = _client(tmp_path) + scan = {"findings": [ + {"rule_id": "R-C", "message": "m", "severity": "CRITICAL", "kind": "defect", + "fingerprint": "c", "qualname": "m.f", + "properties": {"actual_return": "ROOT"}, "suppressed": "active"} + ]} + resp = c.post("/wardline/scan-results", + json={"cell": "surface_only", "agent_id": "a", "scan": scan}) + assert resp.status_code == 422 + assert c.get("/overrides").json() == [] + + +def test_scan_results_rejects_oversized_finding_batch_without_writing(tmp_path): + c = _client(tmp_path) + finding = {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, + "suppressed": "active"} + scan = {"findings": [{**finding, "fingerprint": f"fp-{i}"} for i in range(501)]} + resp = c.post("/wardline/scan-results", + json={"cell": "surface_only", "agent_id": "a", "scan": scan}) + assert resp.status_code == 422 + assert c.get("/overrides").json() == [] + + +def test_scan_results_server_owned_routing_rejects_request_routing(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_WARDLINE_CELL", "surface_only") + c = _client(tmp_path) + body = {"cell": "surface_override", "agent_id": "a", "scan": {"findings": [ + {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, "suppressed": "active"} + ]}} + resp = c.post("/wardline/scan-results", json=body) + assert resp.status_code == 403 + assert "server-owned" in resp.json()["detail"] + + +def test_scan_results_default_rejects_request_owned_routing(tmp_path, monkeypatch): + monkeypatch.delenv("LEGIS_UNSAFE_WARDLINE_REQUEST_ROUTING", raising=False) + c = _client(tmp_path) + body = {"cell": "surface_only", "agent_id": "a", "scan": {"findings": [ + {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, "suppressed": "active"} + ]}} + + resp = c.post("/wardline/scan-results", json=body) + + assert resp.status_code == 403 + assert "server-owned" in resp.json()["detail"] + + +def test_scan_results_can_use_server_owned_single_cell(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_WARDLINE_CELL", "surface_only") + c = _client(tmp_path) + body = {"agent_id": "a", "scan": {"findings": [ + {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, "suppressed": "active"} + ]}} + resp = c.post("/wardline/scan-results", json=body) + assert resp.status_code == 200 + assert resp.json()["routed"][0]["mode"] == "surface_only" + + +def _signed_wardline_scan(scan, key=b"wardline-key"): + return { + **scan, + "artifact_signature": sign(wardline_artifact_fields(scan), key), + } + + +def test_scan_results_requires_signed_artifact_when_configured(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_WARDLINE_ARTIFACT_KEY", "wardline-key") + c = _client(tmp_path) + scan = { + "scanner_identity": "wardline@1", + "rule_set_version": "rules@abc123", + "commit_sha": "a" * 40, + "tree_sha": "b" * 40, + "findings": [ + {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, "suppressed": "active"} + ], + } + + resp = c.post("/wardline/scan-results", + json={"cell": "surface_only", "agent_id": "a", "scan": scan}) + + assert resp.status_code == 422 + assert "artifact signature" in resp.json()["detail"] + assert c.get("/overrides").json() == [] + + +def test_scan_results_records_verified_artifact_provenance(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_WARDLINE_ARTIFACT_KEY", "wardline-key") + c = _client(tmp_path) + scan = _signed_wardline_scan({ + "scanner_identity": "wardline@1", + "rule_set_version": "rules@abc123", + "commit_sha": "a" * 40, + "tree_sha": "b" * 40, + "findings": [ + {"rule_id": "R", "message": "m", "severity": "INFO", "kind": "defect", + "fingerprint": "fp", "qualname": "m.f", "properties": {}, "suppressed": "active"} + ], + }) + + resp = c.post("/wardline/scan-results", + json={"cell": "surface_only", "agent_id": "a", "scan": scan}) + + assert resp.status_code == 200 + wardline = c.get("/overrides").json()[0]["extensions"]["wardline"] + assert wardline["artifact_status"] == "verified" + assert wardline["scanner_identity"] == "wardline@1" + assert wardline["rule_set_version"] == "rules@abc123" + assert wardline["commit_sha"] == "a" * 40 + assert wardline["tree_sha"] == "b" * 40 + assert wardline["artifact_signature"].startswith("hmac-sha256:v2:") + + def test_scan_results_single_cell_still_works(tmp_path): c = _client(tmp_path) body = {"cell": "surface_override", "agent_id": "agent-1", "scan": {"findings": [ diff --git a/tests/api/test_complex_api.py b/tests/api/test_complex_api.py index 84fc2eb..ae96348 100644 --- a/tests/api/test_complex_api.py +++ b/tests/api/test_complex_api.py @@ -1,4 +1,5 @@ import json +import hashlib import sqlite3 from fastapi.testclient import TestClient @@ -32,13 +33,20 @@ def evaluate(self, record): } -def _app(tmp_path, opinion=JudgeOpinion(Verdict.ACCEPTED, "judge@1", "ok")): +def _fingerprint(path): + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def _app(tmp_path, opinion=JudgeOpinion(Verdict.ACCEPTED, "judge@1", "ok"), repo_path=None): store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") clock = FixedClock("2026-06-02T12:00:00+00:00") pg = ProtectedGate(store, clock, judge=ScriptedJudge(opinion), key=KEY) sg = SignoffGate(store, clock) app = create_app( - protected_gate=pg, signoff_gate=sg, trail_verifier=TrailVerifier(KEY, PROTECTED) + repo_path=repo_path, + protected_gate=pg, + signoff_gate=sg, + trail_verifier=TrailVerifier(KEY, PROTECTED), ) return TestClient(app), store @@ -49,7 +57,40 @@ def test_protected_post_records_and_verified_read_succeeds(tmp_path): trail = c.get("/overrides") assert trail.status_code == 200 sig = trail.json()[0]["extensions"]["judge_metadata_signature"] - assert sig.startswith("hmac-sha256:v1:") + assert sig.startswith("hmac-sha256:v2:") + + +def test_protected_post_rejects_stale_source_fingerprint_before_signing(tmp_path): + source = tmp_path / "src" / "x.py" + source.parent.mkdir() + source.write_text("def f():\n return 1\n") + c, store = _app(tmp_path, repo_path=tmp_path) + + resp = c.post( + "/protected/overrides", + json={**PBODY, "file_fingerprint": "sha256:" + "0" * 64}, + ) + + assert resp.status_code == 422 + assert "fingerprint does not match current source" in resp.json()["detail"] + assert store.read_all() == [] + + +def test_protected_post_records_verified_source_binding(tmp_path): + source = tmp_path / "src" / "x.py" + source.parent.mkdir() + source.write_text("def f():\n return 1\n") + c, store = _app(tmp_path, repo_path=tmp_path) + + resp = c.post( + "/protected/overrides", + json={**PBODY, "file_fingerprint": _fingerprint(source)}, + ) + + assert resp.status_code == 201 + ext = store.read_all()[0].payload["extensions"] + assert ext["source_binding"]["status"] == "verified" + assert ext["source_binding"]["source_path"] == "src/x.py" def test_protected_blocked_post_is_409(tmp_path): @@ -66,6 +107,21 @@ def test_operator_override_post_is_201_and_distinct(tmp_path): assert resp.json()["verdict"] == "OVERRIDDEN_BY_OPERATOR" +def test_authenticated_token_actor_overrides_body_operator_id(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_API_TOKEN_ACTORS", "op-a=token-a") + c, _ = _app(tmp_path, JudgeOpinion(Verdict.BLOCKED, "judge@1", "no")) + body = {**PBODY, "operator_id": "spoofed-op"} + del body["agent_id"] + resp = c.post( + "/protected/operator-override", + json=body, + headers={"Authorization": "Bearer token-a"}, + ) + assert resp.status_code == 201 + trail = c.get("/overrides").json() + assert trail[0]["agent_id"] == "op-a" + + def test_signoff_request_then_sign_clears(tmp_path): c, _ = _app(tmp_path) req = c.post( diff --git a/tests/api/test_git_api.py b/tests/api/test_git_api.py index 58215eb..4933c6c 100644 --- a/tests/api/test_git_api.py +++ b/tests/api/test_git_api.py @@ -1,6 +1,7 @@ from fastapi.testclient import TestClient from legis.api.app import create_app +from legis.git.surface import GitError, GitSurface def client(git_repo): @@ -39,3 +40,20 @@ def test_git_renames_endpoint(git_repo): def test_git_commit_unknown_sha_returns_404(git_repo): resp = client(git_repo).get(f"/git/commits/{'0' * 40}") assert resp.status_code == 404 + + +def test_git_renames_invalid_range_returns_4xx(git_repo): + resp = client(git_repo).get("/git/renames", params={"rev_range": "--version"}) + assert resp.status_code in (400, 422) + assert "invalid" in resp.json()["detail"].lower() + + +def test_git_branches_errors_are_mapped_to_4xx(git_repo, monkeypatch): + def fail(self): + raise GitError("bad repo") + + monkeypatch.setattr(GitSurface, "branches", fail) + c = TestClient(create_app(repo_path=git_repo), raise_server_exceptions=False) + resp = c.get("/git/branches") + assert resp.status_code == 400 + assert "bad repo" in resp.json()["detail"] diff --git a/tests/api/test_health.py b/tests/api/test_health.py index a315267..3027b72 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -10,4 +10,4 @@ def test_health_returns_ok(): body = resp.json() assert body["status"] == "ok" assert body["service"] == "legis" - assert body["version"] == "0.1.0" + assert body["version"] == "1.0.0rc2" diff --git a/tests/api/test_override_api.py b/tests/api/test_override_api.py index 84750e8..5e12110 100644 --- a/tests/api/test_override_api.py +++ b/tests/api/test_override_api.py @@ -51,6 +51,19 @@ def test_chill_post_override_returns_201_and_records(tmp_path): assert trail[0]["identity_stable"] is False +def test_authenticated_token_actor_overrides_body_agent_id(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_API_TOKEN_ACTORS", "agent-a=token-a") + c = chill_client(tmp_path) + resp = c.post( + "/overrides", + json={**BODY, "agent_id": "spoofed-agent"}, + headers={"Authorization": "Bearer token-a"}, + ) + assert resp.status_code == 201 + trail = c.get("/overrides").json() + assert trail[0]["agent_id"] == "agent-a" + + def test_coached_blocked_post_returns_409_with_judge_reasoning(tmp_path): c = coached_client( tmp_path, JudgeOpinion(Verdict.BLOCKED, "judge@1", "rationale is boilerplate") diff --git a/tests/api/test_sei_api.py b/tests/api/test_sei_api.py index fe95e2e..68732b5 100644 --- a/tests/api/test_sei_api.py +++ b/tests/api/test_sei_api.py @@ -31,6 +31,11 @@ def lineage(self, sei): return self._lineage +class BrokenLineageClient(FakeClient): + def lineage(self, sei): + raise RuntimeError("clarion down") + + class ScriptedJudge: def __init__(self, opinion): self.opinion = opinion @@ -145,7 +150,25 @@ def test_lineage_integrity_endpoint_reports_clean_when_appended(tmp_path): c.post("/overrides", json={"policy": "no-eval", "entity": "python:function:m.f", "rationale": "reviewed", "agent_id": "agent-1"}) # FakeClient.lineage still returns the same [born]; snapshot matches → clean. - assert c.get("/governance/lineage-integrity").json() == {"divergences": []} + assert c.get("/governance/lineage-integrity").json() == { + "status": "verified", + "divergences": [], + "unavailable": [], + } + + +def test_lineage_integrity_endpoint_reports_unavailable_not_clean(tmp_path): + alive = {"sei": "clarion:eid:abc123", "current_locator": "python:function:m.f", + "content_hash": "h", "alive": True} + c = _app(tmp_path, BrokenLineageClient(alive, lineage=[{"event": "born"}])) + c.post("/overrides", json={"policy": "no-eval", "entity": "python:function:m.f", + "rationale": "reviewed", "agent_id": "agent-1"}) + body = c.get("/governance/lineage-integrity").json() + assert body["status"] == "unverified" + assert body["divergences"] == [] + assert body["unavailable"] == [ + {"sei": "clarion:eid:abc123", "reason": "unavailable"} + ] def test_protected_and_signoff_paths_carry_clarion_block(tmp_path): diff --git a/tests/conftest.py b/tests/conftest.py index aa6a080..3c701e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,12 @@ } +@pytest.fixture(autouse=True) +def _unsafe_dev_auth_for_local_tests(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LEGIS_UNSAFE_DEV_AUTH", "1") + monkeypatch.setenv("LEGIS_UNSAFE_WARDLINE_REQUEST_ROUTING", "1") + + def _git(repo: Path, *args: str) -> str: import os diff --git a/tests/contract/test_git_renames_contract.py b/tests/contract/test_git_renames_contract.py index 901f8a0..48f0c10 100644 --- a/tests/contract/test_git_renames_contract.py +++ b/tests/contract/test_git_renames_contract.py @@ -50,3 +50,64 @@ def test_git_renames_endpoint_matches_clarion_parser(tmp_path): assert isinstance(items, list) # Clarion requires an array pairs = _parse_like_clarion(items) assert ("auth.py", "authn.py") in pairs # the rename survives the contract + + +def _parse_git_diff_lines(stdout: str) -> list[tuple[str, str]]: + out = [] + for line in stdout.splitlines(): + cols = line.split("\t") + if len(cols) >= 3 and cols[0].startswith("R"): + old, new = cols[1], cols[2] + if old and new: + out.append((old, new)) + return out + + +def test_git_renames_union_integration(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "t@t") + _git(repo, "config", "user.name", "t") + + # Initial files in base commit + (repo / "auth.py").write_text("def login():\n return 1\n" * 5) + (repo / "extra.py").write_text("def helper():\n return 2\n" * 5) + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "base") + base = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, text=True).stdout.strip() + + # 1. Committed rename: auth.py -> authn.py + (repo / "authn.py").write_text((repo / "auth.py").read_text()) + (repo / "auth.py").unlink() + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "rename auth -> authn") + + # 2. Uncommitted working-tree rename: extra.py -> extras.py + _git(repo, "mv", "extra.py", "extras.py") + + # Query Legis for the committed window + c = TestClient(create_app(repo_path=str(repo))) + resp = c.get("/git/renames", params={"rev_range": f"{base}..HEAD"}) + assert resp.status_code == 200 + committed_renames = _parse_like_clarion(resp.json()) + + # Query local git for the uncommitted working-tree window + git_diff_out = subprocess.run( + ["git", "-C", str(repo), "diff", "--name-status", "-M", "HEAD"], + capture_output=True, text=True, check=True + ).stdout + working_tree_renames = _parse_git_diff_lines(git_diff_out) + + # Perform the union (exactly mimicking Clarion's gather_git_renames) + union_renames = [] + for rename in committed_renames + working_tree_renames: + if rename not in union_renames: + union_renames.append(rename) + + # Assertions + assert len(union_renames) == 2 + assert ("auth.py", "authn.py") in union_renames + assert ("extra.py", "extras.py") in union_renames + diff --git a/tests/enforcement/test_protected_extensions.py b/tests/enforcement/test_protected_extensions.py index 4d0da3a..652ba66 100644 --- a/tests/enforcement/test_protected_extensions.py +++ b/tests/enforcement/test_protected_extensions.py @@ -48,10 +48,13 @@ def test_clarion_block_does_not_break_the_signature(tmp_path): assert verify(signing_fields(payload), sig, KEY) is True -def test_mutating_clarion_block_does_not_invalidate_the_signature(tmp_path): - # Discriminating regression lock for WP-A1: the clarion block lives OUTSIDE - # the signed field set. Mutating it after signing must NOT break the - # signature — if a refactor pulled clarion into signing_fields, this fails. +import pytest +from legis.enforcement.protected import TamperError + + +def test_mutating_clarion_block_invalidates_the_signature(tmp_path): + # Discriminating regression lock for WP-A1/L-05: the clarion block must be bound + # to the signed field set. Mutating it after signing MUST break the signature. g, store = _gate(tmp_path) g.submit(policy="no-eval", entity_key=EntityKey.from_sei("clarion:eid:abc"), rationale="r", agent_id="a", file_fingerprint="fp", ast_path="ap", @@ -61,9 +64,11 @@ def test_mutating_clarion_block_does_not_invalidate_the_signature(tmp_path): payload["extensions"]["clarion"]["content_hash"] = "TAMPERED" payload["extensions"]["clarion"]["lineage_snapshot"] = {"length": 99, "hash": "x"} sig = payload["extensions"]["judge_metadata_signature"] - assert verify(signing_fields(payload), sig, KEY) is True - # The protected-tier load-time verifier likewise accepts the mutated record. - TrailVerifier(KEY, frozenset({"no-eval"})).verify([record]) + assert verify(signing_fields(payload), sig, KEY) is False + # The protected-tier load-time verifier likewise rejects the mutated record. + with pytest.raises(TamperError): + TrailVerifier(KEY, frozenset({"no-eval"})).verify([record]) + def test_operator_override_carries_clarion_block(tmp_path): diff --git a/tests/enforcement/test_protected_override.py b/tests/enforcement/test_protected_override.py index 65ee364..cc49168 100644 --- a/tests/enforcement/test_protected_override.py +++ b/tests/enforcement/test_protected_override.py @@ -39,5 +39,5 @@ def test_operator_override_is_distinct_signed_and_accepted(tmp_path): payload = store.read_all()[0].payload ext = payload["extensions"] assert ext["judge_verdict"] == "OVERRIDDEN_BY_OPERATOR" # distinct from ACCEPTED - assert ext["judge_metadata_signature"].startswith("hmac-sha256:v1:") + assert ext["judge_metadata_signature"].startswith("hmac-sha256:v2:") assert payload["agent_id"] == "op-sec-lead" diff --git a/tests/enforcement/test_protected_submit.py b/tests/enforcement/test_protected_submit.py index 595c517..49477fc 100644 --- a/tests/enforcement/test_protected_submit.py +++ b/tests/enforcement/test_protected_submit.py @@ -14,6 +14,16 @@ def evaluate(self, record): return self.opinion +class CapturingJudge: + def __init__(self, opinion): + self.opinion = opinion + self.seen = None + + def evaluate(self, record): + self.seen = record + return self.opinion + + KEY = b"protected-key-1" @@ -46,10 +56,11 @@ def test_accepted_record_is_bound_and_signed(tmp_path): assert result.verdict is Verdict.ACCEPTED ext = store.read_all()[0].payload["extensions"] + assert ext["protected_cell"] is True assert ext["judge_verdict"] == "ACCEPTED" assert ext["file_fingerprint"] == "sha256:abc" assert ext["ast_path"] == "Module/FunctionDef[f]/Call[eval]" - assert ext["judge_metadata_signature"].startswith("hmac-sha256:v1:") + assert ext["judge_metadata_signature"].startswith("hmac-sha256:v2:") def test_signature_covers_entity_and_policy(tmp_path): @@ -62,6 +73,8 @@ def test_signature_covers_entity_and_policy(tmp_path): # Transplanting the verdict to a different entity must invalidate the sig. moved = {**fields, "entity": {"value": "src/other.py:g", "identity_stable": False}} assert verify(moved, sig, KEY) is False + downgraded = {**fields, "protected_cell": False} + assert verify(downgraded, sig, KEY) is False def test_key_is_never_written_to_the_payload(tmp_path): @@ -71,3 +84,24 @@ def test_key_is_never_written_to_the_payload(tmp_path): submit(g) raw = json.dumps(store.read_all()[0].payload) assert "protected-key-1" not in raw + + +def test_judge_receives_source_and_clarion_context_that_will_be_signed(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + judge = CapturingJudge(JudgeOpinion(Verdict.ACCEPTED, "judge@1", "ok")) + g = ProtectedGate(store, FixedClock("2026-06-02T12:00:00+00:00"), judge=judge, key=KEY) + + g.submit( + policy="no-eval", + entity_key=EntityKey.from_sei("clarion:eid:abc"), + rationale="r", + agent_id="a", + file_fingerprint="fp", + ast_path="ap", + extensions={"clarion": {"alive": True, "content_hash": "h", "lineage_snapshot": {"length": 1, "hash": "lh"}}}, + ) + + assert judge.seen is not None + assert judge.seen.extensions["file_fingerprint"] == "fp" + assert judge.seen.extensions["ast_path"] == "ap" + assert judge.seen.extensions["clarion"]["content_hash"] == "h" diff --git a/tests/enforcement/test_regressions.py b/tests/enforcement/test_regressions.py new file mode 100644 index 0000000..99ca0e3 --- /dev/null +++ b/tests/enforcement/test_regressions.py @@ -0,0 +1,203 @@ +import pytest +import sqlite3 +from fastapi.testclient import TestClient + +from legis.api.app import create_app +from legis.cli import main +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.signoff import SignoffGate +from legis.git.surface import GitSurface, GitError +from legis.identity.entity_key import EntityKey +from legis.policy.decorator import check_policy_boundary, policy_boundary, fingerprint +from legis.policy.grammar import PolicyGrammar, PolicyResult +from legis.policy.exemptions import ExemptionRegistry, Exemption +from legis.store.audit_store import AuditStore + + +def test_git_surface_double_dash(git_repo): + s = GitSurface(git_repo) + with pytest.raises(GitError): + s.commit("--version") + with pytest.raises(GitError): + s.commits("--version") + with pytest.raises(GitError): + s.merge_base("--version", "main") + with pytest.raises(GitError): + s.renames("--version") + + +def test_signoff_gate_out_of_bounds(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + try: + g = SignoffGate(store, FixedClock("2026-06-02T12:00:00+00:00")) + with pytest.raises(ValueError) as excinfo: + g.sign_off(request_seq=999, operator_id="op", rationale="r") + assert "No pending sign-off request found at sequence 999" in str(excinfo.value) + finally: + store._engine.dispose() + + +def test_api_overrides_protected_policies_403(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", "no-eval,protected-policy") + monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") + app = create_app() + client = TestClient(app) + res = client.post("/overrides", json={ + "policy": "protected-policy", + "entity": "clarion:eid:abc", + "rationale": "bypass", + "agent_id": "agent-1" + }) + assert res.status_code == 403 + assert "protected" in res.json()["detail"] + + +def test_api_admin_auth(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_API_SECRET", "super-secret") + monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{tmp_path / 'gov.db'}") + app = create_app() + client = TestClient(app) + + # 1. operator override unauthenticated + res = client.post("/protected/operator-override", json={ + "policy": "no-eval", + "entity": "clarion:eid:abc", + "rationale": "override", + "operator_id": "op-1", + "file_fingerprint": "fp", + "ast_path": "ap" + }) + assert res.status_code == 401 + + # operator override authenticated + res = client.post("/protected/operator-override", json={ + "policy": "no-eval", + "entity": "clarion:eid:abc", + "rationale": "override", + "operator_id": "op-1", + "file_fingerprint": "fp", + "ast_path": "ap" + }, headers={"Authorization": "Bearer super-secret"}) + assert res.status_code == 201 + + # 2. signoff sign unauthenticated + res = client.post("/signoff/1/sign", json={ + "operator_id": "op-1", + "rationale": "override" + }) + assert res.status_code == 401 + + +def test_api_policy_evaluate_logging(tmp_path, monkeypatch): + monkeypatch.setenv("LEGIS_API_SECRET", "super-secret") + monkeypatch.setenv("LEGIS_HMAC_KEY", "secret-key") + db_path = tmp_path / "gov.db" + monkeypatch.setenv("LEGIS_GOVERNANCE_DB", f"sqlite:///{db_path}") + app = create_app() + client = TestClient(app) + + # Unknown policy evaluation unauthenticated + res = client.post("/policy/evaluate", json={ + "policy": "unknown-policy-here", + "target": {"value": "some-val"} + }) + assert res.status_code == 401 + + store = AuditStore(f"sqlite:///{db_path}") + try: + records = store.read_all() + assert len(records) == 0 + + # Unknown policy evaluation authenticated + res = client.post("/policy/evaluate", json={ + "policy": "unknown-policy-here", + "target": {"value": "some-val"} + }, headers={"Authorization": "Bearer super-secret"}) + assert res.status_code == 200 + + records = store.read_all() + assert len(records) == 1 + assert records[0].payload["policy"] == "unknown-policy-here" + finally: + store._engine.dispose() + + +def test_exemption_unhashable_target_value(): + exemptions = ExemptionRegistry([Exemption("no-eval", "safe", "reason")]) + g = PolicyGrammar(exemptions=exemptions) + + class DummyBoundary: + name = "no-eval" + def evaluate(self, target): + return PolicyResult.VIOLATION, "violation" + + g.register(DummyBoundary()) + + res = g.evaluate("no-eval", {"value": ["unhashable", "list"]}) + assert res.result is PolicyResult.VIOLATION + + +def test_cli_check_override_rate_tampered_db(tmp_path): + db_path = tmp_path / "gov.db" + db_url = f"sqlite:///{db_path}" + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE audit_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + payload TEXT, + content_hash TEXT, + prev_hash TEXT, + chain_hash TEXT + ) + """) + cursor.execute(""" + INSERT INTO audit_log (seq, payload, content_hash, prev_hash, chain_hash) + VALUES (1, '{"policy": "p", "entity_key": {"value": "x"}}', 'hash1', 'prev1', 'tampered-hash') + """) + conn.commit() + cursor.close() + conn.close() + + rc = main(["check-override-rate", "--db", db_url]) + assert rc == 1 + + +class BoundaryClassLocalTest: + def class_method(self): + pass + + +def fake_bound_test(): + # references class_method and no-eval + instance = BoundaryClassLocalTest() + instance.class_method() + assert "no-eval" == "no-eval" + + +def test_honesty_gate_bound_methods(): + def resolver(ref): + return fake_bound_test + + class BoundaryClassLocal: + @policy_boundary( + source="src/legis/x.py:1", + suppresses=("no-eval",), + invariant="class invariant", + test_ref="fake_ref", + test_fingerprint=fingerprint(fake_bound_test), + ) + def class_method(self): + return "ok" + + # Test checking unbound function / method accessed on class + finding = check_policy_boundary(BoundaryClassLocal.class_method, resolver) + assert finding.ok is True, finding.reason + + # Test checking bound method + inst = BoundaryClassLocal() + finding_bound = check_policy_boundary(inst.class_method, resolver) + assert finding_bound.ok is True, finding_bound.reason diff --git a/tests/enforcement/test_signing.py b/tests/enforcement/test_signing.py index 749fe77..afb5514 100644 --- a/tests/enforcement/test_signing.py +++ b/tests/enforcement/test_signing.py @@ -1,4 +1,4 @@ -from legis.enforcement.signing import SIG_PREFIX, sign, verify +from legis.enforcement.signing import SIG_PREFIX, SIG_PREFIX_V1, sign, verify def test_sign_is_prefixed_and_deterministic(): @@ -17,3 +17,10 @@ def test_verify_round_trips_and_rejects_wrong_key_or_tamper(): assert verify({**fields, "policy": "q"}, sig, b"key-1") is False # tampered field assert verify(fields, "not-a-sig", b"key-1") is False # malformed assert verify(fields, "", b"key-1") is False + + +def test_verify_accepts_explicit_legacy_v1_signature(): + fields = {"verdict": "ACCEPTED", "policy": "p"} + sig = sign(fields, b"key-1", version="v1") + assert sig.startswith(SIG_PREFIX_V1) + assert verify(fields, sig, b"key-1") is True diff --git a/tests/enforcement/test_signoff.py b/tests/enforcement/test_signoff.py index d37f176..dece498 100644 --- a/tests/enforcement/test_signoff.py +++ b/tests/enforcement/test_signoff.py @@ -64,4 +64,40 @@ def test_protected_signoff_is_tamper_bound(tmp_path): ) g.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok") ext = store.read_all()[1].payload["extensions"] - assert ext["signoff_signature"].startswith("hmac-sha256:v1:") + assert ext["signoff_signature"].startswith("hmac-sha256:v2:") + + +def test_signoff_index_bounds_validation(tmp_path): + g, _ = gate(tmp_path) + import pytest + + # Out of bounds request_seq + with pytest.raises(ValueError): + g.sign_off(request_seq=0, operator_id="op-1") + with pytest.raises(ValueError): + g.sign_off(request_seq=-1, operator_id="op-1") + with pytest.raises(ValueError): + g.sign_off(request_seq=999, operator_id="op-1") + + # request_record checks + assert g.request_record(0) is None + assert g.request_record(-5) is None + assert g.request_record(999) is None + + +def test_signoff_duplicate_signoff_rejected(tmp_path): + g, _ = gate(tmp_path) + import pytest + + req = g.request( + policy="prod-deploy", + entity_key=EntityKey.from_locator("svc/api"), + rationale="ship", + agent_id="agent-3", + ) + g.sign_off(request_seq=req.seq, operator_id="op-1") + + # Second signoff should be rejected + with pytest.raises(ValueError) as excinfo: + g.sign_off(request_seq=req.seq, operator_id="op-2") + assert "already been signed off" in str(excinfo.value) diff --git a/tests/enforcement/test_trail_verify.py b/tests/enforcement/test_trail_verify.py index 0a9e90c..4752df7 100644 --- a/tests/enforcement/test_trail_verify.py +++ b/tests/enforcement/test_trail_verify.py @@ -3,7 +3,13 @@ from legis.canonical import canonical_json, content_hash from legis.clock import FixedClock -from legis.enforcement.protected import ProtectedGate, TamperError, TrailVerifier +from legis.enforcement.protected import ( + ProtectedGate, + TamperError, + TrailVerifier, + legacy_signing_fields, +) +from legis.enforcement.signing import sign from legis.enforcement.verdict import JudgeOpinion, Verdict from legis.identity.entity_key import EntityKey from legis.store.audit_store import GENESIS, AuditStore, _chain @@ -49,6 +55,19 @@ def test_clean_protected_trail_verifies(tmp_path): TrailVerifier(KEY, PROTECTED).verify(store.read_all()) # no raise +def test_legacy_v1_protected_signature_still_verifies(tmp_path): + g, store = _gate(tmp_path / "gov.db") + _submit(g) + + def replace_with_legacy_signature(p): + p["extensions"]["judge_metadata_signature"] = sign( + legacy_signing_fields(p), KEY, version="v1" + ) + + _edit_payload_and_rechain(tmp_path / "gov.db", replace_with_legacy_signature) + TrailVerifier(KEY, PROTECTED).verify(store.read_all()) # no raise + + def test_missing_signature_on_protected_policy_is_tampering(tmp_path): g, store = _gate(tmp_path / "gov.db") _submit(g) @@ -76,6 +95,89 @@ def test_hmac_catches_a_fully_rechained_edit(tmp_path): pass +def test_hmac_catches_rechained_agent_attribution_edit(tmp_path): + g, store = _gate(tmp_path / "gov.db") + _submit(g) + _edit_payload_and_rechain(tmp_path / "gov.db", lambda p: p.update({"agent_id": "forged-agent"})) + assert store.verify_integrity() is True + try: + TrailVerifier(KEY, PROTECTED).verify(store.read_all()) + raise AssertionError("expected TamperError on forged agent_id") + except TamperError: + pass + + +def test_protected_gate_record_verifies_even_with_empty_protected_policy_set(tmp_path): + g, store = _gate(tmp_path / "gov.db") + _submit(g) + _edit_payload_and_rechain(tmp_path / "gov.db", lambda p: p.update({"agent_id": "forged-agent"})) + assert store.verify_integrity() is True + try: + TrailVerifier(KEY, frozenset()).verify(store.read_all()) + raise AssertionError("expected TamperError on protected gate record despite empty policy config") + except TamperError: + pass + + +def test_hmac_catches_rechained_judge_rationale_edit(tmp_path): + g, store = _gate(tmp_path / "gov.db") + _submit(g) + _edit_payload_and_rechain( + tmp_path / "gov.db", + lambda p: p["extensions"].update({"judge_rationale": "forged rationale"}), + ) + assert store.verify_integrity() is True + try: + TrailVerifier(KEY, PROTECTED).verify(store.read_all()) + raise AssertionError("expected TamperError on forged judge rationale") + except TamperError: + pass + + +def test_missing_entity_key_on_protected_policy_is_tampering(tmp_path): + g, store = _gate(tmp_path / "gov.db") + _submit(g) + _edit_payload_and_rechain( + tmp_path / "gov.db", + lambda p: (p.pop("entity_key", None), p["extensions"].pop("judge_metadata_signature", None)), + ) + assert store.verify_integrity() is True + try: + TrailVerifier(KEY, PROTECTED).verify(store.read_all()) + raise AssertionError("expected TamperError on missing entity_key") + except TamperError: + pass + + +def test_protected_signoff_signature_covers_clarion_metadata(tmp_path): + from legis.enforcement.signoff import SignoffGate + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + gate = SignoffGate( + store, + FixedClock("2026-06-02T12:00:00+00:00"), + signer=True, + key=KEY, + ) + gate.request( + policy="no-eval", + entity_key=EntityKey.from_sei("clarion:eid:x"), + rationale="needs review", + agent_id="agent-1", + extensions={"clarion": {"alive": True, "content_hash": "original", "lineage_snapshot": {"length": 1, "hash": "h"}}}, + ) + _edit_payload_and_rechain( + tmp_path / "gov.db", + lambda p: p["extensions"]["clarion"].update({"content_hash": "forged"}), + ) + assert store.verify_integrity() is True + try: + TrailVerifier(KEY, PROTECTED).verify(store.read_all()) + raise AssertionError("expected TamperError on forged signoff clarion metadata") + except TamperError: + pass + + # --- raw-sqlite tamper helpers (out-of-band edits the store API forbids) --- @@ -101,12 +203,16 @@ def _rechain(con): def _edit_rationale_and_rechain(db, new_rationale): + _edit_payload_and_rechain(db, lambda p: p.update({"rationale": new_rationale})) + + +def _edit_payload_and_rechain(db, mutate): con = _open_unlocked(db) seq, payload = con.execute( "SELECT seq, payload FROM audit_log ORDER BY seq ASC LIMIT 1" ).fetchone() p = json.loads(payload) - p["rationale"] = new_rationale + mutate(p) con.execute("UPDATE audit_log SET payload=? WHERE seq=?", (canonical_json(p), seq)) _rechain(con) con.close() diff --git a/tests/enforcement/test_verdict_parse.py b/tests/enforcement/test_verdict_parse.py index 07070fb..6d5c671 100644 --- a/tests/enforcement/test_verdict_parse.py +++ b/tests/enforcement/test_verdict_parse.py @@ -14,11 +14,19 @@ ("blocked: rationale is boilerplate", Verdict.BLOCKED), # Ambiguity is fail-closed: BLOCKED wins when both tokens appear. ("I would say ACCEPTED but actually BLOCKED", Verdict.BLOCKED), + # Multi-line cases: only the first non-empty line is parsed + ("ACCEPTED\nReasoning: this could have been BLOCKED but isn't", Verdict.ACCEPTED), + ("BLOCKED\nBut we will ACCEPTED next time", Verdict.BLOCKED), + ("This is not clear\nACCEPTED", Verdict.BLOCKED), # Unparseable / unknown is fail-closed. ("", Verdict.BLOCKED), (" ", Verdict.BLOCKED), ("maybe?", Verdict.BLOCKED), ("the model timed out", Verdict.BLOCKED), + ("NOT ACCEPTED", Verdict.BLOCKED), + ("NO ACCEPTED", Verdict.BLOCKED), + ("NEVER ACCEPTED", Verdict.BLOCKED), + ("UNACCEPTED", Verdict.BLOCKED), ], ) def test_parse_verdict_is_fail_closed(raw, expected): diff --git a/tests/filigree/test_client.py b/tests/filigree/test_client.py index eea9dbf..bc94375 100644 --- a/tests/filigree/test_client.py +++ b/tests/filigree/test_client.py @@ -1,3 +1,5 @@ +import pytest + from legis.filigree.client import FiligreeError, HttpFiligreeClient @@ -19,16 +21,49 @@ def test_attach_posts_entity_id_and_hash(): resp = {"issue_id": "ISSUE-1", "clarion_entity_id": "clarion:eid:abc", "content_hash_at_attach": "h", "attached_at": "t", "attached_by": "legis"} fetch = _fake_fetch({("POST", "/api/issue/ISSUE-1/entity-associations"): resp}) - c = HttpFiligreeClient("http://f", fetch=fetch) + c = HttpFiligreeClient("http://localhost", fetch=fetch) out = c.attach("ISSUE-1", "clarion:eid:abc", "h", actor="legis") assert out == resp - assert fetch.calls[-1] == ("POST", "http://f/api/issue/ISSUE-1/entity-associations", + assert fetch.calls[-1] == ("POST", "http://localhost/api/issue/ISSUE-1/entity-associations", {"entity_id": "clarion:eid:abc", "content_hash": "h", "actor": "legis"}) def test_associations_for_entity_url_encodes_colons(): fetch = _fake_fetch({("GET", "/api/entity-associations"): {"associations": []}}) - c = HttpFiligreeClient("http://f", fetch=fetch) + c = HttpFiligreeClient("http://localhost", fetch=fetch) assert c.associations_for_entity("clarion:eid:abc") == [] url = fetch.calls[-1][1] assert "entity_id=clarion%3Aeid%3Aabc" in url # colons percent-encoded + + +def test_attach_escapes_path_traversal_payload(): + resp = {"attached": True} + # Expected URL has quoted/escaped path traversal characters + fetch = _fake_fetch({("POST", "/api/issue/..%2F..%2Fadmin%2Fdelete/entity-associations"): resp}) + c = HttpFiligreeClient("http://localhost", fetch=fetch) + c.attach("../../admin/delete", "clarion:eid:abc", "h", actor="legis") + assert fetch.calls[-1][1] == "http://localhost/api/issue/..%2F..%2Fadmin%2Fdelete/entity-associations" + + +def test_attach_rejects_non_object_response(): + fetch = _fake_fetch({("POST", "/api/issue/ISSUE-1/entity-associations"): []}) + with pytest.raises(FiligreeError): + HttpFiligreeClient("http://localhost", fetch=fetch).attach( + "ISSUE-1", "clarion:eid:abc", "h", actor="legis" + ) + + +def test_associations_rejects_non_object_and_non_list_response(): + fetch = _fake_fetch({("GET", "/api/entity-associations"): []}) + with pytest.raises(FiligreeError): + HttpFiligreeClient("http://localhost", fetch=fetch).associations_for_entity("clarion:eid:abc") + + fetch = _fake_fetch({("GET", "/api/entity-associations"): {"associations": "bad"}}) + with pytest.raises(FiligreeError): + HttpFiligreeClient("http://localhost", fetch=fetch).associations_for_entity("clarion:eid:abc") + + +def test_client_rejects_unsafe_base_urls(): + for url in ("file:///tmp/filigree.json", "http://example.com", "not-a-url"): + with pytest.raises(FiligreeError): + HttpFiligreeClient(url) diff --git a/tests/git/test_git_surface.py b/tests/git/test_git_surface.py index 5991410..1fa02b1 100644 --- a/tests/git/test_git_surface.py +++ b/tests/git/test_git_surface.py @@ -92,3 +92,38 @@ def test_renames_carry_pre_and_post_blob_shas(git_repo): assert (ev.old_path, ev.new_path) == ("a.txt", "renamed.txt") assert len(ev.old_blob) == 40 and len(ev.new_blob) == 40 assert ev.old_blob == ev.new_blob # pure git mv → identical blob + + +def test_git_surface_command_injection_mitigation(git_repo): + s = GitSurface(git_repo) + + # Option injections starting with '-' + with pytest.raises(GitError): + s.commit("-o") + with pytest.raises(GitError): + s.commits("-o") + with pytest.raises(GitError): + s.merge_base("-o", "main") + with pytest.raises(GitError): + s.merge_base("main", "-o") + with pytest.raises(GitError): + s.renames("-o") + + # Invalid patterns + with pytest.raises(GitError): + s.commit("main; rm -rf /") + with pytest.raises(GitError): + s.commits("HEAD&echo") + with pytest.raises(GitError): + s.merge_base("main|sh", "feature") + with pytest.raises(GitError): + s.renames("HEAD~1..HEAD; echo") + + +def test_git_surface_times_out_slow_git_commands(git_repo, monkeypatch): + def slow_run(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], timeout=kwargs.get("timeout")) + + monkeypatch.setattr(subprocess, "run", slow_run) + with pytest.raises(GitError, match="timed out"): + GitSurface(git_repo).branches() diff --git a/tests/governance/test_binding_ledger.py b/tests/governance/test_binding_ledger.py index 26d25a3..6a95170 100644 --- a/tests/governance/test_binding_ledger.py +++ b/tests/governance/test_binding_ledger.py @@ -28,7 +28,7 @@ def test_record_then_get_round_trips_the_binding(tmp_path): assert got["issue_id"] == "ISSUE-1" assert got["entity_key"] == {"value": "clarion:eid:abc", "identity_stable": True} assert got["content_hash"] == "h" - assert got["binding_signature"].startswith("hmac-sha256:v1:") + assert got["binding_signature"].startswith("hmac-sha256:v2:") def test_verify_passes_for_a_legit_record(tmp_path): diff --git a/tests/governance/test_gaps.py b/tests/governance/test_gaps.py index 787bd94..7c6314f 100644 --- a/tests/governance/test_gaps.py +++ b/tests/governance/test_gaps.py @@ -1,6 +1,8 @@ from legis.canonical import content_hash from legis.governance.gaps import ( LineageDivergence, + LineageUnavailable, + find_lineage_integrity, find_lineage_divergence, find_orphan_gaps, ) @@ -33,6 +35,11 @@ def lineage(self, sei): return self._lineages.get(sei, []) +class BrokenLineageClient(FakeClient): + def lineage(self, sei): + raise RuntimeError("clarion down") + + def test_orphaned_sei_surfaces_a_gap(tmp_path): store = _store(tmp_path, _rec("clarion:eid:alive"), _rec("clarion:eid:dead")) client = FakeClient({ @@ -66,3 +73,23 @@ def test_truncated_or_mutated_prefix_is_divergence(tmp_path): tampered = [{"event": "born"}] # the 'moved' event vanished — prefix broken div = find_lineage_divergence(store.read_all(), FakeClient({}, {"clarion:eid:s": tampered})) assert div == [LineageDivergence(sei="clarion:eid:s", recorded_length=2, current_length=1)] + + +def test_lineage_integrity_reports_unavailable_fetches(tmp_path): + born = [{"event": "born"}] + snap = {"length": 1, "hash": content_hash(born)} + store = _store(tmp_path, _rec("clarion:eid:s", snapshot=snap)) + integrity = find_lineage_integrity(store.read_all(), BrokenLineageClient({})) + assert integrity.divergences == [] + assert integrity.unavailable == [ + LineageUnavailable(sei="clarion:eid:s", reason="lineage_fetch_failed") + ] + + +def test_lineage_integrity_reports_missing_snapshot_as_unverified(tmp_path): + store = _store(tmp_path, _rec("clarion:eid:s")) + integrity = find_lineage_integrity(store.read_all(), FakeClient({})) + assert integrity.divergences == [] + assert integrity.unavailable == [ + LineageUnavailable(sei="clarion:eid:s", reason="missing_snapshot") + ] diff --git a/tests/governance/test_signoff_binding.py b/tests/governance/test_signoff_binding.py index 7fa424f..1fb34cb 100644 --- a/tests/governance/test_signoff_binding.py +++ b/tests/governance/test_signoff_binding.py @@ -57,7 +57,7 @@ def test_bind_records_a_signed_binding_when_a_ledger_is_given(tmp_path): recorded = ledger.get(7) assert recorded["issue_id"] == "ISSUE-1" assert recorded["entity_key"]["value"] == "clarion:eid:abc" - assert recorded["binding_signature"].startswith("hmac-sha256:v1:") + assert recorded["binding_signature"].startswith("hmac-sha256:v2:") def test_bind_without_a_ledger_keeps_prior_behaviour(): diff --git a/tests/identity/test_clarion_client.py b/tests/identity/test_clarion_client.py index f223f42..c7c63cc 100644 --- a/tests/identity/test_clarion_client.py +++ b/tests/identity/test_clarion_client.py @@ -1,4 +1,6 @@ -from legis.identity.clarion_client import ClarionError, HttpClarionIdentity +import pytest + +from legis.identity.clarion_client import ClarionError, HttpClarionIdentity, _urllib_fetch def _fake_fetch(responses): @@ -17,29 +19,81 @@ def fetch(method, url, body): def test_capability_true_when_sei_supported(): fetch = _fake_fetch({("GET", "/api/v1/_capabilities"): {"sei": {"supported": True, "version": 1}}}) - assert HttpClarionIdentity("http://c", fetch=fetch).capability() is True + assert HttpClarionIdentity("http://localhost", fetch=fetch).capability() is True def test_capability_false_when_absent_or_unsupported(): fetch = _fake_fetch({("GET", "/api/v1/_capabilities"): {"registry_backend": True}}) - assert HttpClarionIdentity("http://c", fetch=fetch).capability() is False + assert HttpClarionIdentity("http://localhost", fetch=fetch).capability() is False def test_resolve_locator_alive_passthrough(): body = {"sei": "clarion:eid:abc", "current_locator": "python:function:m.f", "content_hash": "h", "alive": True} fetch = _fake_fetch({("POST", "/api/v1/identity/resolve"): body}) - c = HttpClarionIdentity("http://c", fetch=fetch) + c = HttpClarionIdentity("http://localhost", fetch=fetch) assert c.resolve_locator("python:function:m.f") == body - assert fetch.calls[-1] == ("POST", "http://c/api/v1/identity/resolve", {"locator": "python:function:m.f"}) + assert fetch.calls[-1] == ("POST", "http://localhost/api/v1/identity/resolve", {"locator": "python:function:m.f"}) def test_resolve_sei_orphaned_carries_lineage(): body = {"sei": "clarion:eid:abc", "alive": False, "lineage": [{"event": "orphaned"}]} - fetch = _fake_fetch({("GET", "/api/v1/identity/sei/clarion:eid:abc"): body}) - assert HttpClarionIdentity("http://c", fetch=fetch).resolve_sei("clarion:eid:abc") == body + fetch = _fake_fetch({("GET", "/api/v1/identity/sei/clarion%3Aeid%3Aabc"): body}) + assert HttpClarionIdentity("http://localhost", fetch=fetch).resolve_sei("clarion:eid:abc") == body def test_lineage_returns_event_list(): body = {"sei": "clarion:eid:abc", "lineage": [{"event": "born"}, {"event": "locator_changed"}]} - fetch = _fake_fetch({("GET", "/api/v1/identity/lineage/clarion:eid:abc"): body}) - assert HttpClarionIdentity("http://c", fetch=fetch).lineage("clarion:eid:abc") == body["lineage"] + fetch = _fake_fetch({("GET", "/api/v1/identity/lineage/clarion%3Aeid%3Aabc"): body}) + assert HttpClarionIdentity("http://localhost", fetch=fetch).lineage("clarion:eid:abc") == body["lineage"] + + +def test_resolve_sei_escapes_path_traversal_payload(): + body = {"alive": False} + # Expected URL has quoted/escaped path traversal characters + fetch = _fake_fetch({("GET", "/api/v1/identity/sei/..%2F..%2Fadmin%2Fdelete"): body}) + c = HttpClarionIdentity("http://localhost", fetch=fetch) + c.resolve_sei("../../admin/delete") + assert fetch.calls[-1][1] == "http://localhost/api/v1/identity/sei/..%2F..%2Fadmin%2Fdelete" + + +def test_resolve_locator_rejects_non_object_response(): + fetch = _fake_fetch({("POST", "/api/v1/identity/resolve"): []}) + with pytest.raises(ClarionError): + HttpClarionIdentity("http://localhost", fetch=fetch).resolve_locator("python:function:m.f") + + +def test_lineage_rejects_non_object_and_non_list_lineage(): + fetch = _fake_fetch({("GET", "/api/v1/identity/lineage/clarion%3Aeid%3Aabc"): []}) + with pytest.raises(ClarionError): + HttpClarionIdentity("http://localhost", fetch=fetch).lineage("clarion:eid:abc") + + fetch = _fake_fetch({("GET", "/api/v1/identity/lineage/clarion%3Aeid%3Aabc"): {"lineage": "bad"}}) + with pytest.raises(ClarionError): + HttpClarionIdentity("http://localhost", fetch=fetch).lineage("clarion:eid:abc") + + +def test_client_rejects_unsafe_base_urls(): + for url in ("file:///tmp/clarion.json", "http://example.com", "not-a-url"): + with pytest.raises(ClarionError): + HttpClarionIdentity(url) + + +def test_urllib_fetch_rejects_oversized_responses(monkeypatch): + class Response: + headers = {"Content-Type": "application/json"} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size=-1): + return b"{" + (b" " * 1_100_000) + + def fake_urlopen(req, timeout): + return Response() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + with pytest.raises(ClarionError, match="too large"): + _urllib_fetch("GET", "http://localhost/api/v1/_capabilities", None) diff --git a/tests/identity/test_resolver.py b/tests/identity/test_resolver.py index a4d2b1b..5800a10 100644 --- a/tests/identity/test_resolver.py +++ b/tests/identity/test_resolver.py @@ -3,11 +3,12 @@ class FakeClient: - def __init__(self, *, capable=True, resolve=None, lineage=None, boom=False): + def __init__(self, *, capable=True, resolve=None, lineage=None, boom=False, lineage_boom=False): self._capable = capable self._resolve = resolve or {"alive": False} self._lineage = lineage or [] self._boom = boom + self._lineage_boom = lineage_boom def capability(self): if self._boom: @@ -21,6 +22,8 @@ def resolve_sei(self, sei): # not used by the resolver raise AssertionError def lineage(self, sei): + if self._lineage_boom: + raise RuntimeError("lineage down") return self._lineage @@ -38,6 +41,8 @@ def test_alive_sei_is_keyed_opaquely_with_two_axes(): assert res.alive is True # identity axis assert res.content_hash == "blake3hash" # content axis assert res.lineage_snapshot == {"length": 1, "hash": content_hash([{"event": "born"}])} + assert res.identity_resolution_status == "resolved" + assert res.lineage_snapshot_status == "verified" def test_capability_absent_degrades_to_locator(): @@ -64,3 +69,42 @@ def test_transport_error_degrades_never_raises(): r = IdentityResolver(FakeClient(boom=True)) res = r.resolve("python:function:m.f") assert res.entity_key.identity_stable is False + + +def test_transient_capability_error_is_retried(): + class FlakyCapabilityClient(FakeClient): + def __init__(self): + super().__init__(resolve=ALIVE, lineage=[{"event": "born"}]) + self.calls = 0 + + def capability(self): + self.calls += 1 + if self.calls == 1: + raise RuntimeError("clarion temporarily down") + return True + + client = FlakyCapabilityClient() + r = IdentityResolver(client) + assert r.resolve("python:function:m.f").entity_key.identity_stable is False + + res = r.resolve("python:function:m.f") + + assert res.entity_key.value == "clarion:eid:deadbeef" + assert client.calls == 2 + + +def test_alive_response_missing_sei_degrades_instead_of_raw_key_error(): + r = IdentityResolver(FakeClient(resolve={"alive": True, "content_hash": "h"})) + res = r.resolve("python:function:m.f") + assert res.entity_key.identity_stable is False + assert res.alive is None + + +def test_alive_sei_with_lineage_failure_records_unavailable_status(): + r = IdentityResolver(FakeClient(resolve=ALIVE, lineage_boom=True)) + res = r.resolve("python:function:m.f") + assert res.entity_key.value == "clarion:eid:deadbeef" + assert res.alive is True + assert res.lineage_snapshot is None + assert res.identity_resolution_status == "resolved" + assert res.lineage_snapshot_status == "unavailable" diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 0000000..d8834e5 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,279 @@ +import io +import json +import hashlib + +from legis.cli import build_parser +from legis.clock import FixedClock +from legis.enforcement.engine import EnforcementEngine +from legis.enforcement.protected import ProtectedGate, TrailVerifier +from legis.enforcement.verdict import JudgeOpinion, Verdict +from legis.store.audit_store import AuditStore + + +class ScriptedJudge: + def __init__(self, opinion): + self.opinion = opinion + + def evaluate(self, record): + return self.opinion + + +def _messages(*items): + return "\n".join(json.dumps(item) for item in items) + "\n" + + +def _run(messages, runtime): + from legis.mcp import run_jsonrpc + + inp = io.StringIO(messages) + out = io.StringIO() + run_jsonrpc(inp, out, runtime) + return [json.loads(line) for line in out.getvalue().splitlines()] + + +def _runtime(tmp_path, *, agent_id="agent-launch"): + from legis.mcp import McpRuntime + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + engine = EnforcementEngine(store, FixedClock("2026-06-02T12:00:00+00:00")) + return McpRuntime(agent_id=agent_id, engine=engine), store + + +def _fingerprint(path): + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def _protected_runtime(tmp_path, *, agent_id="agent-launch", source_root=None): + from legis.mcp import McpRuntime + + store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}") + clock = FixedClock("2026-06-02T12:00:00+00:00") + protected_gate = ProtectedGate( + store, + clock, + judge=ScriptedJudge(JudgeOpinion(Verdict.ACCEPTED, "judge@1", "ok")), + key=b"k", + ) + return ( + McpRuntime( + agent_id=agent_id, + protected_gate=protected_gate, + trail_verifier=TrailVerifier(b"k", frozenset({"no-eval"})), + source_root=source_root, + ), + store, + ) + + +def test_cli_has_mcp_subcommand_with_launch_bound_agent_id(): + args = build_parser().parse_args(["mcp", "--agent-id", "agent-1"]) + assert args.command == "mcp" + assert args.agent_id == "agent-1" + + +def test_initialize_and_tools_list_hide_actor_identity_arguments(tmp_path): + runtime, _store = _runtime(tmp_path) + responses = _run( + _messages( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ), + runtime, + ) + assert responses[0]["result"]["serverInfo"]["name"] == "legis" + tools = responses[1]["result"]["tools"] + by_name = {tool["name"]: tool for tool in tools} + assert { + "submit_override", + "protected_override", + "policy_evaluate", + "wardline_scan_results", + "list_overrides", + } <= set(by_name) + assert "protected_operator_override" not in by_name + for tool in tools: + props = tool["inputSchema"].get("properties", {}) + assert "agent_id" not in props + assert "operator_id" not in props + wardline_props = by_name["wardline_scan_results"]["inputSchema"]["properties"] + assert "cell" not in wardline_props + assert "cell_by_severity" not in wardline_props + + +def test_submit_override_tool_records_launch_agent_not_tool_arguments(tmp_path): + runtime, store = _runtime(tmp_path, agent_id="agent-launch") + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "submit_override", + "arguments": { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "reviewed", + "agent_id": "spoofed-agent", + }, + }, + } + ), + runtime, + ) + assert responses[0]["result"]["structuredContent"]["accepted"] is True + assert store.read_all()[0].payload["agent_id"] == "agent-launch" + + +def test_disabled_protected_cell_maps_to_mcp_tool_error(tmp_path): + runtime, _store = _runtime(tmp_path) + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "protected_override", + "arguments": { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "reviewed", + "file_fingerprint": "sha256:abc", + "ast_path": "Module/FunctionDef[f]", + }, + }, + } + ), + runtime, + ) + result = responses[0]["result"] + assert result["isError"] is True + assert result["structuredContent"]["error_code"] == "NOT_ENABLED" + + +def test_protected_tool_rejects_stale_source_fingerprint_before_signing(tmp_path): + source = tmp_path / "src" / "x.py" + source.parent.mkdir() + source.write_text("def f():\n return 1\n") + runtime, store = _protected_runtime(tmp_path, source_root=tmp_path) + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "protected_override", + "arguments": { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "reviewed", + "file_fingerprint": "sha256:" + "0" * 64, + "ast_path": "Module/FunctionDef[f]", + }, + }, + } + ), + runtime, + ) + + result = responses[0]["result"] + assert result["isError"] is True + assert result["structuredContent"]["error_code"] == "INVALID_ARGUMENT" + assert "fingerprint does not match current source" in result["structuredContent"]["message"] + assert store.read_all() == [] + + +def test_protected_tool_records_verified_source_binding(tmp_path): + source = tmp_path / "src" / "x.py" + source.parent.mkdir() + source.write_text("def f():\n return 1\n") + runtime, store = _protected_runtime(tmp_path, source_root=tmp_path) + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "protected_override", + "arguments": { + "policy": "no-eval", + "entity": "src/x.py:f", + "rationale": "reviewed", + "file_fingerprint": _fingerprint(source), + "ast_path": "Module/FunctionDef[f]", + }, + }, + } + ), + runtime, + ) + + result = responses[0]["result"]["structuredContent"] + assert result["accepted"] is True + assert store.read_all()[0].payload["extensions"]["source_binding"]["status"] == "verified" + + +def test_wardline_tool_uses_server_owned_routing_and_launch_agent(tmp_path): + runtime, store = _runtime(tmp_path, agent_id="agent-launch") + runtime.wardline_cell = "surface_only" + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "wardline_scan_results", + "arguments": { + "scan": { + "findings": [ + { + "rule_id": "PY-WL-101", + "message": "m", + "severity": "INFO", + "kind": "defect", + "fingerprint": "fp1", + "qualname": "m.f", + "properties": {}, + "suppressed": "active", + } + ] + }, + "cell": "surface_override", + "agent_id": "spoofed-agent", + }, + }, + } + ), + runtime, + ) + result = responses[0]["result"]["structuredContent"] + assert result["routed"][0]["mode"] == "surface_only" + payload = store.read_all()[0].payload + assert payload["agent_id"] == "agent-launch" + assert payload["extensions"]["wardline"]["scan_digest"].startswith("sha256:") + + +def test_wardline_tool_invalid_server_cell_maps_to_mcp_error(tmp_path): + runtime, _store = _runtime(tmp_path) + runtime.wardline_cell = "not-a-cell" + responses = _run( + _messages( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "wardline_scan_results", + "arguments": {"scan": {"findings": []}}, + }, + } + ), + runtime, + ) + result = responses[0]["result"] + assert result["isError"] is True + assert result["structuredContent"]["error_code"] == "INVALID_ARGUMENT" diff --git a/tests/policy/test_honesty_gate.py b/tests/policy/test_honesty_gate.py index c1740a2..984b4e4 100644 --- a/tests/policy/test_honesty_gate.py +++ b/tests/policy/test_honesty_gate.py @@ -7,7 +7,12 @@ # A real, resolvable "test" function the gate will fingerprint. def fake_boundary_test(): - # references the decorated function name 'handler' and the policy 'no-eval' + assert handler("payload") == "payload" + assert "no-eval" == "no-eval" + + +def string_only_boundary_test(): + # mentions the decorated function name and policy without exercising either handler_under_test = "handler exercises no-eval boundary" assert "no-eval" in handler_under_test @@ -36,6 +41,16 @@ def test_gate_passes_with_a_pinned_unmodified_test(): assert finding.ok is True, finding.reason +def test_gate_rejects_string_only_mentions_as_behavioural_evidence(): + def string_resolver(ref): + return {"tests::fake": string_only_boundary_test}.get(ref) + + stale_proof = fingerprint(string_only_boundary_test) + finding = check_policy_boundary(_decorate(stale_proof), string_resolver) + assert finding.ok is False + assert "exercise" in finding.reason + + def test_gate_fails_on_fingerprint_drift(): # THE discriminating test: a stale fingerprint means the test changed after # review — behavioural evidence no longer pinned. diff --git a/tests/service/test_governance.py b/tests/service/test_governance.py index ef75746..3368eee 100644 --- a/tests/service/test_governance.py +++ b/tests/service/test_governance.py @@ -44,6 +44,8 @@ def test_identity_resolution_carries_clarion_extension_when_alive_known(): "alive": True, "content_hash": "abc", "lineage_snapshot": ["e1"], + "identity_resolution_status": "resolved", + "lineage_snapshot_status": "verified", } @@ -58,6 +60,8 @@ def test_alive_false_records_clarion_extension_with_alive_false(): "alive": False, "content_hash": None, "lineage_snapshot": None, + "identity_resolution_status": "not_alive", + "lineage_snapshot_status": "not_applicable", } @@ -79,6 +83,11 @@ def records(self): return self._records +class _IntegrityFailGate(_FakeProtectedGate): + def verify_integrity(self): + return False + + class _OkVerifier: def verify(self, records): return None @@ -115,6 +124,12 @@ def test_verified_records_raises_audit_integrity_error_on_tamper(): assert isinstance(exc_info.value.__cause__, TamperError) +def test_verified_records_uses_public_gate_integrity_hook(): + gate = _IntegrityFailGate(["bad"]) + with pytest.raises(AuditIntegrityError, match="hash chain"): + verified_records(gate, None, _boom) + + def test_compute_override_rate_returns_status_rate_sample_below_min_sample(): # An empty trail is below min-sample → the gate is not FAIL; rate is 0. res = compute_override_rate([]) diff --git a/tests/store/test_audit_store.py b/tests/store/test_audit_store.py index 4e17aca..86dce1d 100644 --- a/tests/store/test_audit_store.py +++ b/tests/store/test_audit_store.py @@ -85,3 +85,27 @@ def test_verify_integrity_detects_out_of_band_tamper(tmp_path): finally: conn.close() assert s.verify_integrity() is False + + +def test_audit_store_concurrent_writes(tmp_path): + import threading + s = make_store(tmp_path) + + errors = [] + def run_appends(tid, count): + try: + for i in range(count): + s.append({"thread": tid, "idx": i}) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=run_appends, args=(t, 20)) for t in range(5)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert not errors, f"Concurrent appends failed with errors: {errors}" + recs = s.read_all() + assert len(recs) == 100 + assert s.verify_integrity() is True diff --git a/tests/test_cli.py b/tests/test_cli.py index aa9b1df..6edd019 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,6 +26,14 @@ def fake_run(app, **kw): {"host": "0.0.0.0", "port": 9001, "factory": True})] +def test_serve_rejects_hmac_key_argument(): + import pytest + + with pytest.raises(SystemExit) as excinfo: + build_parser().parse_args(["serve", "--hmac-key", "secret"]) + assert excinfo.value.code == 2 + + def test_main_no_command_returns_2(): rc = main([], run=lambda *a, **k: None) assert rc == 2 @@ -62,3 +70,11 @@ def test_check_override_rate_exits_0_when_clean(tmp_path, capsys): "extensions": {"judge_verdict": Verdict.ACCEPTED.value}}) assert main(["check-override-rate", "--db", db]) == 0 assert "PASS" in capsys.readouterr().out + + +def test_check_override_rate_fails_for_missing_sqlite_db_without_creating_it(tmp_path, capsys): + db_path = tmp_path / "missing.db" + rc = main(["check-override-rate", "--db", f"sqlite:///{db_path}"]) + assert rc == 1 + assert not db_path.exists() + assert "missing" in capsys.readouterr().err.lower() diff --git a/tests/wardline/test_governor.py b/tests/wardline/test_governor.py index 72001e3..8f56160 100644 --- a/tests/wardline/test_governor.py +++ b/tests/wardline/test_governor.py @@ -5,7 +5,7 @@ from legis.identity.entity_key import EntityKey from legis.store.audit_store import AuditStore from legis.wardline.governor import WardlineCellPolicy, route_findings -from legis.wardline.ingest import WardlineSeverity, active_defects +from legis.wardline.ingest import WardlinePayloadError, WardlineSeverity, active_defects def _scan(): @@ -38,6 +38,24 @@ def test_surface_override_cell_records_an_override(tmp_path): assert "untrusted reaches trusted" in trail[0]["rationale"] +def test_invalid_trust_tier_properties_are_rejected(): + import pytest + + scan = _scan() + scan["findings"][0]["properties"] = {"actual_return": "ROOT"} + with pytest.raises(WardlinePayloadError, match="trust tier"): + active_defects(scan) + + +def test_suppressed_defect_without_proof_is_rejected(): + import pytest + + scan = _scan() + scan["findings"][0]["suppressed"] = "waived" + with pytest.raises(WardlinePayloadError, match="suppression proof"): + active_defects(scan) + + def test_surface_override_captures_clarion_lineage_alongside_wardline(tmp_path): # A SEI-keyed wardline-routed override must carry the REQ-L-01 clarion # lineage snapshot (alive/content_hash/lineage_snapshot) merged ALONGSIDE the @@ -58,6 +76,23 @@ def test_surface_override_captures_clarion_lineage_alongside_wardline(tmp_path): assert ext["wardline"]["fingerprint"] == "fp1" # wardline ext still present +def test_route_records_batch_provenance(tmp_path): + eng = _engine(tmp_path) + provenance = {"scan_digest": "sha256:abc", "finding_count": 3, "active_count": 1} + route_findings( + active_defects(_scan()), + policy=WardlineCellPolicy.SURFACE_OVERRIDE, + agent_id="agent-1", + resolve=lambda q: (EntityKey.from_locator(q or "unknown"), {}), + engine=eng, + batch_provenance=provenance, + ) + wardline = eng.trail()[0]["extensions"]["wardline"] + assert wardline["scan_digest"] == "sha256:abc" + assert wardline["finding_count"] == 3 + assert wardline["active_count"] == 1 + + def test_block_escalate_cell_opens_a_signoff_request(tmp_path): store = AuditStore(f"sqlite:///{tmp_path / 'g.db'}") gate = SignoffGate(store, FixedClock("2026-06-02T12:00:00+00:00")) @@ -82,6 +117,24 @@ def test_block_escalate_cell_opens_a_signoff_request(tmp_path): ) +def test_block_escalate_captures_clarion_and_wardline_metadata(tmp_path): + store = AuditStore(f"sqlite:///{tmp_path / 'g.db'}") + gate = SignoffGate(store, FixedClock("2026-06-02T12:00:00+00:00")) + clarion_ext = {"clarion": {"alive": True, "content_hash": "h", + "lineage_snapshot": {"length": 1, "hash": "z"}}} + results = route_findings( + active_defects(_scan()), + policy=WardlineCellPolicy.BLOCK_ESCALATE, + agent_id="agent-1", + resolve=lambda q: (EntityKey.from_sei("clarion:eid:x"), clarion_ext), + signoff=gate, + ) + record = store.read_all()[results[0]["seq"] - 1].payload + assert record["extensions"]["clarion"] == clarion_ext["clarion"] + assert record["extensions"]["wardline"]["fingerprint"] == "fp1" + assert record["extensions"]["wardline"]["severity"] == "ERROR" + + def test_surface_only_records_a_non_gating_event(tmp_path): eng = _engine(tmp_path) results = route_findings( @@ -135,13 +188,15 @@ def test_cell_map_routes_each_finding_by_severity(tmp_path): assert by_fp == {"c": "block_escalate", "w": "surface_override", "i": "surface_only"} -def test_unmapped_severity_falls_back_to_surface_override(tmp_path): +def test_unmapped_severity_in_cell_map_is_rejected_before_writes(tmp_path): + import pytest eng = _engine(tmp_path) cell_map = {WardlineSeverity.CRITICAL: WardlineCellPolicy.SURFACE_ONLY} - results = route_findings( # _scan() finding is ERROR — not in the map → fallback - active_defects(_scan()), cell_map=cell_map, agent_id="a", - resolve=lambda q: (EntityKey.from_locator(q or "unknown"), {}), engine=eng) - assert results[0]["mode"] == "surface_override" + with pytest.raises(ValueError, match="unmapped severity"): + route_findings( + active_defects(_scan()), cell_map=cell_map, agent_id="a", + resolve=lambda q: (EntityKey.from_locator(q or "unknown"), {}), engine=eng) + assert eng.trail() == [] def test_exactly_one_of_policy_or_cell_map(tmp_path): @@ -186,6 +241,7 @@ def test_pre_loop_guard_prevents_partial_application(tmp_path): cell_map = { WardlineSeverity.WARN: WardlineCellPolicy.SURFACE_OVERRIDE, WardlineSeverity.CRITICAL: WardlineCellPolicy.BLOCK_ESCALATE, + WardlineSeverity.INFO: WardlineCellPolicy.SURFACE_ONLY, } with pytest.raises(ValueError, match="block_escalate cell requires a signoff gate"): route_findings(active_defects(_mixed_scan()), cell_map=cell_map, agent_id="a", diff --git a/tests/wardline/test_ingest.py b/tests/wardline/test_ingest.py index 1090f95..b0bea1c 100644 --- a/tests/wardline/test_ingest.py +++ b/tests/wardline/test_ingest.py @@ -26,7 +26,15 @@ def test_from_wire_carries_trust_properties_verbatim(): def test_active_defects_excludes_suppressed_and_non_defects(): scan = {"findings": [ _finding(fingerprint="a"), # active defect → in - _finding(fingerprint="b", suppressed="waived"), # waived → out + _finding( + fingerprint="b", + suppressed="waived", + properties={ + "actual_return": "UNKNOWN_RAW", + "declared_return": "ASSURED", + "suppression_proof": "ISSUE-1", + }, + ), # proved waiver → out _finding(fingerprint="c", kind="metric", severity="NONE"), # not a defect → out ]} got = [f.fingerprint for f in active_defects(scan)] diff --git a/uv.lock b/uv.lock index 4649b74..3d88ecd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "annotated-doc" @@ -33,6 +37,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -227,7 +271,7 @@ wheels = [ [[package]] name = "legis" -version = "0.1.0" +version = "1.0.0rc2" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -238,6 +282,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx" }, + { name = "mypy" }, { name = "pytest" }, ] @@ -251,9 +296,123 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", specifier = ">=1.19" }, { name = "pytest", specifier = ">=8.0" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -263,6 +422,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0"