test(#830): bound promote lost-ack residual
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -13,4 +13,14 @@
|
||||
- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C.
|
||||
- Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
|
||||
|
||||
## Named residual: promote-lease-lost-ACK (WI-3 D2-v5)
|
||||
|
||||
A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered.
|
||||
|
||||
**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists).
|
||||
|
||||
**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant.
|
||||
|
||||
**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations.
|
||||
|
||||
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
|
||||
@@ -450,6 +450,66 @@ describe('whole mutator-class lease gate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('promote-lease-lost-ACK orphaned VERIFIED lease is caught by observer revoke and by monotonic TTL expiry (D2-v5 backstop)', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const observerSessionId = await register(socket);
|
||||
const observerPending = await beginVerification(socket, observerSessionId, 'claude');
|
||||
await promote(socket, observerSessionId, observerPending.promotion_token!);
|
||||
|
||||
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
const retriedPromotion = await promote(
|
||||
socket,
|
||||
observerSessionId,
|
||||
observerPending.promotion_token!,
|
||||
);
|
||||
expect(retriedPromotion.ok).toBe(false);
|
||||
expect(['PROMOTION_TOKEN_MISMATCH', 'INVALID_LEASE_TRANSITION']).toContain(
|
||||
retriedPromotion.code,
|
||||
);
|
||||
|
||||
const revoked = spawnSync(
|
||||
'python3',
|
||||
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_LEASE_SESSION_ID: observerSessionId,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(revoked.status, revoked.stderr).toBe(0);
|
||||
expect(await authorize(socket, observerSessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const { socket: expirySocket } = await startBroker();
|
||||
const expirySessionId = await register(expirySocket);
|
||||
expect(expirySessionId).not.toBe(observerSessionId);
|
||||
const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1);
|
||||
await promote(expirySocket, expirySessionId, expiryPending.promotion_token!);
|
||||
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'LEASE_EXPIRED',
|
||||
decision: 'deny',
|
||||
});
|
||||
});
|
||||
|
||||
test('a fired observer fences the old lease even while broker transport is unavailable', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
Reference in New Issue
Block a user