Skip to content

Commit 66330df

Browse files
szhygulinclaude
andcommitted
feat(security): UnsignedTx.secondLlmRequired scaffold for Inv #12.5 (#501)
Inv #12.5 (skill-side, issue #501) marks a curated list of op classes where the second-LLM check is a precondition of `confirmed: true`, not opt-in. The skill-side rule lives in vaultpilot-skill v9+; this PR is the MCP-side scaffold so future hard-trigger op producers can flip a flag at build time and have the existing verification block surface a mandatory ⚠ line. What ships: - New `secondLlmRequired?: boolean` on `UnsignedTx`. Default absent / false. Documentation comment lists today's hard-trigger op classes (all currently deferred or not built): - EIP-7702 setCode (#481) - Permit2 batch grants (#453) - Opaque-facet bridges (#451) - Approval-management N-candidate selection (Inv #13) - Safe enableModule / setGuard / threshold changes - `renderVerificationBlock` reads the flag and emits a single ⚠ line below the hash: ⚠ SECOND-LLM CHECK REQUIRED — call get_verification_artifact(handle) and relay the pasteableBlock to the user BEFORE 'send' (Inv #12.5 hard-trigger op). - 3 tests pinning the behavior: omitted when absent/false, emitted when true, composes cleanly with recipient + tokenClass warnings. What does NOT ship: - No producer wires the flag yet (every hard-trigger op class is itself deferred). The flag is dormant scaffold. - No enforcement layer below the rendered ⚠. The MCP can't tell whether the agent actually ran the second-LLM check — same self- attestation gap as `userDecision: "send"`. Trust note (in the field doc): The flag is a workflow signal, not a cryptographic primitive. Closing the agent-side honesty gap requires infrastructure that doesn't exist today (provider-signed LLM responses, TEE attestation, or zkML proofs of NN inference). Discussed in the PR thread for this issue. Coordinated with skill v9 release (lifts §16 unconditional 7702 refusal + adds Inv #12.5 hard-trigger language); MCP-side `EXPECTED_SKILL_SHA256` bump happens in the skill-coordination PR, not this one. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9e45246 commit 66330df

3 files changed

Lines changed: 143 additions & 1 deletion

File tree

src/signing/render-verification.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,13 @@ function formatDecoder(v: TxVerification): string {
161161
export function renderVerificationBlock(
162162
tx: Pick<
163163
UnsignedTx,
164-
"chain" | "to" | "value" | "data" | "recipient" | "tokenClass"
164+
| "chain"
165+
| "to"
166+
| "value"
167+
| "data"
168+
| "recipient"
169+
| "tokenClass"
170+
| "secondLlmRequired"
165171
> & {
166172
verification: TxVerification;
167173
},
@@ -200,6 +206,22 @@ export function renderVerificationBlock(
200206
for (const w of tx.tokenClass?.warnings ?? []) {
201207
lines.push(` ⚠ ${w}`);
202208
}
209+
// Inv #12.5 hard-trigger ops (issue #501) — second-LLM check is a
210+
// precondition of `confirmed: true`, not opt-in. The renderer
211+
// surfaces a single mandatory line; the agent reads it and is
212+
// expected to call `get_verification_artifact({ handle })` and
213+
// relay the `pasteableBlock` BEFORE asking the user to reply
214+
// 'send'. Producers (e.g. future `prepare_eip7702_authorization`
215+
// / Permit2 batch / opaque-facet bridges) set the flag at build
216+
// time. Today no producer wires it — pure scaffold for when
217+
// hard-trigger op classes ship.
218+
if (tx.secondLlmRequired === true) {
219+
lines.push(
220+
" ⚠ SECOND-LLM CHECK REQUIRED — call get_verification_artifact(handle) " +
221+
"and relay the pasteableBlock to the user BEFORE 'send' (Inv #12.5 " +
222+
"hard-trigger op).",
223+
);
224+
}
203225
return lines.join("\n");
204226
}
205227

src/types/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,35 @@ export interface UnsignedTx {
13091309
* signing. Absent for ops that don't bind to a durable identifier.
13101310
*/
13111311
durableBindings?: import("../security/durable-binding.js").DurableBinding[];
1312+
/**
1313+
* Set to `true` when the prepared op falls into the Inv #12.5
1314+
* "hard-trigger ops list" (issue #501) — op classes where the
1315+
* second-LLM check is a precondition of `confirmed: true`, not
1316+
* an opt-in side offer. The verification renderer surfaces a
1317+
* `⚠ SECOND-LLM CHECK REQUIRED` line so the agent knows to call
1318+
* `get_verification_artifact({ handle })` and relay the
1319+
* `pasteableBlock` BEFORE asking the user to reply 'send'.
1320+
*
1321+
* Today's hard-trigger ops list (none currently shipped on this
1322+
* server — the flag is scaffold for when they land):
1323+
* - EIP-7702 setCode (#481, deferred)
1324+
* - Permit2 batch grants (#453, gated on Inv #1b/#2b)
1325+
* - Opaque-facet bridges (#451, deferred — Wormhole / Mayan /
1326+
* NEAR Intents / Across V3 with non-EVM destinations)
1327+
* - Approval-management N-candidate selection (Inv #13 territory)
1328+
* - Safe enableModule / setGuard / threshold changes
1329+
*
1330+
* Trust note: this is a workflow flag, not a cryptographic
1331+
* primitive. The agent could ignore it or self-attest the second-
1332+
* LLM check happened — same self-attestation gap as
1333+
* `userDecision: "send"`. Closing the gap requires infrastructure
1334+
* that doesn't exist today (provider-signed responses, TEE
1335+
* attestation, or zkML — discussed in the PR thread for #501).
1336+
* The flag is the smallest scaffold that lets future hard-trigger
1337+
* op classes hook into the existing verification block without
1338+
* a coordinated schema change at flag-add time.
1339+
*/
1340+
secondLlmRequired?: boolean;
13121341
}
13131342

13141343
/** Shape of ~/.vaultpilot-mcp/config.json. */
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Inv #12.5 hard-trigger flag (issue #501) — `secondLlmRequired` on
3+
* `UnsignedTx` causes the verification renderer to surface a
4+
* mandatory `⚠ SECOND-LLM CHECK REQUIRED` line so the agent knows
5+
* to call `get_verification_artifact` and relay the `pasteableBlock`
6+
* before the user's 'send' reply.
7+
*
8+
* No producer wires the flag yet — pure scaffold for when hard-
9+
* trigger op classes ship (#481 EIP-7702, #453 Permit2 batch, #451
10+
* opaque-facet bridges, future Safe enableModule / setGuard, etc.).
11+
*/
12+
import { describe, it, expect } from "vitest";
13+
import { CONTRACTS } from "../src/config/contracts.js";
14+
15+
const HEX_DATA =
16+
"0xa9059cbb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042b1d8d3a3f0000";
17+
18+
const baseTx = {
19+
chain: "ethereum" as const,
20+
to: CONTRACTS.ethereum.tokens.USDC as `0x${string}`,
21+
value: "0",
22+
data: HEX_DATA as `0x${string}`,
23+
verification: {
24+
payloadHash: "0xdeadbeef" as `0x${string}`,
25+
payloadHashShort: "deadbeef",
26+
comparisonString: "ignored",
27+
humanDecode: {
28+
functionName: "transfer" as const,
29+
args: [],
30+
source: "none" as const,
31+
},
32+
},
33+
};
34+
35+
describe("renderVerificationBlock — secondLlmRequired surfaces a ⚠ line", () => {
36+
it("omits the line when secondLlmRequired is absent or false (default for plain prepares)", async () => {
37+
const { renderVerificationBlock } = await import(
38+
"../src/signing/render-verification.js"
39+
);
40+
const omitted = renderVerificationBlock({ ...baseTx });
41+
const explicitFalse = renderVerificationBlock({
42+
...baseTx,
43+
secondLlmRequired: false,
44+
});
45+
expect(omitted).not.toMatch(/SECOND-LLM CHECK REQUIRED/);
46+
expect(explicitFalse).not.toMatch(/SECOND-LLM CHECK REQUIRED/);
47+
});
48+
49+
it("emits the ⚠ line when secondLlmRequired is true", async () => {
50+
const { renderVerificationBlock } = await import(
51+
"../src/signing/render-verification.js"
52+
);
53+
const block = renderVerificationBlock({
54+
...baseTx,
55+
secondLlmRequired: true,
56+
});
57+
expect(block).toMatch(/ SECOND-LLM CHECK REQUIRED/);
58+
expect(block).toMatch(/get_verification_artifact/);
59+
expect(block).toMatch(/Inv #12\.5/);
60+
// Surfaces below the hash line so the agent reads it at the
61+
// same scan position as other ⚠ warnings (recipient + token-
62+
// class), where the user's attention lands before 'send'.
63+
const hashIdx = block.indexOf("Hash:");
64+
const warnIdx = block.indexOf("⚠ SECOND-LLM CHECK REQUIRED");
65+
expect(warnIdx).toBeGreaterThan(hashIdx);
66+
});
67+
68+
it("composes with other ⚠ warnings (recipient + tokenClass + secondLlmRequired all surface together)", async () => {
69+
const { renderVerificationBlock } = await import(
70+
"../src/signing/render-verification.js"
71+
);
72+
const block = renderVerificationBlock({
73+
...baseTx,
74+
to: CONTRACTS.ethereum.lido.stETH as `0x${string}`,
75+
recipient: {
76+
source: "literal",
77+
warnings: [
78+
"contacts file failed verification — recipient label not checked",
79+
],
80+
},
81+
tokenClass: {
82+
flags: ["rebasing"],
83+
warnings: ["stETH is rebasing — recipient may receive 1-2 wei less."],
84+
},
85+
secondLlmRequired: true,
86+
});
87+
expect(block).toMatch(/ contacts file failed verification/);
88+
expect(block).toMatch(/ stETH is rebasing/);
89+
expect(block).toMatch(/ SECOND-LLM CHECK REQUIRED/);
90+
});
91+
});

0 commit comments

Comments
 (0)