MAX_LEASE_TTL_SECONDS (daemon cap+default) and DEFAULT_TTL_SECONDS (lease_promote client) both move to 3600. The 5-minute TTL made gated-by-default sessions unusable (re-promotion mid-task); 1 hour matches a working session. Full test:framework-shell RC=0. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
338 lines
13 KiB
Python
338 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Lease promotion client — the half the enforcement toolkit never shipped.
|
|
|
|
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
|
|
promotion half has no production caller anywhere in the package: as of 0.0.48,
|
|
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
|
|
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
|
|
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
|
|
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
|
|
gate nothing can satisfy.
|
|
|
|
THE PROTOCOL (``daemon.py:578-754``)
|
|
------------------------------------
|
|
1. ``begin_verification`` — broker revokes, mints a challenge, and returns the
|
|
exact ``receipt`` text the MODEL must emit
|
|
2. *the model emits that text verbatim as its ENTIRE latest message*
|
|
3. the runtime adapter ships that message to the daemon-owned observer socket
|
|
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
|
|
5. ``promote_lease`` -> ``VERIFIED``
|
|
|
|
THIS MODULE IMPLEMENTS 1, 4 AND 5 — NEVER 2
|
|
-------------------------------------------
|
|
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
|
|
``hmac.compare_digest`` against the exact minted string — explicitly "not a
|
|
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
|
|
live model that received the challenge in its context and echoed it exactly.
|
|
|
|
``receipt-observer-client.py`` will post ANY string as the latest assistant
|
|
message. A promotion client that posted its own receipt would satisfy the broker
|
|
while proving nothing — a gate-disabler indistinguishable from a working fix
|
|
unless someone looks for it. **This module never posts a receipt.** Emitting it
|
|
belongs to the runtime adapter, where a real model turn happens.
|
|
|
|
The construction binds the exact normative source bytes. ``h_source`` /
|
|
``h_payload`` are derived by the framework's own
|
|
``normative_fragments.build_payload`` rather than reimplemented: the broker
|
|
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
|
|
There must be exactly one implementation.
|
|
|
|
WHAT THE BINDING DOES *NOT* PROVE
|
|
---------------------------------
|
|
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
|
|
**It does not mean that**, and writing it down that way is how the belief spread.
|
|
The broker holds no reference copy of any normative source and never opens one;
|
|
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
|
|
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
|
|
Both sides of that comparison originate here, so it detects corruption in
|
|
transit and nothing else. What the binding actually asserts is "the client
|
|
claims these bytes, self-consistently".
|
|
|
|
Making it mean the stronger thing requires the broker to re-read the on-disk
|
|
sources itself, against a manifest the agent cannot rewrite — i.e. broker code
|
|
attestation under its own uid. Until then, do not cite a VERIFIED lease as
|
|
evidence of law integrity.
|
|
|
|
Usage
|
|
-----
|
|
lease_promote.py --begin # prints the receipt the MODEL must emit
|
|
lease_promote.py --complete <challenge> # after the adapter observed it
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
# Isolated (`python -I`) adapter invocations must still import co-located
|
|
# framework modules; never depend on the caller's PYTHONPATH.
|
|
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
if _MODULE_DIRECTORY not in sys.path:
|
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
|
|
|
from normative_fragments import NormativeFragment, build_payload # noqa: E402
|
|
|
|
MAX_FRAME: Final = 64 * 1024
|
|
BROKER_TIMEOUT_SECONDS: Final = 3.0
|
|
SCHEMA_VERSION: Final = 1
|
|
MANIFEST_VERSION: Final = 1
|
|
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
|
|
DEFAULT_TTL_SECONDS: Final = 3600
|
|
|
|
# Normative sources whose exact bytes bind the lease, in binding order. Order is
|
|
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
|
|
# the derivation. Never fabricate a source that is not on disk.
|
|
FRAGMENT_SOURCES: Final = (
|
|
"CONSTITUTION.md",
|
|
"AGENTS.md",
|
|
"SOUL.md",
|
|
"USER.md",
|
|
"STANDARDS.md",
|
|
"TOOLS.md",
|
|
)
|
|
|
|
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
|
|
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
|
|
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
|
|
# missing one of these is broken, not minimal, so their absence is refused rather
|
|
# than silently dropped from the binding.
|
|
#
|
|
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
|
|
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
|
|
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
|
|
# only. Absence of those three is reported, not fatal.
|
|
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
|
|
|
|
|
|
class IncompleteBinding(RuntimeError):
|
|
"""A source that must bind this lease could not be read.
|
|
|
|
**Never downgrade this to a skip.** The broker recomputes the hashes from the
|
|
fragments it is sent, so an omitted fragment is internally consistent and
|
|
``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like
|
|
a complete one, and nothing downstream can tell the difference. Dropping an
|
|
unreadable source therefore does not degrade the binding, it forges a smaller
|
|
one. Fail here, where the omission is still visible.
|
|
"""
|
|
|
|
|
|
def mosaic_home() -> Path:
|
|
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
|
|
|
|
|
|
def broker_socket() -> Path:
|
|
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
|
|
if value:
|
|
return Path(value)
|
|
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
|
if runtime_dir:
|
|
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
|
|
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
|
|
|
|
|
|
def session_identity() -> tuple[str, int, str]:
|
|
"""Session id, CURRENT generation, runtime.
|
|
|
|
The generation file wins over the env var, matching ``lease_generation.py``.
|
|
Sending a generation HIGHER than the broker's would revoke this session's own
|
|
authority (``daemon.py:342-344``), so this never guesses.
|
|
"""
|
|
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
|
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
|
|
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
|
|
if state_file:
|
|
try:
|
|
return session_id, int(Path(state_file).read_text().strip()), runtime
|
|
except (OSError, ValueError):
|
|
pass
|
|
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
|
|
|
|
|
|
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
|
|
"""Assemble the wire construction and derive its hashes with the sole builder."""
|
|
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
|
|
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
|
|
required = REQUIRED_SOURCES | {runtime_contract}
|
|
wire_fragments: list[dict[str, str]] = []
|
|
objects: list[NormativeFragment] = []
|
|
absent: list[str] = []
|
|
|
|
for source_id in sources:
|
|
try:
|
|
content = (mosaic_home() / source_id).read_bytes()
|
|
except FileNotFoundError:
|
|
# Genuinely not on disk. Legitimate only for operator-owned sources.
|
|
if source_id in required:
|
|
raise IncompleteBinding(
|
|
f"required normative source is absent: {source_id}"
|
|
) from None
|
|
absent.append(source_id)
|
|
continue
|
|
except OSError as exc:
|
|
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
|
|
# That is an anomaly for EVERY source, optional ones included: an
|
|
# unreadable file is not an un-configured one, and treating it as
|
|
# absent is what lets a permission change quietly shrink the law.
|
|
raise IncompleteBinding(
|
|
f"normative source is present but unreadable: {source_id} "
|
|
f"({type(exc).__name__})"
|
|
) from exc
|
|
|
|
digest = hashlib.sha256(content).hexdigest()
|
|
wire_fragments.append(
|
|
{
|
|
"source_id": source_id,
|
|
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
"expected_sha256": digest,
|
|
}
|
|
)
|
|
objects.append(NormativeFragment(source_id, content, digest))
|
|
|
|
if not wire_fragments:
|
|
raise IncompleteBinding("no normative sources found — refusing an empty binding")
|
|
|
|
# Absence is legitimate here but never invisible. The omission is already
|
|
# baked into h_source (the framed source sequence differs), but nothing
|
|
# compares h_source to an expected value, so this line is the only place a
|
|
# human learns the binding was narrower than the full set.
|
|
if absent:
|
|
print(
|
|
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
result = build_payload(
|
|
manifest_version=MANIFEST_VERSION,
|
|
generator_version=GENERATOR_VERSION,
|
|
fragments=objects,
|
|
)
|
|
if result.injectionDecision != "ACCEPTED" or not result.promotion:
|
|
raise RuntimeError(f"construction refused locally: {result.source_reason}")
|
|
|
|
return (
|
|
{
|
|
"manifest_version": MANIFEST_VERSION,
|
|
"generator_version": GENERATOR_VERSION,
|
|
"fragments": wire_fragments,
|
|
},
|
|
result,
|
|
)
|
|
|
|
|
|
def broker_request(payload: dict[str, object]) -> dict[str, object]:
|
|
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
if len(raw) > MAX_FRAME:
|
|
raise ValueError(
|
|
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
|
|
)
|
|
response = bytearray()
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
|
connection.connect(str(broker_socket()))
|
|
connection.sendall(raw)
|
|
connection.shutdown(socket.SHUT_WR)
|
|
while len(response) <= MAX_FRAME:
|
|
chunk = connection.recv(4096)
|
|
if not chunk:
|
|
break
|
|
response.extend(chunk)
|
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
|
raise ValueError("invalid broker reply")
|
|
value = json.loads(response)
|
|
if not isinstance(value, dict):
|
|
raise ValueError("invalid broker reply")
|
|
return value
|
|
|
|
|
|
def begin(
|
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
compaction_epoch: int = 0,
|
|
request_epoch: int = 0,
|
|
) -> dict[str, object]:
|
|
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
|
|
session_id, generation, runtime = session_identity()
|
|
construction, derived = build_construction(runtime)
|
|
return broker_request(
|
|
{
|
|
"action": "begin_verification",
|
|
"session_id": session_id,
|
|
"runtime_generation": generation,
|
|
"runtime": runtime,
|
|
"ttl_seconds": ttl_seconds,
|
|
"binding": {
|
|
"compaction_epoch": compaction_epoch,
|
|
"request_epoch": request_epoch,
|
|
"h_source": derived.h_source,
|
|
"h_payload": derived.h_payload,
|
|
"schema_version": SCHEMA_VERSION,
|
|
},
|
|
"construction": construction,
|
|
}
|
|
)
|
|
|
|
|
|
def complete(challenge: str) -> dict[str, object]:
|
|
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
|
|
shipped it to the observer socket."""
|
|
session_id, generation, _ = session_identity()
|
|
observed = broker_request(
|
|
{
|
|
"action": "observe_receipt",
|
|
"session_id": session_id,
|
|
"runtime_generation": generation,
|
|
"receipt_challenge": challenge,
|
|
}
|
|
)
|
|
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
|
return {"stage": "observe_receipt", **observed}
|
|
promoted = broker_request(
|
|
{
|
|
"action": "promote_lease",
|
|
"session_id": session_id,
|
|
"runtime_generation": generation,
|
|
"receipt_challenge": challenge,
|
|
}
|
|
)
|
|
return {"stage": "promote_lease", **promoted}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument(
|
|
"--begin",
|
|
action="store_true",
|
|
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
|
|
)
|
|
group.add_argument(
|
|
"--complete",
|
|
metavar="CHALLENGE",
|
|
help="observe the emitted receipt and promote the lease",
|
|
)
|
|
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
|
|
arguments = parser.parse_args(argv)
|
|
|
|
try:
|
|
if arguments.begin:
|
|
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
|
|
else:
|
|
print(json.dumps(complete(arguments.complete), indent=2))
|
|
except KeyError as exc:
|
|
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
|
|
return 2
|
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
|
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|