agentwallet-mcp 1.12.2 → 1.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/build/index.js +1 -1
- package/build/local-wallet.js +56 -12
- package/build/ssrf-guard.d.ts +9 -1
- package/build/ssrf-guard.js +60 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
|
|
|
43
43
|
|
|
44
44
|
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
|
|
45
45
|
|
|
46
|
-
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom` and `approve`, the
|
|
46
|
+
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance` and Permit2 `approve`, the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open.
|
|
47
47
|
|
|
48
48
|
### Solana local signing
|
|
49
49
|
|
|
@@ -327,7 +327,8 @@ Which guards apply depends on who holds the key.
|
|
|
327
327
|
**Local mode** (you hold the key): there is no server in the signing path, so the server-side limits and pause above cannot see or stop a locally signed transaction, and nobody (including us) can freeze a local wallet. Your protection is the per-transaction caps enforced inside your own process before anything is signed. Set them before you fund the wallet; an unset cap means no limit.
|
|
328
328
|
|
|
329
329
|
- `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
|
|
330
|
-
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer, in human units of the token
|
|
330
|
+
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
|
|
331
|
+
- `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
|
|
331
332
|
- `AGENTWALLET_MAX_TX_SOL`: ceiling per SOL or SPL transfer
|
|
332
333
|
- `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment (default `1`)
|
|
333
334
|
|
package/build/index.js
CHANGED
|
@@ -461,7 +461,7 @@ function resolveChainId(network) {
|
|
|
461
461
|
const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
|
|
462
462
|
const server = new McpServer({
|
|
463
463
|
name: 'agentwallet',
|
|
464
|
-
version: '1.12.
|
|
464
|
+
version: '1.12.3',
|
|
465
465
|
}, {
|
|
466
466
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
|
|
467
467
|
|
package/build/local-wallet.js
CHANGED
|
@@ -193,7 +193,12 @@ export async function localTokenBalance(chainId, token) {
|
|
|
193
193
|
* 2-decimal token move 10,000x the configured cap.
|
|
194
194
|
*/
|
|
195
195
|
const decimalsCache = new Map();
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Decimals for `token`: the trusted registry, then the contract's own
|
|
198
|
+
* decimals() (cached on success). Null when the address does not answer like
|
|
199
|
+
* an ERC-20 at all, which is how the guard tells a token from a router.
|
|
200
|
+
*/
|
|
201
|
+
async function probeTokenDecimals(chainId, token) {
|
|
197
202
|
const known = lookupTrustedDecimals(chainId, token);
|
|
198
203
|
if (typeof known === 'number')
|
|
199
204
|
return known;
|
|
@@ -217,11 +222,33 @@ async function resolveTokenDecimals(chainId, token) {
|
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
224
|
catch {
|
|
220
|
-
// Unreachable RPC, non-standard token,
|
|
225
|
+
// Unreachable RPC, non-standard token, not a token at all.
|
|
221
226
|
}
|
|
222
|
-
return
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
async function resolveTokenDecimals(chainId, token) {
|
|
230
|
+
return (await probeTokenDecimals(chainId, token)) ?? 0;
|
|
223
231
|
}
|
|
224
|
-
|
|
232
|
+
/** Canonical Permit2, the same address on every EVM chain it is deployed to. */
|
|
233
|
+
const PERMIT2 = '0x000000000022d473030f116ddee9f6b43ac78ba3';
|
|
234
|
+
const CAP_LAYOUTS = {
|
|
235
|
+
a9059cbb: { amountIndex: 1, what: 'token transfer' }, // transfer(address,uint256)
|
|
236
|
+
'23b872dd': { amountIndex: 2, what: 'token transfer' }, // transferFrom(address,address,uint256)
|
|
237
|
+
'095ea7b3': { amountIndex: 1, what: 'approval' }, // approve(address,uint256)
|
|
238
|
+
'39509351': { amountIndex: 1, what: 'allowance increase' }, // increaseAllowance(address,uint256)
|
|
239
|
+
'87517c45': { amountIndex: 2, tokenIndex: 0, what: 'Permit2 approval' }, // Permit2.approve(address,address,uint160,uint48)
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* The selector table used to be an allowlist that returned early for anything
|
|
243
|
+
* it did not recognise, so `increaseAllowance`, a direct Permit2 `approve`, or
|
|
244
|
+
* any other function on a token contract went through uncapped while the guard
|
|
245
|
+
* reported nothing. Reported privately 2026-09-25. Now: priced calls are
|
|
246
|
+
* checked, and unpriced calldata aimed at a token contract or at Permit2 is
|
|
247
|
+
* refused, because any state change there can move funds. Calls to other
|
|
248
|
+
* contracts (routers, bridges) still pass: they can only pull what an approval
|
|
249
|
+
* already allowed, and approvals are what this guard bounds.
|
|
250
|
+
*/
|
|
251
|
+
async function assertWithinTokenCap(chainId, to, data) {
|
|
225
252
|
const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
|
|
226
253
|
if (!cap)
|
|
227
254
|
return;
|
|
@@ -230,21 +257,38 @@ async function assertWithinTokenCap(chainId, token, data) {
|
|
|
230
257
|
}
|
|
231
258
|
const hex = data.slice(2);
|
|
232
259
|
const selector = hex.slice(0, 8).toLowerCase();
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
260
|
+
const isPermit2 = to.toLowerCase() === PERMIT2;
|
|
261
|
+
const layout = CAP_LAYOUTS[selector];
|
|
262
|
+
const priced = layout && (layout.tokenIndex === undefined || isPermit2);
|
|
263
|
+
if (!priced) {
|
|
264
|
+
if (process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1')
|
|
265
|
+
return;
|
|
266
|
+
const looksLikeToken = isPermit2 || (await probeTokenDecimals(chainId, to)) !== null;
|
|
267
|
+
if (looksLikeToken) {
|
|
268
|
+
throw new Error(`Blocked by local guard: calldata selector 0x${selector} sent to ` +
|
|
269
|
+
`${isPermit2 ? 'Permit2' : 'a token contract'} is not one AGENTWALLET_MAX_TX_TOKEN can price, ` +
|
|
270
|
+
`so it is refused rather than let through uncapped. Use transfer, transferFrom, approve, ` +
|
|
271
|
+
`increaseAllowance or Permit2 approve, or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
|
|
272
|
+
}
|
|
273
|
+
return; // an ordinary contract call; it can only pull what an approval already allowed
|
|
274
|
+
}
|
|
275
|
+
const wordAt = (i) => hex.slice(8 + i * 64, 8 + (i + 1) * 64);
|
|
276
|
+
const word = wordAt(layout.amountIndex);
|
|
239
277
|
if (word.length !== 64)
|
|
240
278
|
return; // malformed; leave it to the node to reject
|
|
241
279
|
const amount = BigInt('0x' + word);
|
|
280
|
+
let token = to;
|
|
281
|
+
if (layout.tokenIndex !== undefined) {
|
|
282
|
+
const tw = wordAt(layout.tokenIndex);
|
|
283
|
+
if (tw.length !== 64)
|
|
284
|
+
return;
|
|
285
|
+
token = ('0x' + tw.slice(24));
|
|
286
|
+
}
|
|
242
287
|
const decimals = await resolveTokenDecimals(chainId, token);
|
|
243
288
|
const [whole, frac = ''] = cap.split('.');
|
|
244
289
|
const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
|
|
245
290
|
if (amount > capRaw) {
|
|
246
|
-
|
|
247
|
-
throw new Error(`Blocked by local guard: ${what} of ${amount} base units exceeds ` +
|
|
291
|
+
throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units exceeds ` +
|
|
248
292
|
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
249
293
|
`Raise the cap deliberately if this is intended.`);
|
|
250
294
|
}
|
package/build/ssrf-guard.d.ts
CHANGED
|
@@ -30,9 +30,17 @@
|
|
|
30
30
|
* by an anonymous researcher, 2026-08-02 (the DNS rebinding race), and by
|
|
31
31
|
* Arthur Kijkrittaya, 2026-09-08 (credentials surviving cross-origin redirects).
|
|
32
32
|
*/
|
|
33
|
+
/** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
|
|
34
|
+
export declare function expandIPv6(hostRaw: string): number[] | null;
|
|
33
35
|
/**
|
|
34
36
|
* Reduce a host to the IP address it actually denotes.
|
|
35
|
-
* Strips IPv6 brackets and unwraps
|
|
37
|
+
* Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
|
|
38
|
+
* address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
|
|
39
|
+
* and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
|
|
40
|
+
* ::7f00:1). The match is on the expanded groups, not on one spelling, so
|
|
41
|
+
* 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
|
|
42
|
+
* form was reported as unclassified on 2026-09-25 (not reachable in practice,
|
|
43
|
+
* modern stacks refuse to route ::/96, but a guard should not depend on that).
|
|
36
44
|
*/
|
|
37
45
|
export declare function canonicalizeHost(hostRaw: string): {
|
|
38
46
|
ip: string | null;
|
package/build/ssrf-guard.js
CHANGED
|
@@ -79,9 +79,45 @@ const V4_BLOCKED = [
|
|
|
79
79
|
'224.0.0.0/4', // multicast
|
|
80
80
|
'240.0.0.0/4', // reserved, includes 255.255.255.255
|
|
81
81
|
];
|
|
82
|
+
/** Expand an IPv6 literal (any spelling Node's isIP accepts) into eight 16-bit groups. */
|
|
83
|
+
export function expandIPv6(hostRaw) {
|
|
84
|
+
let h = hostRaw.toLowerCase();
|
|
85
|
+
// A trailing dotted IPv4 (::ffff:1.2.3.4, ::1.2.3.4, 64:ff9b::1.2.3.4) is the last two groups.
|
|
86
|
+
const dotted = h.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
87
|
+
if (dotted) {
|
|
88
|
+
const v4 = ipv4ToInt(dotted[2]);
|
|
89
|
+
if (v4 === null)
|
|
90
|
+
return null;
|
|
91
|
+
h = dotted[1] + (v4 >>> 16).toString(16) + ':' + (v4 & 0xffff).toString(16);
|
|
92
|
+
}
|
|
93
|
+
const halves = h.split('::');
|
|
94
|
+
if (halves.length > 2)
|
|
95
|
+
return null;
|
|
96
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
97
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
98
|
+
const missing = 8 - head.length - tail.length;
|
|
99
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0)
|
|
100
|
+
return null;
|
|
101
|
+
const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const g of groups) {
|
|
104
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
105
|
+
return null;
|
|
106
|
+
out.push(parseInt(g, 16));
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
const embeddedV4 = (hi, lo) => [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.');
|
|
111
|
+
const zeroThrough = (g, n) => g.slice(0, n).every(x => x === 0);
|
|
82
112
|
/**
|
|
83
113
|
* Reduce a host to the IP address it actually denotes.
|
|
84
|
-
* Strips IPv6 brackets and unwraps
|
|
114
|
+
* Strips IPv6 brackets and unwraps every IPv6 form that embeds an IPv4
|
|
115
|
+
* address: IPv4-mapped (::ffff:a.b.c.d), IPv4-translated (::ffff:0:a.b.c.d)
|
|
116
|
+
* and the deprecated IPv4-compatible form (::a.b.c.d, also spelled
|
|
117
|
+
* ::7f00:1). The match is on the expanded groups, not on one spelling, so
|
|
118
|
+
* 0:0:0:0:0:0:7f00:1 and ::7F00:1 canonicalize the same way. The compatible
|
|
119
|
+
* form was reported as unclassified on 2026-09-25 (not reachable in practice,
|
|
120
|
+
* modern stacks refuse to route ::/96, but a guard should not depend on that).
|
|
85
121
|
*/
|
|
86
122
|
export function canonicalizeHost(hostRaw) {
|
|
87
123
|
let host = hostRaw.trim().toLowerCase();
|
|
@@ -95,16 +131,15 @@ export function canonicalizeHost(hostRaw) {
|
|
|
95
131
|
return { ip: null, family: 0 };
|
|
96
132
|
if (fam === 4)
|
|
97
133
|
return { ip: host, family: 4 };
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return { ip: [
|
|
107
|
-
}
|
|
134
|
+
const g = expandIPv6(host);
|
|
135
|
+
if (!g)
|
|
136
|
+
return { ip: host, family: 6 };
|
|
137
|
+
if (zeroThrough(g, 5) && g[5] === 0xffff)
|
|
138
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:a.b.c.d
|
|
139
|
+
if (zeroThrough(g, 4) && g[4] === 0xffff && g[5] === 0)
|
|
140
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::ffff:0:a.b.c.d
|
|
141
|
+
if (zeroThrough(g, 6) && !(g[6] === 0 && g[7] <= 1))
|
|
142
|
+
return { ip: embeddedV4(g[6], g[7]), family: 4 }; // ::a.b.c.d, but not :: or ::1
|
|
108
143
|
return { ip: host, family: 6 };
|
|
109
144
|
}
|
|
110
145
|
/** True when the address is loopback, private, link-local, multicast or reserved. */
|
|
@@ -114,17 +149,23 @@ export function isPrivateAddress(ipRaw) {
|
|
|
114
149
|
return false;
|
|
115
150
|
if (family === 4)
|
|
116
151
|
return V4_BLOCKED.some(c => inV4Range(ip, c));
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
return true; //
|
|
120
|
-
if (
|
|
152
|
+
const g = expandIPv6(ip);
|
|
153
|
+
if (!g)
|
|
154
|
+
return true; // isIP said IPv6 but it will not parse: refuse
|
|
155
|
+
if (zeroThrough(g, 7) && g[7] <= 1)
|
|
156
|
+
return true; // :: unspecified, ::1 loopback
|
|
157
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
121
158
|
return true; // fc00::/7 unique-local
|
|
122
|
-
if (
|
|
159
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
123
160
|
return true; // fe80::/10 link-local
|
|
124
|
-
if (
|
|
161
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
125
162
|
return true; // ff00::/8 multicast
|
|
126
|
-
if (
|
|
127
|
-
return true; // NAT64
|
|
163
|
+
if (g[0] === 0x64 && g[1] === 0xff9b)
|
|
164
|
+
return true; // 64:ff9b::/96 NAT64
|
|
165
|
+
if (g[0] === 0x2002)
|
|
166
|
+
return true; // 2002::/16 6to4
|
|
167
|
+
if (g[0] === 0x2001 && g[1] === 0xdb8)
|
|
168
|
+
return true; // 2001:db8::/32 documentation
|
|
128
169
|
return false;
|
|
129
170
|
}
|
|
130
171
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"version": "1.12.
|
|
4
|
+
"version": "1.12.3",
|
|
5
5
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|