Skip to content

Commit 98b2365

Browse files
refactor(signing): extract render/notices.ts
Part of #718 Moves the non-signing notice-render surface (~373 lines) out of src/signing/render-verification.ts into src/signing/render/notices.ts: AutoInstallContext, renderMissingSkillWarning, the private renderAutoInstallInProgress/Succeeded helpers, renderMissingSetupSkillWarning, renderPreflightSkillPinBlock, renderMissingDemoWalletWarning, and renderUpdateAvailableNotice. render-verification.ts gains export * from "./render/notices.js" so every existing import path is unaffected. Byte-identical move: the moved block is a pure cut-paste (verified by diff against the pre-move slice). None of these functions render signing content, so no wording/byte-identity risk beyond the general no-edits discipline. Import paths verified unaffected: src/shared/version-check.ts imports renderUpdateAvailableNotice from ../signing/render-verification.js, src/index.ts imports renderMissingSkillWarning/renderMissingSetupSkillWarning/ renderMissingDemoWalletWarning/renderPreflightSkillPinBlock from the same barrel, and test/missing-skill-render.test.ts, test/preflight-pin-block.test.ts, test/demo-wallet-notice.test.ts (via src/index.js) all resolve through the barrel unchanged. Scope: render-verification.ts + src/signing/render/** only. No test edits. src/modules/execution/index.ts untouched.
1 parent 350719a commit 98b2365

2 files changed

Lines changed: 374 additions & 374 deletions

File tree

src/signing/render-verification.ts

Lines changed: 1 addition & 374 deletions
Original file line numberDiff line numberDiff line change
@@ -7,379 +7,6 @@ export * from "./render/litecoin.js";
77
export * from "./render/tron.js";
88
export * from "./render/evm.js";
99
export * from "./render/solana.js";
10-
11-
/**
12-
* Agent-task block emitted when the user has NOT installed the
13-
* `vaultpilot-preflight` Claude Code skill (see
14-
* https://github.com/szhygulin/vaultpilot-security-skill). The skill is the only
15-
* MCP-independent source of truth for agent-side integrity checks — its
16-
* content lives under `~/.claude/skills/` on the user's disk, outside
17-
* this server's reach. Without it, a compromised MCP could silently
18-
* suppress its own CHECKS PERFORMED directives and the agent would have
19-
* no static rule to fall back on.
20-
*
21-
* This block is prefixed to every `prepare_*` / `preview_*` tool response
22-
* when the skill marker file is missing. It is a UX nudge, not a security
23-
* boundary: an actually-compromised MCP would of course suppress its own
24-
* warning too. The point is to catch the honest-MCP case where the user
25-
* simply hasn't completed the install step, so they don't silently run
26-
* with a weaker agent.
27-
*
28-
* `skillRepoUrl` is the GitHub URL the user clones from; passed in so the
29-
* call site owns the single source of truth (index.ts).
30-
*/
31-
/**
32-
* Auto-install state passed in by `index.ts`. The renderer switches on this
33-
* to produce one of three notice variants:
34-
* - `not-attempted` / unset / `already-present` → manual-install prose
35-
* (the original notice content, unchanged).
36-
* - `in-progress` → "auto-install kicked off, restart at end of session"
37-
* so the user knows we're handling it but Claude Code needs a restart
38-
* to load the freshly-cloned SKILL.md (skills are loaded at session
39-
* start, not on the fly).
40-
* - `succeeded` → "auto-installed, restart now to activate".
41-
* - `failed` → manual-install prose + the error detail so the user can
42-
* diagnose (no `git`, network down, dangling dir, etc.).
43-
*/
44-
export interface AutoInstallContext {
45-
state:
46-
| "not-attempted"
47-
| "in-progress"
48-
| "succeeded"
49-
| "failed"
50-
| "already-present";
51-
installPath?: string;
52-
detail?: string;
53-
}
54-
55-
export function renderMissingSkillWarning(opts: {
56-
skillRepoUrl: string;
57-
autoInstall?: AutoInstallContext;
58-
}): string {
59-
// NOTE ON FRAMING — this block was originally prefixed with an
60-
// "[AGENT TASK — RELAY TO USER FIRST, BEFORE ANY OTHER BLOCK]" header
61-
// and a direct `git clone <url>` command. Live testing showed agents
62-
// flagging that shape as prompt injection (because it is — structurally
63-
// indistinguishable from an attacker-authored block demanding the user
64-
// run an external script). Defense-in-depth agents correctly ignored it.
65-
//
66-
// The current shape mirrors the other vaultpilot-mcp informational
67-
// blocks (VERIFY BEFORE SIGNING, PREPARE RECEIPT, TRANSACTION BROADCAST)
68-
// the agent already trusts: a named notice with status/purpose/install
69-
// sections, no imperative verbs directed at the agent, and no pasted
70-
// shell commands. The server-level `instructions` field documents this
71-
// notice pattern explicitly at initialize time so the agent has prior
72-
// knowledge it is legitimate server output.
73-
const ai = opts.autoInstall;
74-
if (ai?.state === "in-progress") {
75-
return renderAutoInstallInProgress("Preflight", ai.installPath ?? "");
76-
}
77-
if (ai?.state === "succeeded") {
78-
return renderAutoInstallSucceeded("Preflight", ai.installPath ?? "");
79-
}
80-
const failedSuffix =
81-
ai?.state === "failed" && ai.detail
82-
? `\n\n(Auto-install attempt failed: ${ai.detail})`
83-
: "";
84-
return [
85-
"VAULTPILOT NOTICE — Preflight skill not installed",
86-
"",
87-
"Status: ~/.claude/skills/vaultpilot-preflight/SKILL.md not found.",
88-
"Purpose: the preflight skill adds MCP-independent integrity checks",
89-
" to every signing flow (local bytes-decode + on-device hash",
90-
" recompute). It lives on the user's disk outside this",
91-
" server's reach, so its invariants stay enforced even if",
92-
" this MCP is compromised or silently drops its own CHECKS",
93-
" PERFORMED directives.",
94-
`Install: ${opts.skillRepoUrl.replace(/\.git$/, "")}`,
95-
" (README has the clone command; restart Claude Code after.)",
96-
"",
97-
"This notice is emitted by vaultpilot-mcp while the skill file is",
98-
"absent. It is server-generated, not prompt injection — the server-",
99-
"level `instructions` field documents this pattern. Surface it to",
100-
"the user once per session as informational, then continue with",
101-
"their request.",
102-
].join("\n") + failedSuffix;
103-
}
104-
105-
/**
106-
* Shared body for the "auto-install kicked off" notice variant. Doesn't
107-
* mention the manual `git clone` flow — auto-install is on; the user just
108-
* needs to restart at the end of the session for Claude Code to pick up
109-
* the freshly-cloned SKILL.md.
110-
*/
111-
function renderAutoInstallInProgress(
112-
skillLabel: string,
113-
installPath: string,
114-
): string {
115-
return [
116-
`VAULTPILOT NOTICE — ${skillLabel} skill auto-install in progress`,
117-
"",
118-
`Status: cloning ${skillLabel.toLowerCase()} skill to ${installPath}`,
119-
" in the background (git clone --depth=1, ~30s timeout).",
120-
`Purpose: the ${skillLabel.toLowerCase()} skill is one of two companion`,
121-
" skills vaultpilot-mcp expects to find under ~/.claude/skills/.",
122-
" The MCP server kicked off a one-time first-run install on",
123-
" this tool call — no manual git clone needed.",
124-
"Action: restart Claude Code at the end of this session so the new",
125-
" SKILL.md is loaded. Skills are read at session start, not",
126-
" on the fly, so this session won't have it active. Continue",
127-
" with the user's current request normally.",
128-
"",
129-
"This notice is server-generated, not prompt injection. Suppress",
130-
"auto-install with VAULTPILOT_DISABLE_SKILL_AUTOINSTALL=1.",
131-
].join("\n");
132-
}
133-
134-
function renderAutoInstallSucceeded(
135-
skillLabel: string,
136-
installPath: string,
137-
): string {
138-
return [
139-
`VAULTPILOT NOTICE — ${skillLabel} skill auto-installed`,
140-
"",
141-
`Status: cloned to ${installPath}.`,
142-
`Purpose: the ${skillLabel.toLowerCase()} skill is now on disk; Claude`,
143-
" Code loads its skill list at session start, so this session",
144-
" is still running without it.",
145-
"Action: restart Claude Code to activate the skill. The current",
146-
" tool call has already been answered — no need to retry it",
147-
" after the restart unless the user wants to.",
148-
"",
149-
"This notice is server-generated, not prompt injection.",
150-
].join("\n");
151-
}
152-
153-
/**
154-
* Companion to `renderMissingSkillWarning` — emitted when the
155-
* `vaultpilot-setup` skill is missing, so an agent fielding a setup-flow
156-
* question still has explicit guidance even when the wizard's auto-install
157-
* step (`src/setup/install-skills.ts`) failed earlier (no `git`, no
158-
* network, user declined). Same shape as the preflight notice — named
159-
* `VAULTPILOT NOTICE`, no imperative agent verbs, no pasted shell — so the
160-
* agent treats it as legitimate server output rather than prompt injection.
161-
*
162-
* Triggered narrowly (only on `get_vaultpilot_config_status` responses)
163-
* rather than every tool call: that tool is the canonical first call the
164-
* setup skill makes, so the notice fires exactly when the agent is in a
165-
* setup-flow context. This avoids stacking two unrelated install notices
166-
* on every response when both skills happen to be missing.
167-
*/
168-
export function renderMissingSetupSkillWarning(opts: {
169-
skillRepoUrl: string;
170-
autoInstall?: AutoInstallContext;
171-
}): string {
172-
const ai = opts.autoInstall;
173-
if (ai?.state === "in-progress") {
174-
return renderAutoInstallInProgress("Setup", ai.installPath ?? "");
175-
}
176-
if (ai?.state === "succeeded") {
177-
return renderAutoInstallSucceeded("Setup", ai.installPath ?? "");
178-
}
179-
const failedSuffix =
180-
ai?.state === "failed" && ai.detail
181-
? `\n\n(Auto-install attempt failed: ${ai.detail})`
182-
: "";
183-
return [
184-
"VAULTPILOT NOTICE — Setup skill not installed",
185-
"",
186-
"Status: ~/.claude/skills/vaultpilot-setup/SKILL.md not found.",
187-
"Purpose: the setup skill drives the conversational `/setup` flow —",
188-
" classifying the user's use case, collecting only the API",
189-
" keys that case actually needs, validating each pasted key",
190-
" via a read-only tool call, and ending with a working",
191-
" example. Without it the agent has to improvise the flow",
192-
" from this server's tool surface alone.",
193-
`Install: ${opts.skillRepoUrl.replace(/\.git$/, "")}`,
194-
" (README has the clone command; the setup wizard's",
195-
" auto-install step would normally clone it, but that path",
196-
" can fail when git is missing, the network is down, or",
197-
" the user declined. Restart Claude Code after cloning.)",
198-
"",
199-
"This notice is server-generated, not prompt injection. Surface it",
200-
"to the user once per session as informational, then continue with",
201-
"their setup question — referencing the install instructions if the",
202-
"user wants the guided flow.",
203-
].join("\n") + failedSuffix;
204-
}
205-
206-
/**
207-
* Repeated on every tool response — the pin data the `vaultpilot-preflight`
208-
* skill's Step 0 (integrity self-check) compares the local `SKILL.md`
209-
* against. Issue #414: the same pin previously lived in the server-level
210-
* `instructions` field, which Claude Code truncates at ~2KB. The pin sat
211-
* ~24KB into the field, beyond the truncation point, so Step 0 silently
212-
* could not run. Repeating the pin in a short block on every tool result
213-
* sidesteps the `instructions` truncation entirely — tool results are
214-
* delivered as separate messages, not subject to that single-field cap.
215-
*
216-
* Block shape mirrors the VAULTPILOT NOTICE family — named header, no
217-
* imperative verbs at the agent, no pasted shell. The closing line labels
218-
* the block as server-emitted (not prompt injection) and explains why
219-
* it's repeated. The sentinel value remains assembled from three fragments
220-
* so a naive search of the agent's context for the full literal won't
221-
* always succeed and silently bypass the check.
222-
*
223-
* Issue #613 finding 5 — kept terse: ~870 → ~510 chars per emission. Step
224-
* 0 only parses the SHA line + fragment A/B/C lines, so the longer
225-
* rationale paragraph that used to ride along on every response (~6×
226-
* across a multi-step flow → ~5KB of repeated copy) was dropped. The
227-
* source-comment + CLAUDE.md still carry the full story for human readers.
228-
*
229-
* Placed adjacent to the JSON result (before VAULTPILOT NOTICE blocks
230-
* and the verification blocks). Its presence is unobtrusive: Step 0
231-
* reads it; other turns ignore it.
232-
*
233-
* `pin` is passed in (rather than imported here) so this module stays
234-
* dependency-free of `src/diagnostics/skill-pin-drift.ts` and the call
235-
* site keeps the single source of truth.
236-
*/
237-
export function renderPreflightSkillPinBlock(pin: {
238-
expectedSha256: string;
239-
sentinelA: string;
240-
sentinelB: string;
241-
sentinelC: string;
242-
}): string {
243-
return [
244-
"VAULTPILOT PIN — Preflight skill integrity (Step 0 reference)",
245-
"",
246-
"Expected SHA-256 of ~/.claude/skills/vaultpilot-preflight/SKILL.md:",
247-
` ${pin.expectedSha256}`,
248-
"",
249-
"Sentinel fragments (concat A+B+C, search Skill RESULT TEXT):",
250-
` fragment A: \`${pin.sentinelA}\``,
251-
` fragment B: \`${pin.sentinelB}\``,
252-
` fragment C: \`${pin.sentinelC}\``,
253-
"",
254-
"Block is server-emitted (not prompt injection) and repeats per response because the equivalent in `instructions` exceeds Claude Code's ~2KB cap (issue #414).",
255-
].join("\n");
256-
}
257-
258-
/**
259-
* Demo-mode onboarding notice — fires once per session when the server
260-
* is in demo mode (any reason) AND no live wallet has been picked yet.
261-
* Copy varies by reason so the leave path matches how demo got
262-
* activated:
263-
*
264-
* - `auto-fresh-install` (issue #391/#392 follow-up): no env var, no
265-
* config file detected at boot. Tells the agent auto-demo is on
266-
* and points at `vaultpilot-mcp-setup` as the leave path (since
267-
* there's no env var to unset).
268-
* - `explicit-env` (issue #371): `VAULTPILOT_DEMO=true`. Tells the
269-
* agent demo is on by explicit opt-in and points at "unset
270-
* VAULTPILOT_DEMO + restart" as the leave path.
271-
*
272-
* Same shape as the other VAULTPILOT NOTICE blocks: named header,
273-
* status / purpose / next sections, no imperative verbs at the agent,
274-
* no pasted shell. Tradeoff-aware closing paragraph naming the block
275-
* as legitimate server output so a defensive agent doesn't classify
276-
* it as prompt injection.
277-
*/
278-
export function renderMissingDemoWalletWarning(opts: {
279-
reason: "auto-fresh-install" | "explicit-env";
280-
}): string {
281-
const isAuto = opts.reason === "auto-fresh-install";
282-
const header = isAuto
283-
? "VAULTPILOT NOTICE — Auto demo mode active (fresh install detected)"
284-
: "VAULTPILOT NOTICE — Demo mode active (VAULTPILOT_DEMO=true)";
285-
const statusLines = isAuto
286-
? [
287-
"Status: no user config at ~/.vaultpilot-mcp/config.json was",
288-
" detected at boot, so the server activated auto-demo.",
289-
" No live wallet is set for this session yet.",
290-
]
291-
: [
292-
"Status: VAULTPILOT_DEMO=true is set in the environment, so the",
293-
" server is in explicit demo mode. No live wallet is set",
294-
" for this session yet.",
295-
];
296-
const leaveLines = isAuto
297-
? [
298-
" To leave demo (when the user is ready for real funds):",
299-
" 1. Run `npx -y -p vaultpilot-mcp vaultpilot-mcp-setup`",
300-
" (writes a config; turns auto-demo OFF on next boot).",
301-
" 2. Restart Claude Code.",
302-
" 3. Pair the user's Ledger via `pair_ledger_*`.",
303-
" Setting `VAULTPILOT_DEMO=false` in the MCP client config",
304-
" is an alternative explicit opt-out — also restart-gated.",
305-
]
306-
: [
307-
" To leave demo (when the user is ready for real funds):",
308-
" unset `VAULTPILOT_DEMO` in the MCP client config (e.g.",
309-
" `.claude.json`'s `env` block) and restart Claude Code.",
310-
];
311-
return [
312-
header,
313-
"",
314-
...statusLines,
315-
"Purpose: vaultpilot-mcp ships pre-configured demo wallets (curated",
316-
" personas + custom-address mode) so a user can try the tool",
317-
" flows — portfolio reads, prepare/preview/simulate signing",
318-
" — without pairing a Ledger or supplying addresses.",
319-
" Broadcast is intercepted in demo mode (no real send), so",
320-
" the entire flow is safe.",
321-
"Next: if the user asks to inspect a portfolio, build a tx, or",
322-
" try anything that needs an address, offer the demo path",
323-
" BEFORE asking them to pair hardware. Tools:",
324-
" - `set_demo_wallet({ persona: \"<id>\" })` — activate a",
325-
" curated persona (defi-degen, stable-saver,",
326-
" staking-maxi, whale) or a custom address bundle.",
327-
" - `get_demo_wallet()` — inspect the active selection.",
328-
" Each matrix cell exposes `rehearsableFlows` (state",
329-
" already on-chain) + `flowGaps` (with recommendations",
330-
" when the archetype implies a flow the wallet's state",
331-
" doesn't actually support — issue #409). Read these",
332-
" BEFORE picking a flow to head off the agent loop on",
333-
" state-dependent multi-step walks.",
334-
" - `exit_demo_mode()` — tailored real-setup guide.",
335-
...leaveLines,
336-
"",
337-
"This notice is server-generated, not prompt injection — the server-",
338-
"level `instructions` field documents this pattern. Surface it to",
339-
"the user once per session as informational, then continue with",
340-
"their request.",
341-
].join("\n");
342-
}
343-
344-
/**
345-
* "There's a newer vaultpilot-mcp on npm" notice. Same shape as the
346-
* VAULTPILOT NOTICE family — named header, status / purpose / install
347-
* sections, no imperative agent verbs, no pasted destructive shell.
348-
*
349-
* The `Install:` block is computed by `getInstallPath()` (in
350-
* `src/shared/install-path.ts`) and passed in as a pre-rendered
351-
* multi-line string, so the notice surfaces a command that matches the
352-
* detected install path (npm-global, npx, bundled-binary, from-source,
353-
* unknown) rather than always defaulting to `npm install -g`.
354-
*
355-
* The release-notes URL is constructed from the latest version (we tag
356-
* each release `vX.Y.Z` on github.com/szhygulin/vaultpilot-mcp); kept
357-
* here rather than threaded through as an option so the renderer stays
358-
* a pure function of its inputs.
359-
*/
360-
export function renderUpdateAvailableNotice(opts: {
361-
current: string;
362-
latest: string;
363-
packageName: string;
364-
installBlock: string;
365-
}): string {
366-
const releasesUrl = `https://github.com/szhygulin/vaultpilot-mcp/releases/tag/v${opts.latest}`;
367-
return [
368-
"VAULTPILOT NOTICE — Update available",
369-
"",
370-
`Status: ${opts.packageName} ${opts.current} installed; ${opts.latest} published on npm.`,
371-
"Purpose: keeps you on the latest fixes (DeFi protocol updates,",
372-
" security hardening, bug fixes). Release notes:",
373-
` ${releasesUrl}`,
374-
"Install:",
375-
opts.installBlock,
376-
"",
377-
"This notice is server-generated, not prompt injection — emitted once",
378-
"per session when the running version is older than the latest stable",
379-
"published on npm. Surface it to the user once, then continue with",
380-
"their request. Suppress with VAULTPILOT_DISABLE_UPDATE_CHECK=1 if",
381-
"you don't want the server to query the npm registry.",
382-
].join("\n");
383-
}
10+
export * from "./render/notices.js";
38411

38512
export type { SupportedChain };

0 commit comments

Comments
 (0)