Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/diagnostics/skill-pin-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { createHash } from "node:crypto";
* literal a second time.
*/
export const EXPECTED_SKILL_SHA256 =
"b70085dfad5d22658372f034dea5dfd6b82d0acee8cdb32da980093bb01f0799";
"01d8d68d03a3c34a832e2c2595c92f666776cbe895341940c08f3c3563101414";

/**
* Sentinel fragments. Assembled from three pieces so the full literal
Expand All @@ -63,8 +63,8 @@ export const EXPECTED_SKILL_SHA256 =
* search the `Skill` tool's result text for the assembled value.
*/
export const EXPECTED_SKILL_SENTINEL_A = "VAULTPILOT_PREFLIGHT_INTEGRITY";
export const EXPECTED_SKILL_SENTINEL_B = "_v7_";
export const EXPECTED_SKILL_SENTINEL_C = "8e252312c08c415b";
export const EXPECTED_SKILL_SENTINEL_B = "_v8_";
export const EXPECTED_SKILL_SENTINEL_C = "4aac027a9df315a9";

/** Raw GitHub URL of the canonical `SKILL.md` on `master`. */
export const SKILL_MD_RAW_URL =
Expand Down
33 changes: 31 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ import {
reverseResolve,
} from "./modules/balances/index.js";
import { getTokenAllowances } from "./modules/allowances/index.js";
import { getTokenAllowancesInput } from "./modules/allowances/schemas.js";
import { getTokenAllowancesInput, type GetTokenAllowancesResult } from "./modules/allowances/schemas.js";
import { renderSetLevelEnumeration } from "./security/set-level-enumeration.js";
import {
getNftCollection,
getNftHistory,
Expand Down Expand Up @@ -1198,6 +1199,34 @@ function configStatusHandler<T>(fn: (args: T) => unknown) {
};
}

/**
* Handler wrapper for `get_token_allowances`. Appends a
* `[SET-LEVEL ENUMERATION]` text block — the structured row dump the
* vaultpilot-preflight skill's Invariant #14 (set-level intent
* verification) expects on every response. The block is mandatory:
* a missing `[SET-LEVEL ENUMERATION]` block is an Invariant #4
* compromise signal, since the agent treats it as evidence the MCP
* silently filtered the row set (the reverse-revoke / set-level lie
* attack from corpus script `a086`, vaultpilot-mcp#450).
*/
function tokenAllowancesHandler<T>(fn: (args: T) => Promise<GetTokenAllowancesResult>) {
const inner = handler(fn);
return async (args: T) => {
const res = await inner(args);
if (!Array.isArray(res.content) || res.content.length === 0) return res;
const first = res.content[0];
if (!first || first.type !== "text") return res;
let payload: GetTokenAllowancesResult | null = null;
try {
payload = JSON.parse(first.text) as GetTokenAllowancesResult;
} catch {
return res;
}
res.content.push({ type: "text", text: renderSetLevelEnumeration(payload) });
return res;
};
}

/**
* Handler wrapper for `preview_send`. Appends the user-facing LEDGER BLIND-
* SIGN HASH block so the agent relays the hash verbatim BEFORE calling
Expand Down Expand Up @@ -3844,7 +3873,7 @@ async function main() {
"Enumerate every spender that currently holds a non-zero allowance over the wallet's balance of a specific ERC-20 token on a single EVM chain. Pulls Approval events from Etherscan's logs API filtered to the wallet as `owner`, dedups by spender (keeping the latest event per spender for provenance), then re-reads the LIVE `allowance(owner, spender)` for each via Multicall3 and drops anyone whose live value is 0 (revoked or fully consumed). Returns rows sorted by allowance descending, each carrying `spender`, optional `spenderLabel` (Aave V3 Pool / Uniswap V3 SwapRouter02 / Lido stETH / etc. resolved against the canonical CONTRACTS table), `currentAllowance` (raw bigint string), `currentAllowanceFormatted` (decimal-adjusted, or the literal string \"unlimited\"), `isUnlimited` (≥MAX_UINT256 − 0.01% — covers wallets that cap below MAX), and the `lastApprovedBlock` / `lastApprovedTxHash` / `lastApprovedAt` provenance. Top-level `unlimitedCount` and `notes[]` flag exposure (\"the spender(s) can move your entire balance, including future top-ups; revoke via approve(spender, 0)\"). Use this for security audits (\"do I have any unrevoked unlimited approvals?\"), pre-tx checks (\"do I already have allowance for X?\"), and revoke-cleanup workflows. v1 EVM-only (Ethereum / Arbitrum / Polygon / Base / Optimism). TRON deferred (different indexer surface); Solana intentionally out of scope (SPL delegation is per-account, not per-mint-per-owner — different question shape). Read-only; no signing, no broadcast.",
inputSchema: getTokenAllowancesInput.shape,
},
handler(getTokenAllowances)
tokenAllowancesHandler(getTokenAllowances)
);

registerTool(server,
Expand Down
163 changes: 163 additions & 0 deletions src/security/canonical-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Invariant #1.a — outer dispatch-target allowlist (MCP-side mirror).
*
* The skill (`vaultpilot-preflight/SKILL.md` §1.a) enforces this
* invariant agent-side as the load-bearing defense against
* rogue-MCP recipient-substitution attacks (smoke-test b117). This
* module mirrors the same canonical-contract allowlist on the MCP
* side as defense-in-depth: if a `prepare_*` flow's internal builder
* ever generates a tx whose outer `to` is not the canonical target
* for that tool family on that chain, this guard throws BEFORE the
* handle is issued — so an internal bug or supply-chain tamper that
* substitutes the target gets caught here too, not just by the
* agent's independent check.
*
* Source of truth: `src/config/contracts.ts`. Both this module and
* the skill's table are derived from it; the regression test
* `test/canonical-dispatch.test.ts` asserts every entry resolves
* to a real `CONTRACTS` address so the lookup can never silently
* fall out of sync.
*
* Scope: prepare_* tools where the canonical target is unambiguous.
* Sends (`prepare_native_send`, `prepare_token_send`) target
* user-supplied addresses or token contracts and have no canonical
* "expected to" — they are NOT covered. Cross-chain swap
* (`prepare_swap` via LiFi) has the LiFi diamond as its target on
* every chain and IS covered.
*/

import { CONTRACTS } from "../config/contracts.js";
import type { SupportedChain } from "../types/index.js";

/**
* Tool families with canonical-target enforcement. Keyed by the
* common prefix of `prepare_*` tool names; matched by `startsWith`.
*
* Per-(family, chain) the value is the SET of acceptable lower-cased
* outer `to` addresses. Set, not single value, because some families
* legitimately target multiple canonical contracts on the same chain
* (Compound has cUSDCv3 / cUSDTv3 / cWETHv3 / etc. — all valid Comet
* targets; the strict per-market check is left to the tool's own
* argument validation).
*/
const EXPECTED_TARGETS: Record<string, Partial<Record<SupportedChain, Set<string>>>> = {
prepare_aave_: chainSet({
ethereum: [CONTRACTS.ethereum.aave.pool],
arbitrum: [CONTRACTS.arbitrum.aave.pool],
polygon: [CONTRACTS.polygon.aave.pool],
base: [CONTRACTS.base.aave.pool],
optimism: [CONTRACTS.optimism.aave.pool],
}),
prepare_compound_: chainSet({
ethereum: Object.values(CONTRACTS.ethereum.compound),
arbitrum: Object.values(CONTRACTS.arbitrum.compound),
polygon: Object.values(CONTRACTS.polygon.compound),
base: Object.values(CONTRACTS.base.compound),
optimism: Object.values(CONTRACTS.optimism.compound),
}),
prepare_lido_stake: chainSet({
ethereum: [CONTRACTS.ethereum.lido.stETH],
}),
prepare_lido_unstake: chainSet({
ethereum: [
CONTRACTS.ethereum.lido.stETH,
CONTRACTS.ethereum.lido.withdrawalQueue,
],
}),
prepare_morpho_: chainSet({
ethereum: [CONTRACTS.ethereum.morpho.blue],
}),
prepare_uniswap_swap: chainSet({
ethereum: [CONTRACTS.ethereum.uniswap.swapRouter02],
arbitrum: [CONTRACTS.arbitrum.uniswap.swapRouter02],
polygon: [CONTRACTS.polygon.uniswap.swapRouter02],
base: [CONTRACTS.base.uniswap.swapRouter02],
optimism: [CONTRACTS.optimism.uniswap.swapRouter02],
}),
prepare_uniswap_v3_: chainSet({
ethereum: [CONTRACTS.ethereum.uniswap.positionManager],
arbitrum: [CONTRACTS.arbitrum.uniswap.positionManager],
polygon: [CONTRACTS.polygon.uniswap.positionManager],
base: [CONTRACTS.base.uniswap.positionManager],
optimism: [CONTRACTS.optimism.uniswap.positionManager],
}),
prepare_eigenlayer_deposit: chainSet({
ethereum: [CONTRACTS.ethereum.eigenlayer.strategyManager],
}),
};

function chainSet(
perChain: Partial<Record<SupportedChain, readonly string[]>>,
): Partial<Record<SupportedChain, Set<string>>> {
const out: Partial<Record<SupportedChain, Set<string>>> = {};
for (const [chain, addrs] of Object.entries(perChain) as [
SupportedChain,
readonly string[],
][]) {
out[chain] = new Set(addrs.map((a) => a.toLowerCase()));
}
return out;
}

function lookupExpected(toolName: string): Partial<Record<SupportedChain, Set<string>>> | null {
// Most-specific match first (e.g. `prepare_lido_stake` before
// `prepare_lido_`). Iterate keys sorted by descending length.
const keys = Object.keys(EXPECTED_TARGETS).sort((a, b) => b.length - a.length);
for (const key of keys) {
if (toolName === key || toolName.startsWith(key)) {
return EXPECTED_TARGETS[key]!;
}
}
return null;
}

/**
* Throws if `to` is not in the canonical-target allowlist for the
* `(toolName, chain)` tuple. No-op when `toolName` is not in the
* allowlist map (sends, swaps to user-supplied tokens, etc.).
*
* Error message mirrors the skill's `✗ DISPATCH-TARGET MISMATCH`
* prose so an operator reading either side gets the same diagnostic.
*/
export function assertCanonicalDispatchTarget(
toolName: string,
chain: SupportedChain,
to: string,
): void {
const expected = lookupExpected(toolName);
if (!expected) return;
const allowlist = expected[chain];
if (!allowlist) {
throw new Error(
`[INV_1A] ${toolName}: chain '${chain}' has no canonical target in the allowlist — refusing to issue an unsigned tx whose dispatch target cannot be verified.`,
);
}
if (!allowlist.has(to.toLowerCase())) {
const allowed = Array.from(allowlist).join(", ");
throw new Error(
`[INV_1A] ✗ DISPATCH-TARGET MISMATCH for ${toolName} on ${chain}: builder produced to=${to}, but the canonical target(s) for this tool family are: ${allowed}. Refusing to issue handle.`,
);
}
}

/**
* Test-only: enumerate every (toolFamily, chain) the allowlist
* covers. Used by the regression test to assert every entry resolves
* to a real `CONTRACTS` address.
*/
export function _enumerateAllowlistForTests(): Array<{
family: string;
chain: SupportedChain;
addresses: string[];
}> {
const out: Array<{ family: string; chain: SupportedChain; addresses: string[] }> = [];
for (const [family, perChain] of Object.entries(EXPECTED_TARGETS)) {
for (const [chain, addrs] of Object.entries(perChain) as [
SupportedChain,
Set<string>,
][]) {
out.push({ family, chain, addresses: Array.from(addrs) });
}
}
return out;
}
53 changes: 53 additions & 0 deletions src/security/set-level-enumeration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Renderer for the `[SET-LEVEL ENUMERATION]` text block emitted on
* every `get_token_allowances` response. Required by skill v8's
* Invariant #14 (set-level intent verification). A missing block is
* an Invariant #4 compromise signal — the agent treats absence as
* evidence the MCP silently filtered the row set.
*
* Pure function over the existing `GetTokenAllowancesResult` shape;
* no I/O. Exported so the wrapper handler in `src/index.ts` and the
* unit test can both consume it without coupling.
*/

import type { GetTokenAllowancesResult } from "../modules/allowances/schemas.js";

export function renderSetLevelEnumeration(
payload: GetTokenAllowancesResult,
): string {
const lines: string[] = [];
lines.push("[SET-LEVEL ENUMERATION]");
lines.push("");
lines.push(`- **Wallet:** \`${payload.wallet}\``);
lines.push(
`- **Token:** ${payload.token.symbol} (\`${payload.token.address}\`) on ${payload.chain}`,
);
lines.push(
`- **Active non-zero allowances:** ${payload.allowances.length} (${payload.unlimitedCount} unlimited)`,
);
if (payload.truncated) {
lines.push(
"- ⚠ **Indexer truncation flag set** — Etherscan row cap hit; the list below may be incomplete.",
);
}
lines.push("");
if (payload.allowances.length === 0) {
lines.push("_No active allowances on this (wallet, token, chain) tuple._");
} else {
lines.push("| # | Spender | Label | Current allowance | Unlimited | Last approved |");
lines.push("|---|---------|-------|-------------------|-----------|---------------|");
payload.allowances.forEach((row, i) => {
const label = row.spenderLabel ?? "(unlabeled)";
const unlimited = row.isUnlimited ? "**YES**" : "no";
const lastApproved = row.lastApprovedAt ?? `block ${row.lastApprovedBlock}`;
lines.push(
`| ${i} | \`${row.spender}\` | ${label} | ${row.currentAllowanceFormatted} | ${unlimited} | ${lastApproved} |`,
);
});
}
lines.push("");
lines.push(
"Per Invariant #14 (set-level intent verification): surface this enumeration verbatim to the user. The user — not the agent — picks which row to revoke. Do NOT filter or recommend.",
);
return lines.join("\n");
}
Loading
Loading