feat(lease): verified lease-remediation stack (rebased onto next) — promotion trigger + promote CLI + carve-out + TTL #1109
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user