diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index 10756ffe..5dc5c3c8 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -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