build_construction skipped any normative source it could not read
(`except OSError: continue`) and promoted whatever remained. That is not a
degraded binding, it is a forged smaller one: the broker recomputes h_source /
h_payload from the fragments it is SENT (daemon.py:602-616), 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.
Measured before this change, against a seeded home: with only USER.md readable,
the client produced a one-fragment construction with promotion=True. Removing
CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted.
The classification mirrors the framework's own file ownership rather than
inventing one:
* CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and
reconciled every upgrade (install.sh FRAMEWORK_OWNED,
config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime
RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken,
not minimal.
* SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by
`mosaic init`") and TOOLS.md is seeded on first install only, so their
absence is legitimate. It is reported on stderr, never silent.
Unreadable is handled separately from absent for EVERY source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.
Also corrects this module's own docstring, which asserted that a VERIFIED lease
means "this agent is running THIS law". It does not. Both sides of the broker's
comparison originate in this client, so it detects corruption in transit and
nothing else. That overstatement is where the belief spread from; the stronger
claim needs the broker re-reading on-disk sources against a manifest the agent
cannot rewrite.
Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the
enumeration guard's population is *test*.sh and does not cover Python, so an
unenumerated test here would simply never run). Falsifier executed: defeating the
guard while leaving the module API intact turns the suite red (12 failures);
restoring it returns green.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
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 = 300
|
|
|
|
# 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())
|