From 082b34f21fec589bc5da11ab0c23b313667c21bd Mon Sep 17 00:00:00 2001 From: Marcus Richards <297783350+marsojuji-cmyk@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:12:00 -0600 Subject: [PATCH] Reject leases the gate never issued Interlock.permit() trusted whatever Lease object it was handed, so a hand-built Lease with a far-future expires_at passed the gate for any resource. LeaseManager now keeps its own record of every lease it grants, and that record decides expiry and revocation. permit() raises the new LeaseNotIssued for leases it did not issue or whose identity was edited, and logs the denial with reason lease_not_issued. --- CHANGELOG.md | 12 +++++ CHARTER.md | 3 +- README.md | 10 ++-- docs/concepts/leases.md | 9 +++- docs/guides/quickstart.md | 7 +-- src/interlock/__init__.py | 18 +++++-- src/interlock/_errors.py | 4 ++ src/interlock/leases.py | 89 +++++++++++++++++++++++++----- tests/test_lease_issuance.py | 101 +++++++++++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 28 deletions(-) create mode 100644 tests/test_lease_issuance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9e1b2..aa1faf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ the current version is `interlock.__version__` (mirrored into `VERSION` by crashing before `close()`; before, records sat in the process buffer and died with it. `close()` fsyncs a file the ledger opened itself. Regression tests: `tests/test_ledger_durability.py`. +- `Interlock.permit()` rejects any lease it didn't issue. Before, a hand-built `Lease` with a + far-future `expires_at` passed the gate. `LeaseManager` now keeps its own record of every + lease it grants, and that record decides expiry and revocation. A hand-built lease, a + lease from another manager, or a lease whose resource, holder, TTL, or grant time was + edited after issue raises `LeaseNotIssued`, and the denial is logged with reason + `lease_not_issued`. Editing `expires_at` or clearing `revoked` on a `Lease` object no + longer extends or restores authority. `keepalive()` rejects unissued leases. Regression + tests: `tests/test_lease_issuance.py`. + +### Added +- `interlock.LeaseNotIssued`, a subclass of `InterlockError`, plus `LeaseManager.issued()` + and `LeaseManager.is_revoked()`. ## [0.1.0] — 2026-09-27 ### Added diff --git a/CHARTER.md b/CHARTER.md index 9255052..acba645 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -51,7 +51,8 @@ an unexercised grant persists. To act, the agent presents the lease and requests a permit for a specific action on a specific target. The interlock checks, in order: is the e-stop -clear? Is the lease live (held, unexpired, keepalive-fresh)? Is there an +clear? Is the lease live (issued by this interlock, unaltered, unexpired, +keepalive-fresh)? Is there an active fault gating this resource? Only if all three pass is the permit issued — and the permit covers exactly the requested action, nothing more. diff --git a/README.md b/README.md index da61a38..e366cf5 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ Mobile robots solved this a decade ago. Boston Dynamics' Spot admits intent thro Inside the substrate, **no action executes without a live lease, a clear e-stop, and no gating fault.** `Interlock.permit()` checks in this order and raises on the first failure: 1. e-stop clear, else `EStopEngaged` -2. lease held, unrevoked, and unexpired, else `LeaseRevoked` or `LeaseExpired` +2. lease issued by this `Interlock` and unaltered, else `LeaseNotIssued`; then unrevoked and unexpired, else `LeaseRevoked` or `LeaseExpired` 3. no `major` or `critical` fault on the lease's resource, else `FaultActive` - **Authority expires.** A lease exists only from `grant()` and dies at `expires_at` unless a keepalive renews it. - **A keepalive cannot resurrect a dead lease.** Renewal after expiry or revocation raises. +- **Only issued leases pass.** The lease manager keeps its own record of every lease it grants, and that record decides expiry and revocation. Editing a `Lease` object grants nothing. - **Revocation is permanent.** - **E-stop release is an operator decision, never a timeout.** One engagement refuses every permit until an explicit `release()`. - **Every permit and every denial is recorded** in the ledger's `permits` or `denials` series, with the reason. @@ -39,7 +40,7 @@ lease = ilk.grant(resource="docs:write", holder="research-agent", ttl=1800) # EXERCISE: every action passes the gate. with ilk.permit(lease, action="write", target="brief.md"): - ... # raises EStopEngaged, LeaseRevoked, LeaseExpired, or FaultActive otherwise + ... # raises EStopEngaged, LeaseNotIssued, LeaseRevoked, LeaseExpired, or FaultActive otherwise # A major or critical fault refuses further permits on the affected resource. ilk.faults.report(code="TOOL_TIMEOUT", severity="major", resource="web:read") @@ -62,7 +63,8 @@ The fastest path to a first lease is [`docs/guides/quickstart.md`](docs/guides/q |---|---| | E-stop engaged | Every `permit()` raises `EStopEngaged` and logs a `denials` record | | Lease expired (no keepalive in time) | `LeaseExpired`. A late keepalive also raises | -| Lease revoked | `LeaseRevoked`, permanently | +| Lease not issued by this `Interlock` (hand-built, from another instance, or edited after issue) | `LeaseNotIssued` and a `denials` record with reason `lease_not_issued` | +| Lease revoked | `LeaseRevoked`, permanently. Resetting `lease.revoked` on the object does not restore it | | `major`/`critical` fault on the resource | `FaultActive` until the fault is cleared. `minor` faults are recorded but do not gate | **Known limits:** @@ -74,7 +76,7 @@ The fastest path to a first lease is [`docs/guides/quickstart.md`](docs/guides/q ## Evidence -- **59 tests pass:** `pytest -q`, run 2026-10-07 on `main`. CI runs the same suite on Python 3.11, 3.12, and 3.13, plus `ruff` lint and format checks and a version-consistency check. +- **68 tests pass:** `pytest -q`, run 2026-10-07 on `main`. CI runs the same suite on Python 3.11, 3.12, and 3.13, plus `ruff` lint and format checks and a version-consistency check. - The quickstart above was executed on 2026-10-07: the gate refused a permit with `FaultActive` after a `major` fault. - [`INTAKE_LEDGER.md`](INTAKE_LEDGER.md) lists every source behind the transfer, with reading depth and an explicit unverified list. diff --git a/docs/concepts/leases.md b/docs/concepts/leases.md index 79155dd..fce8df6 100644 --- a/docs/concepts/leases.md +++ b/docs/concepts/leases.md @@ -38,6 +38,12 @@ decays, and nothing about an unexercised grant persists. Revocation is the deliberate counterpart: immediate, permanent, and checked before expiry in every gate. +The manager keeps its own record of every lease it grants, and that record +decides expiry and revocation. A hand-built `Lease`, a lease from another +manager, or a lease whose resource, holder, TTL, or grant time was edited +after issue fails with `LeaseNotIssued`. Pushing `expires_at` forward or +clearing `revoked` on the object grants nothing. + ## Safety Leases are designed to reduce the risk of authority outliving its need. They @@ -45,7 +51,8 @@ are not designed to eliminate it. A lease system cannot constrain code that bypasses it, and a TTL cannot help if the TTL is a year. Operators SHOULD treat short TTLs and frequent keepalives as the normal posture, not as paranoia. The checkable guarantee is narrower: *within* the substrate, no -permit issues against an expired or revoked lease, and the tests cover it. +permit issues against a lease the manager did not issue, or against an +expired or revoked lease. The tests cover both. ## A lease, end to end diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index f333919..d00e8bf 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -30,9 +30,10 @@ with ilk.permit(lease, action="write", target="notes.md"): Path("notes.md").write_text("hello") ``` -The gate checks, in order: e-stop clear, lease live, no gating fault on -`docs:write`. If any check fails, the context manager raises instead of -yielding — `EStopEngaged`, `LeaseExpired`/`LeaseRevoked`, or `FaultActive`. +The gate checks, in order: e-stop clear, lease issued by this `Interlock` +and live, no gating fault on `docs:write`. If any check fails, the context +manager raises instead of yielding — `EStopEngaged`, `LeaseNotIssued`, +`LeaseExpired`/`LeaseRevoked`, or `FaultActive`. ## 4. Watch a fault refuse diff --git a/src/interlock/__init__.py b/src/interlock/__init__.py index edfc89e..9e8e26f 100644 --- a/src/interlock/__init__.py +++ b/src/interlock/__init__.py @@ -7,8 +7,8 @@ - ``.faults`` — the shared :class:`~interlock.faults.FaultBus`. - ``.ledger`` — the self-describing :class:`~interlock.ledger.Ledger`. -Every permit passes the gate in order: e-stop clear, lease live, no gating -fault on the lease's resource. +Every permit passes the gate in order: e-stop clear, lease issued by this +facade and live, no gating fault on the lease's resource. """ from collections.abc import Callable, Iterator @@ -19,6 +19,7 @@ FaultActive, InterlockError, LeaseExpired, + LeaseNotIssued, LeaseRevoked, ) from interlock.claims import Claim @@ -43,6 +44,7 @@ "Lease", "LeaseExpired", "LeaseManager", + "LeaseNotIssued", "LeaseRevoked", "Ledger", "__version__", @@ -95,8 +97,9 @@ def permit(self, lease: Lease, action: str, target: str) -> Iterator[Lease]: """Permit exactly one action on one target, checking in order: 1. e-stop clear → else :exc:`EStopEngaged` - 2. lease live (held, unrevoked, unexpired) → else :exc:`LeaseRevoked` - or :exc:`LeaseExpired` + 2. lease issued by this facade's manager and unaltered → else + :exc:`LeaseNotIssued`; then live (unrevoked, unexpired) → else + :exc:`LeaseRevoked` or :exc:`LeaseExpired` 3. no gating fault on the lease's resource → else :exc:`FaultActive` Permits and denials are appended to the ledger. @@ -113,7 +116,12 @@ def permit(self, lease: Lease, action: str, target: str) -> Iterator[Lease]: except EStopEngaged: self.ledger.append("denials", {**base, "reason": "estop"}) raise - if lease.revoked: + if not self.leases.issued(lease): + self.ledger.append("denials", {**base, "reason": "lease_not_issued"}) + raise LeaseNotIssued( + f"lease {lease.id} for {lease.resource} was not issued by this Interlock" + ) + if self.leases.is_revoked(lease): self.ledger.append("denials", {**base, "reason": "lease_revoked"}) raise LeaseRevoked(f"lease {lease.id} for {lease.resource} was revoked") if not self.leases.is_live(lease): diff --git a/src/interlock/_errors.py b/src/interlock/_errors.py index 3b44965..3e0c89f 100644 --- a/src/interlock/_errors.py +++ b/src/interlock/_errors.py @@ -14,6 +14,10 @@ class LeaseExpired(InterlockError): """A lease was exercised after its expiry without a keepalive renewal.""" +class LeaseNotIssued(InterlockError): + """A lease was presented that this lease manager never issued, or was altered after issue.""" + + class LeaseRevoked(InterlockError): """A lease was exercised after being revoked by its granter.""" diff --git a/src/interlock/leases.py b/src/interlock/leases.py index 4d05102..3504a0e 100644 --- a/src/interlock/leases.py +++ b/src/interlock/leases.py @@ -5,6 +5,11 @@ comes into existence; without keepalive renewal the lease expires, and an unexercised grant simply decays. The clock is injectable so tests can drive time deterministically. + +The manager keeps its own record of every lease it issued. That record, not +the caller's ``Lease`` object, is the source of truth for expiry and +revocation: a hand-built ``Lease``, a lease from another manager, or a lease +whose fields were edited after issue is not recognised as issued. """ import time @@ -12,7 +17,7 @@ from dataclasses import dataclass from uuid import uuid4 -from interlock._errors import LeaseExpired, LeaseRevoked +from interlock._errors import LeaseExpired, LeaseNotIssued, LeaseRevoked @dataclass @@ -29,11 +34,24 @@ class Lease: revoked: bool = False +@dataclass +class _Issued: + """The manager's authoritative record of one issued lease.""" + + resource: str + holder: str + ttl_seconds: float + granted_at: float + expires_at: float + revoked: bool = False + + class LeaseManager: """Issues, renews, revokes, and checks leases against an injectable clock.""" def __init__(self, clock: Callable[[], float] | None = None) -> None: self._clock = clock if clock is not None else time.monotonic + self._issued: dict[str, _Issued] = {} def now(self) -> float: """Current time according to the manager's clock.""" @@ -42,7 +60,7 @@ def now(self) -> float: def grant(self, resource: str, holder: str, ttl_seconds: float) -> Lease: """Create a new lease. This is the only moment authority comes into existence.""" now = self.now() - return Lease( + lease = Lease( id=uuid4().hex, resource=resource, holder=holder, @@ -51,29 +69,72 @@ def grant(self, resource: str, holder: str, ttl_seconds: float) -> Lease: expires_at=now + ttl_seconds, last_keepalive=now, ) + self._issued[lease.id] = _Issued( + resource=resource, + holder=holder, + ttl_seconds=ttl_seconds, + granted_at=now, + expires_at=lease.expires_at, + ) + return lease + + def _record(self, lease: Lease) -> _Issued | None: + rec = self._issued.get(lease.id) + if rec is None: + return None + if (rec.resource, rec.holder, rec.ttl_seconds, rec.granted_at) != ( + lease.resource, + lease.holder, + lease.ttl_seconds, + lease.granted_at, + ): + return None # same id, altered identity: not the lease that was issued + return rec + + def issued(self, lease: Lease) -> bool: + """True when this manager issued ``lease`` and its identity fields are unaltered.""" + return self._record(lease) is not None + + def is_revoked(self, lease: Lease) -> bool: + """True when ``lease`` was revoked, by the manager's record or the object's flag.""" + rec = self._record(lease) + return lease.revoked or (rec is not None and rec.revoked) def keepalive(self, lease: Lease) -> Lease: """Renew a lease: ``expires_at`` becomes now plus the original TTL. - Raises :exc:`LeaseRevoked` if the lease was revoked and - :exc:`LeaseExpired` if the renewal arrives after expiry. A renewal - cannot resurrect a dead lease. + Raises :exc:`LeaseNotIssued` if this manager never issued the lease, + :exc:`LeaseRevoked` if the lease was revoked, and :exc:`LeaseExpired` + if the renewal arrives after expiry. A renewal cannot resurrect a dead + lease. Expiry is judged against the manager's record, not the object. """ - if lease.revoked: + rec = self._record(lease) + if rec is None: + raise LeaseNotIssued(f"lease {lease.id} for {lease.resource} was not issued here") + if lease.revoked or rec.revoked: raise LeaseRevoked(f"lease {lease.id} for {lease.resource} was revoked") now = self.now() - if now > lease.expires_at: - raise LeaseExpired( - f"lease {lease.id} for {lease.resource} expired at {lease.expires_at}" - ) + if now > rec.expires_at: + raise LeaseExpired(f"lease {lease.id} for {lease.resource} expired at {rec.expires_at}") + rec.expires_at = now + rec.ttl_seconds lease.last_keepalive = now - lease.expires_at = now + lease.ttl_seconds + lease.expires_at = rec.expires_at return lease def revoke(self, lease: Lease) -> None: - """Revoke a lease immediately. Revocation is permanent.""" + """Revoke a lease immediately. Revocation is permanent. + + The manager records the revocation, so resetting ``lease.revoked`` on + the object does not restore authority. + """ lease.revoked = True + rec = self._record(lease) + if rec is not None: + rec.revoked = True def is_live(self, lease: Lease) -> bool: - """True when the lease is held, unrevoked, and not yet expired.""" - return not lease.revoked and self.now() <= lease.expires_at + """True when this manager issued the lease and it is unrevoked and unexpired.""" + rec = self._record(lease) + if rec is None: + return False + return not (lease.revoked or rec.revoked) and self.now() <= rec.expires_at diff --git a/tests/test_lease_issuance.py b/tests/test_lease_issuance.py new file mode 100644 index 0000000..02b8f5c --- /dev/null +++ b/tests/test_lease_issuance.py @@ -0,0 +1,101 @@ +"""Regression tests: the gate admits only leases it issued, unaltered and unrevoked. + +``LeaseNotIssued`` is looked up at call time so the core regression tests fail +on an assertion (not an ImportError) against code that predates the fix. +""" + +import dataclasses + +import pytest + +import interlock +from interlock import Interlock, InterlockError, Lease, LeaseExpired, LeaseRevoked + + +def _forged(resource="docs:write"): + return Lease( + id="forged", + resource=resource, + holder="anyone", + ttl_seconds=1, + granted_at=0, + expires_at=1e18, + last_keepalive=0, + ) + + +def _not_issued_error(): + return getattr(interlock, "LeaseNotIssued", None) + + +def test_permit_rejects_a_forged_lease(ilk): + ran = [] + with ( + pytest.raises(InterlockError) as info, + ilk.permit(_forged(), action="write", target="f.md"), + ): + ran.append(True) # pragma: no cover + assert ran == [], "the action body must never run under a forged lease" + assert type(info.value).__name__ == "LeaseNotIssued" + denials = list(ilk.ledger.read_series("denials")) + assert denials[-1]["reason"] == "lease_not_issued" + assert list(ilk.ledger.read_series("permits")) == [] + + +def test_permit_rejects_a_lease_from_another_interlock(clock): + issuer = Interlock(clock=clock) + gate = Interlock(clock=clock) + lease = issuer.grant("docs:write", "agent", ttl_seconds=300) + with pytest.raises(InterlockError) as info, gate.permit(lease, action="write", target="f"): + pass # pragma: no cover + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_permit_rejects_a_lease_retargeted_after_issue(ilk): + lease = ilk.grant("docs:read", "agent", ttl_seconds=300) + widened = dataclasses.replace(lease, resource="docs:write") # same id, new resource + with pytest.raises(InterlockError) as info, ilk.permit(widened, action="write", target="f"): + pass # pragma: no cover + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_editing_expires_at_does_not_extend_authority(ilk, clock): + lease = ilk.grant("docs:write", "agent", ttl_seconds=60) + lease.expires_at = 1e18 + clock.advance(61) + with pytest.raises(LeaseExpired), ilk.permit(lease, action="write", target="f"): + pass # pragma: no cover + + +def test_clearing_the_revoked_flag_does_not_restore_authority(ilk): + lease = ilk.grant("docs:write", "agent", ttl_seconds=300) + ilk.leases.revoke(lease) + lease.revoked = False + with pytest.raises(LeaseRevoked), ilk.permit(lease, action="write", target="f"): + pass # pragma: no cover + + +def test_keepalive_rejects_a_lease_it_never_issued(ilk): + with pytest.raises(InterlockError) as info: + ilk.leases.keepalive(_forged()) + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_keepalive_still_renews_issued_leases(ilk, clock): + lease = ilk.grant("docs:write", "agent", ttl_seconds=60) + clock.advance(50) + ilk.leases.keepalive(lease) + clock.advance(50) + with ilk.permit(lease, action="write", target="f"): + pass # renewed at t=50 in the manager's record, live until t=110 + + +def test_is_live_false_for_unissued_lease(ilk): + assert ilk.leases.is_live(_forged()) is False + assert ilk.leases.issued(ilk.grant("r", "h", ttl_seconds=5)) is True + + +def test_lease_not_issued_is_exported_and_derives_from_base(): + err = _not_issued_error() + assert err is not None and "LeaseNotIssued" in interlock.__all__ + assert issubclass(err, InterlockError)