diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e2fb0..2ddd4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,42 @@ versions per [PEP 440](https://peps.python.org/pep-0440/) / _Post-1.0.0 work lands here; legis versions independently from the Weft 1.0 launch on._ +## [1.1.0] — 2026-06-19 + +Three defects surfaced by a `lacuna` dogfooding pass, confirmed (investigation + +adversarial verification) against the shipped 1.0.0 surface and fixed test-first. + +### ⚠️ BREAKING (for pinned fingerprints) — version-stable policy-boundary fingerprint + +- **`@policy_boundary` `test_fingerprint` is now interpreter-stable (legis-13b4e97bf4).** + The fingerprint canonicalization hashed raw `ast.dump` output, whose text is + Python-version-dependent (3.13 omits default-empty AST fields that 3.12 renders), so a + fingerprint pinned under one interpreter reported a spurious + `POLICY_BOUNDARY_TEST_FINGERPRINT_MISMATCH` under another. `decorator.get_normalized_ast_str` + now serializes via `_stable_ast_repr`, which emits **every** field of every node in a + fixed order — version-stable by construction (pinned by a cross-interpreter 3.12↔3.13 + test). **This changes the fingerprint value for all sources.** Consumers with pinned + `test_fingerprint`s (e.g. lacuna's specimen) must regenerate them once against 1.1.0; + after that they no longer drift across Python minors. + +### Fixed + +- **Posture reads degrade instead of crashing on an unprovisioned ledger (legis-5fd3b257c3).** + Production opens the posture ledger `initialize=False`, so a pre-posture / empty (no + `audit_log` table) DB made `posture_get` and `policy_list` raise + `OperationalError: no such table: audit_log`, surfaced to the agent as a non-recoverable + `INTERNAL_ERROR` leaking the SQL string — the intended fail-closed `structured` degrade + was never delivered. `AuditStore` reads (`get_latest_sequence_and_hash`, `read_all`, + `read_by_seq`) now treat an absent table as an empty store (the same state as a missing + file), so every posture read fails closed to `structured` rather than crashing. +- **`install --posture --insecure-key-in-env` adopts the operator key instead of dropping it + (legis-1844bf8ac9).** `install_posture` unconditionally minted a fresh key and the `env` + custody sink was a no-op, so an operator-supplied `LEGIS_OPERATOR_KEY` was ignored: GENESIS + was stamped with a throwaway key the later `EnvSigner` never held, and every `posture set` + refused with `fingerprint_mismatch` (the floor was a dead read-only affordance). The `env` + backend now adopts and validates `LEGIS_OPERATOR_KEY` (64 hex) as the epoch key, and fails + loud (no dead GENESIS) when it is absent or malformed. + ## [1.0.0] — 2026-06-13 This is the gold release — the legis unit of the coordinated **Weft 1.0** launch. It diff --git a/pyproject.toml b/pyproject.toml index a06c177..4c8796a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "legis" -version = "1.0.0" +version = "1.1.0" description = "Legis — the git/CI + governance layer of the Weft suite" readme = "README.md" license = "MIT" diff --git a/src/legis/__init__.py b/src/legis/__init__.py index f8106ef..f9f75c1 100644 --- a/src/legis/__init__.py +++ b/src/legis/__init__.py @@ -1,3 +1,3 @@ """Legis — the git/CI + governance layer of the Weft suite.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/src/legis/canonical.py b/src/legis/canonical.py index 7816473..8320beb 100644 --- a/src/legis/canonical.py +++ b/src/legis/canonical.py @@ -27,8 +27,10 @@ canonicalization choke point, that upgrade stays a one-file change. The companion Q-L5 fingerprint reconciliation (decorator.py / boundary_scan.py) is independent and is done — -those fingerprints are Python ``ast.dump`` output, not cross-language JSON, so -RFC-8785 does not apply to them. +those fingerprints are a content hash of a version-stable AST serialization +(``decorator._stable_ast_repr``, which superseded the interpreter-fragile +``ast.dump`` per legis-13b4e97bf4), not cross-language JSON, so RFC-8785 does not +apply to them. """ from __future__ import annotations diff --git a/src/legis/install.py b/src/legis/install.py index 32d90d5..fddb5f3 100644 --- a/src/legis/install.py +++ b/src/legis/install.py @@ -1243,9 +1243,12 @@ def install_posture( or a ``KEY_RESET`` tail) -> no mint, no append; return the existing epoch fingerprint. This mirrors ``PostureLedger.genesis``'s own guard so install never mints a throwaway key on a second pass. - 4. else: ``mint_key()`` -> hand to the backend via ``key_sink`` -> compute - the fingerprint -> ``ledger.genesis(key_fingerprint=fp, ...)``. The key - bytes reach ONLY the sink; the ledger stores the fingerprint alone. + 4. else: for the ``env`` backend, ADOPT the operator-supplied key from + ``LEGIS_OPERATOR_KEY`` (validated, fail-loud if absent/malformed) so the + later ``EnvSigner`` matches the epoch; for every other backend + ``mint_key()``. Then hand to the backend via ``key_sink`` -> compute the + fingerprint -> ``ledger.genesis(key_fingerprint=fp, ...)``. The key bytes + reach ONLY the sink; the ledger stores the fingerprint alone. """ from legis.clock import SystemClock from legis.posture import ( @@ -1263,7 +1266,14 @@ def install_posture( if ledger.store.get_latest_sequence_and_hash()[0] != 0: return ledger.current_epoch_fingerprint() - key_hex = mint_key() + # The env escape hatch SIGNS with LEGIS_OPERATOR_KEY (EnvSigner), so GENESIS + # must be stamped with THAT key's fingerprint — minting a throwaway here would + # leave the floor read-only (the env signer could never match the epoch). + # Adopt + validate it; every other backend mints a fresh key into custody. + if backend == "env": + key_hex = _adopt_env_operator_key() + else: + key_hex = mint_key() sink = key_sink if key_sink is not None else _default_key_sink # Hand the key to custody BEFORE writing GENESIS: if custody fails we have # written no fingerprint we cannot later sign against (fail-closed). @@ -1275,6 +1285,34 @@ def install_posture( return fp +def _adopt_env_operator_key() -> str: + """Read + validate the operator key from ``LEGIS_OPERATOR_KEY`` (env backend). + + The ``--insecure-key-in-env`` path must adopt the operator-supplied key as + the epoch key so the later :class:`~legis.posture.EnvSigner` (which reads + ``LEGIS_OPERATOR_KEY`` at sign time) matches the GENESIS epoch. A missing or + malformed key fails LOUD rather than minting a throwaway the signer can never + match — which would leave the floor read-only and ``LEGIS_OPERATOR_KEY`` a + dead affordance (dogfood: legis-1844bf8ac9). + """ + key_hex = os.environ.get("LEGIS_OPERATOR_KEY") + if not key_hex: + raise OperatorKeyCustodyError( + "the env operator-key backend (--insecure-key-in-env) requires the " + "operator key in LEGIS_OPERATOR_KEY; refusing to mint a throwaway key " + "the env signer could never match (the floor would be read-only)" + ) + try: + if len(bytes.fromhex(key_hex)) != 32: + raise ValueError + except ValueError: + raise OperatorKeyCustodyError( + "LEGIS_OPERATOR_KEY must be 64 hex chars (a 32-byte key, e.g. as " + "minted by `legis`); refusing to genesis an unusable operator epoch" + ) from None + return key_hex + + def _default_key_sink(key_hex: str, backend: str) -> None: """Default custody hand-off for the minted operator key. diff --git a/src/legis/policy/decorator.py b/src/legis/policy/decorator.py index 9594a01..06920a1 100644 --- a/src/legis/policy/decorator.py +++ b/src/legis/policy/decorator.py @@ -101,6 +101,31 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return decorator +def _stable_ast_repr(node: Any) -> str: + """A Python-version-stable serialization of an AST node. + + ``ast.dump`` is NOT version-stable: its 3.13 default (``show_empty=False``) + omits default-empty fields that 3.12 renders, so the same source hashes + differently across interpreters (dogfood: legis-13b4e97bf4). This walks + ``node._fields`` explicitly and emits EVERY field in its declared order, so an + empty ``arguments`` node renders identically on every interpreter. Node + attributes (``lineno`` / ``col_offset``) are excluded — matching + ``ast.dump``'s default and keeping the fingerprint about structure, not + source formatting. + """ + import ast + + if isinstance(node, ast.AST): + fields = ", ".join( + f"{name}={_stable_ast_repr(getattr(node, name, None))}" + for name in node._fields + ) + return f"{type(node).__name__}({fields})" + if isinstance(node, list): + return "[" + ", ".join(_stable_ast_repr(item) for item in node) + "]" + return repr(node) + + def get_normalized_ast_str(source: str) -> str: import ast parsed = ast.parse(source) @@ -111,7 +136,9 @@ def get_normalized_ast_str(source: str) -> str: val = node.body[0].value if isinstance(val, ast.Constant) and isinstance(val.value, str): node.body.pop(0) - return ast.dump(parsed) + # NOT ast.dump(parsed): that is interpreter-version-dependent. The custom + # serializer is version-stable so a pinned fingerprint survives a Python bump. + return _stable_ast_repr(parsed) def fingerprint_source(source: str) -> str: diff --git a/src/legis/store/audit_store.py b/src/legis/store/audit_store.py index 317fd28..b2a02d1 100644 --- a/src/legis/store/audit_store.py +++ b/src/legis/store/audit_store.py @@ -31,6 +31,7 @@ Text, create_engine, insert, + inspect, select, text, ) @@ -310,9 +311,22 @@ def append_signed(self, build_payload: BuildSignedPayload) -> int: conn.execute(text("BEGIN IMMEDIATE")) return self._insert_signed(conn, build_payload) + def _has_log_table(self, conn) -> bool: + """True iff the ``audit_log`` table exists on this connection. + + An ``initialize=False`` handle opened against an unprovisioned DB (a + missing or empty/no-table file) has no table; reads then treat the store + as empty rather than raising ``OperationalError("no such table")`` + (fail-closed: callers map an empty store to their safe default). DDL only + ever runs under ``initialize=True``, so a read can never create it. + """ + return inspect(conn).has_table("audit_log") + def read_all(self) -> list[AuditRecord]: self._assert_no_batch_in_progress("read_all") with self._engine.begin() as conn: + if not self._has_log_table(conn): + return [] rows = conn.execute( select(self._log).order_by(self._log.c.seq.asc()) ).all() @@ -330,6 +344,8 @@ def read_all(self) -> list[AuditRecord]: def read_by_seq(self, seq: int) -> AuditRecord | None: self._assert_no_batch_in_progress("read_by_seq") with self._engine.begin() as conn: + if not self._has_log_table(conn): + return None row = conn.execute( select(self._log).where(self._log.c.seq == seq) ).first() @@ -428,6 +444,8 @@ def verify_integrity(self) -> bool: def get_latest_sequence_and_hash(self) -> tuple[int, str]: self._assert_no_batch_in_progress("get_latest_sequence_and_hash") with self._engine.begin() as conn: + if not self._has_log_table(conn): + return 0, GENESIS row = conn.execute( select(self._log.c.seq, self._log.c.chain_hash) .order_by(self._log.c.seq.desc()) diff --git a/tests/install/test_install_posture.py b/tests/install/test_install_posture.py index d2051bc..c423fa4 100644 --- a/tests/install/test_install_posture.py +++ b/tests/install/test_install_posture.py @@ -218,6 +218,71 @@ def test_install_age_file_sink_refuses_without_passphrase(project, monkeypatch): assert not (project / ".weft" / "legis" / "operator.age").exists() +# --------------------------------------------------------------------------- +# env backend ADOPTS the operator key (dogfood: legis-1844bf8ac9) +# --------------------------------------------------------------------------- +# The --insecure-key-in-env path must use the operator-supplied +# LEGIS_OPERATOR_KEY as the epoch key, NOT mint a throwaway. Minting fresh +# stamps GENESIS with a key the later EnvSigner (which reads LEGIS_OPERATOR_KEY) +# does not hold -> set_floor refuses fingerprint_mismatch -> the floor is a dead +# read-only affordance. Adoption makes the env signer's fingerprint == the epoch. + + +def test_install_env_backend_adopts_operator_key(project, monkeypatch): + env_key = "a1" * 32 # a known, valid 64-hex operator key + monkeypatch.setenv("LEGIS_OPERATOR_KEY", env_key) + + fp = install.install_posture(project, backend="env") + + # GENESIS is stamped with the ENV key's fingerprint, not a throwaway minted one. + assert fp == key_fingerprint(env_key) + recs = _records(project) + assert len(recs) == 1 + assert recs[0].payload["kind"] == KIND_GENESIS + assert recs[0].payload["key_fingerprint"] == key_fingerprint(env_key) + + +def test_install_env_backend_floor_writable_with_env_key(project, monkeypatch): + # End-to-end: the epoch the env signer will present must match the GENESIS + # epoch (this is exactly the comparison set_floor makes at step 2). If they + # match, the floor is writable; before the fix they differed every install. + import warnings + + from legis.posture import EnvSigner, PostureLedger + + env_key = "b2" * 32 + monkeypatch.setenv("LEGIS_OPERATOR_KEY", env_key) + install.install_posture(project, backend="env") + + led = PostureLedger(install.posture_db_url_for_install(), initialize=False) + epoch_fp = led.current_epoch_fingerprint() + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # InsecureEnvKeyWarning is expected + signer = EnvSigner(insecure_env=True) + assert signer.fingerprint() == epoch_fp # no fingerprint_mismatch refusal + + +def test_install_env_backend_refuses_without_key(project, monkeypatch): + # No LEGIS_OPERATOR_KEY -> fail loud, write no dead GENESIS (fail-closed). + monkeypatch.delenv("LEGIS_OPERATOR_KEY", raising=False) + with pytest.raises(install.OperatorKeyCustodyError): + install.install_posture(project, backend="env") + db = project / ".weft" / "legis" / "legis-posture.db" + if db.exists(): + assert _records(project) == [] + + +def test_install_env_backend_refuses_malformed_key(project, monkeypatch): + # A non-hex / wrong-length LEGIS_OPERATOR_KEY must not become a dead epoch. + monkeypatch.setenv("LEGIS_OPERATOR_KEY", "not-a-valid-hex-key") + with pytest.raises(install.OperatorKeyCustodyError): + install.install_posture(project, backend="env") + db = project / ".weft" / "legis" / "legis-posture.db" + if db.exists(): + assert _records(project) == [] + + def test_install_default_backend_selection(project, monkeypatch): # keychain available -> keychain monkeypatch.setattr(install, "_keychain_available", lambda: True) diff --git a/tests/policy/test_fingerprint_stability.py b/tests/policy/test_fingerprint_stability.py new file mode 100644 index 0000000..dcab517 --- /dev/null +++ b/tests/policy/test_fingerprint_stability.py @@ -0,0 +1,65 @@ +"""The policy-boundary fingerprint must be stable across Python interpreters. + +dogfood: legis-13b4e97bf4. The fingerprint canonicalization used to be a raw +``ast.dump`` hash, whose text is Python-version-dependent: 3.13 omits +default-empty AST fields (an empty ``arguments`` node) where 3.12 lists +``posonlyargs`` / ``args`` / ``kwonlyargs`` etc. A fingerprint pinned under one +interpreter then reports a spurious mismatch under another. The fix replaces the +``ast.dump`` text with a serializer that emits EVERY field of every node in a +fixed order, so empty fields cannot be silently dropped — version-stable by +construction. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from legis.policy.decorator import fingerprint_source, get_normalized_ast_str + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SRC = _REPO_ROOT / "src" + +# A snippet whose AST has an ``arguments`` node with several empty subfields — +# exactly the shape that differs between ``ast.dump`` on 3.12 vs 3.13. +_SNIPPET = "def t():\n assert True\n" + + +def test_normalized_ast_str_emits_empty_fields(): + # The version-stability invariant: empty argument-list fields are emitted + # explicitly, never dropped (raw ast.dump on 3.13 omits these). This is the + # property that neutralizes the interpreter difference. + dumped = get_normalized_ast_str(_SNIPPET) + for field in ("posonlyargs=[]", "args=[]", "kwonlyargs=[]", "defaults=[]"): + assert field in dumped, f"{field!r} missing from {dumped!r}" + + +@pytest.mark.skipif( + shutil.which("python3.12") is None, reason="needs python3.12 for cross-interp check" +) +def test_fingerprint_identical_across_python_minor(): + # Decisive end-to-end proof: the repo's OWN fingerprint over one fixed source + # must be byte-identical under the in-process interpreter and under + # python3.12. Before the fix these differed; after it they match. + here_fp = fingerprint_source(_SNIPPET) + + prog = ( + "import sys\n" + f"sys.path.insert(0, {str(_SRC)!r})\n" + "from legis.policy.decorator import fingerprint_source\n" + f"sys.stdout.write(fingerprint_source({_SNIPPET!r}))\n" + ) + other = subprocess.run( + ["python3.12", "-c", prog], + capture_output=True, + text=True, + ) + assert other.returncode == 0, other.stderr + assert other.stdout == here_fp, ( + f"fingerprint drifted across interpreters: " + f"{sys.version_info[:2]} -> {here_fp}, 3.12 -> {other.stdout}" + ) diff --git a/tests/posture/test_ledger_edges.py b/tests/posture/test_ledger_edges.py index 460f551..064ca30 100644 --- a/tests/posture/test_ledger_edges.py +++ b/tests/posture/test_ledger_edges.py @@ -43,6 +43,45 @@ def test_read_floor_empty_initialized_store_is_none(tmp_path): assert ledger.read_floor() is None +# -- unprovisioned ledger: a file with no audit_log table --------------------- +# Production reads open the ledger initialize=False (mcp.py: build_runtime), so a +# pre-posture / unprovisioned DB has NO audit_log table. Such a read must degrade +# to the same fail-closed "no ledger" state (None / False) as a missing file, not +# crash with OperationalError("no such table: audit_log"). (dogfood: legis-5fd3b257c3) + + +def test_read_floor_empty_file_no_table_is_none(tmp_path): + # A 0-byte / no-table DB opened initialize=False must read as None, not raise. + db = tmp_path / "empty-posture.db" + db.touch() + ledger = PostureLedger(f"sqlite:///{db}", initialize=False) + assert ledger.read_floor() is None + + +def test_epoch_reset_unacknowledged_no_table_is_false(tmp_path): + # epoch_reset_unacknowledged() reads via read_all() with no existence guard; + # an unprovisioned (empty-file) ledger must report False, not raise. + db = tmp_path / "empty-posture.db" + db.touch() + ledger = PostureLedger(f"sqlite:///{db}", initialize=False) + assert ledger.epoch_reset_unacknowledged() is False + + +def test_epoch_reset_unacknowledged_missing_file_is_false(tmp_path): + # Missing file (no path.exists guard inside read_all) must also degrade. + url = f"sqlite:////{tmp_path.as_posix().lstrip('/')}/missing.db" + ledger = PostureLedger(url, initialize=False) + assert ledger.epoch_reset_unacknowledged() is False + + +def test_current_epoch_fingerprint_no_table_is_none(tmp_path): + # The change-gate epoch read must also tolerate an unprovisioned ledger. + db = tmp_path / "empty-posture.db" + db.touch() + ledger = PostureLedger(f"sqlite:///{db}", initialize=False) + assert ledger.current_epoch_fingerprint() is None + + def test_session_opened_implemented_in_phase3(tmp_path): # Phase 3.2 supersedes the Phase 1 stub: session_opened now appends a # keyless OPERATOR_SESSION_OPENED record (full coverage in test_session.py). diff --git a/tests/posture/test_posture_get.py b/tests/posture/test_posture_get.py index af448b3..7a50d93 100644 --- a/tests/posture/test_posture_get.py +++ b/tests/posture/test_posture_get.py @@ -149,6 +149,27 @@ def test_posture_get_missing_ledger_structured(tmp_path): assert sc2["effective_cell"] == "structured" +def test_posture_get_unprovisioned_ledger_degrades_not_errors(tmp_path): + # The PRODUCTION wiring opens the ledger initialize=False; an unprovisioned + # (empty / no audit_log table) DB must deliver the fail-closed 'structured' + # degrade, NOT a raw OperationalError mapped to a non-recoverable + # INTERNAL_ERROR leaking the SQL string. (dogfood: legis-5fd3b257c3) + empty_db = tmp_path / "legis-posture.db" + empty_db.touch() # 0-byte file, no audit_log table + ledger = PostureLedger(f"sqlite:///{empty_db}", initialize=False) + runtime = _runtime(tmp_path, ledger=ledger) + + result = _call(runtime, "posture_get", {}) + assert not result.get("isError"), result + sc = result["structuredContent"] + assert sc["floor"] == "structured" + assert sc["epoch_reset_unacknowledged"] is False + + # policy_list shares the floored-registry read path; it too must not crash. + pl = _call(runtime, "policy_list", {}) + assert not pl.get("isError"), pl + + def test_posture_get_indicates_unacknowledged_key_reset(tmp_path): # A KEY_RESET with no follow-on signed transition -> the agent sees the same # pending-operator-action signal doctor surfaces (Quality medium).