feat(lease): verified lease-remediation stack (rebased onto next) — promotion trigger + promote CLI + carve-out + TTL #1109

Open
mos-dt-0 wants to merge 24 commits from feat/lease-promotion-and-harness-isolation into next
Showing only changes of commit 603f91b242 - Show all commits
@@ -38,8 +38,27 @@ const RECEIPT_OBSERVER_CLIENT = join(
'lease-broker',
'receipt-observer-client.py',
);
const LEASE_PROMOTE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'lease_promote.py');
const RECOVERY_TOOL = 'mosaic_context_recover';
// Lazy lease promotion: a lease is promoted on the FIRST DENIED MUTATOR, not at
// session start. Two reasons that matter:
//
// 1. Promotion costs a whole model turn, because the receipt must be the entire
// message (hmac.compare_digest, "not a transcript substring"). Doing it at
// session start would collide with the Constitution's first-response mode
// declaration — the two cannot share a message. Deferring it means the mode
// declaration happens first and the receipt gets its own later turn, so no
// governance change is required.
// 2. A read-only session never pays for it at all.
//
// Bounded so a model that will not emit the receipt verbatim degrades to the
// current behaviour (denied mutators) rather than looping forever.
const MAX_PROMOTION_ATTEMPTS = 3;
let pendingReceiptChallenge: string | null = null;
let pendingReceiptText: string | null = null;
let promotionAttempts = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -126,6 +145,63 @@ function runPiLeaseRevoker(args: string[]): boolean {
return result.status === 0;
}
/**
* Mint a promotion challenge and return the instruction the model must follow.
*
* The receipt has to be the model's ENTIRE next message: the broker compares it
* with `hmac.compare_digest`, so any surrounding prose fails. Returning it as
* the tool_call block `reason` is what puts it in front of the model — pi feeds
* that string back as the tool result.
*
* This never posts the receipt itself. `receipt-observer-client.py` would accept
* any string, so self-posting would satisfy the broker while proving nothing;
* the whole point of the exchange is that a live model echoes the challenge.
*/
function beginPiPromotion(): string | undefined {
if (promotionAttempts >= MAX_PROMOTION_ATTEMPTS) return undefined;
const result = spawnSync('python3', [LEASE_PROMOTE, '--begin'], {
encoding: 'utf8',
timeout: 10_000,
env: process.env,
});
if (result.status !== 0) return undefined;
try {
const reply = JSON.parse(String(result.stdout)) as {
ok?: boolean;
receipt?: string;
receipt_challenge?: string;
};
if (reply.ok !== true || !reply.receipt || !reply.receipt_challenge) return undefined;
pendingReceiptChallenge = reply.receipt_challenge;
pendingReceiptText = reply.receipt;
promotionAttempts += 1;
return reply.receipt;
} catch {
return undefined;
}
}
/** Complete promotion after the model emitted the receipt and message_end
* shipped it to the observer. Returns true when the lease reached VERIFIED. */
function completePiPromotion(): boolean {
const challenge = pendingReceiptChallenge;
if (!challenge) return false;
pendingReceiptChallenge = null;
pendingReceiptText = null;
const result = spawnSync('python3', [LEASE_PROMOTE, '--complete', challenge], {
encoding: 'utf8',
timeout: 10_000,
env: process.env,
});
if (result.status !== 0) return false;
try {
const reply = JSON.parse(String(result.stdout)) as { state?: string };
return reply.state === 'VERIFIED';
} catch {
return false;
}
}
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
input: `${JSON.stringify({ tool_name: toolName })}\n`,
@@ -137,6 +213,29 @@ function checkPiMutatorGate(toolName: string): { block: true; reason: string } |
const detail = String(result.stderr ?? '')
.trim()
.split('\n')[0];
// Only an UNVERIFIED lease is promotable. Any other denial (GATE_UNAVAILABLE,
// STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) means something is wrong
// that a receipt cannot fix — minting there would thrash the broker, since
// begin_verification revokes before it mints.
if (detail.includes('MUTATOR_UNVERIFIED') && pendingReceiptChallenge === null) {
const receipt = beginPiPromotion();
if (receipt) {
return {
block: true,
reason:
`${detail}\n\n` +
'This session holds an UNVERIFIED lease, so mutators are denied. To ' +
'promote it, reply with the following text and NOTHING ELSE — no ' +
'preamble, no explanation, no code fence, no trailing text. It is ' +
'compared byte-for-byte, so any extra character fails:\n\n' +
`${receipt}\n\n` +
'Emit exactly that as your entire next message. The lease will then be ' +
'VERIFIED and you can retry this tool.',
};
}
}
return {
block: true,
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
@@ -370,7 +469,29 @@ export default function register(pi: ExtensionAPI) {
// Pi records only a finalized assistant entry at message_end. It never uses
// after_provider_response, which occurs before stream consumption.
pi.on('message_end', async (event) => {
recordPiMessageEnd((event as unknown as { message?: unknown }).message);
const message = (event as unknown as { message?: unknown }).message;
recordPiMessageEnd(message);
// Complete ONLY when the message just observed IS the receipt.
//
// message_end also fires for the message that CONTAINED the blocked tool
// call — i.e. one turn BEFORE the model emits the receipt. Completing there
// makes observe_receipt compare against the wrong text, fail, and burn the
// challenge before the model ever answers. Gating on an exact text match
// mirrors the broker's own hmac.compare_digest semantics and waits for the
// right turn.
//
// Order still matters within this handler: recordPiMessageEnd must have
// shipped the message to the observer before observe_receipt asks about it.
if (pendingReceiptChallenge !== null && pendingReceiptText !== null) {
if (assistantMessageText(message) === pendingReceiptText) {
if (completePiPromotion()) {
promotionAttempts = 0;
}
// On failure the challenge is cleared, so the next denied mutator mints
// a fresh one — bounded by MAX_PROMOTION_ATTEMPTS, after which the
// session simply stays UNVERIFIED rather than looping.
}
}
});
// The recovery custom tool is the only Pi invocation that maps to the