@elisym/mcp 0.13.0 → 0.14.0

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/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from 'commander';
7
7
  import { generateSecretKey, nip19, getPublicKey } from 'nostr-tools';
8
8
  import { stat, readFile, writeFile, rename, unlink } from 'node:fs/promises';
9
9
  import { homedir, platform } from 'node:os';
10
- import { dirname, join, isAbsolute, resolve } from 'node:path';
10
+ import { dirname, join, resolve, isAbsolute, relative } from 'node:path';
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -725,7 +725,7 @@ async function buildEffectiveLimits() {
725
725
  throw new Error(`Duplicate session_spend_limit entry in ${globalConfigPath()}: ${key}`);
726
726
  }
727
727
  seen.add(key);
728
- map.set(key, parseAssetAmount(asset, entry.amount.toString()));
728
+ map.set(key, parseAssetAmount(asset, entry.amount));
729
729
  }
730
730
  return map;
731
731
  }
@@ -817,6 +817,14 @@ async function buildAgentInstance(name, config) {
817
817
  agentDir: resolved?.dir
818
818
  };
819
819
  }
820
+ function scrubAgent(ctx, agent) {
821
+ agent.client.close();
822
+ if (agent.solanaKeypair) {
823
+ agent.solanaKeypair.secretKey.fill(0);
824
+ }
825
+ agent.identity.scrub();
826
+ ctx.registry.delete(agent.name);
827
+ }
820
828
  var agentTools = [
821
829
  defineTool({
822
830
  name: "create_agent",
@@ -905,34 +913,34 @@ Solana: ${solanaSigner.address}
905
913
  }
906
914
  } catch {
907
915
  }
916
+ let old;
908
917
  try {
909
- const old = ctx.active();
910
- if (old.name !== input.name) {
911
- old.client.close();
912
- if (old.solanaKeypair) {
913
- old.solanaKeypair.secretKey.fill(0);
914
- }
915
- old.identity.scrub();
916
- ctx.registry.delete(old.name);
917
- }
918
+ old = ctx.active();
918
919
  } catch {
919
920
  }
920
921
  if (ctx.registry.has(input.name)) {
922
+ if (old && old.name !== input.name) {
923
+ scrubAgent(ctx, old);
924
+ }
921
925
  ctx.activeAgentName = input.name;
922
926
  const agent = ctx.active();
923
- const npub = agent.identity.npub;
924
- return textResult(`Switched to agent "${input.name}" (${npub}).`);
927
+ const npub2 = agent.identity.npub;
928
+ return textResult(`Switched to agent "${input.name}" (${npub2}).`);
925
929
  }
930
+ let instance;
926
931
  try {
927
932
  const config = await loadAgentConfig(input.name);
928
- const instance = await buildAgentInstance(input.name, config);
929
- ctx.register(instance, true);
930
- const npub = instance.identity.npub;
931
- return textResult(`Loaded and switched to agent "${input.name}" (${npub}).`);
933
+ instance = await buildAgentInstance(input.name, config);
932
934
  } catch (e) {
933
935
  const msg = e instanceof Error ? e.message : String(e);
934
936
  return errorResult(`Failed to load agent "${input.name}": ${msg}`);
935
937
  }
938
+ if (old && old.name !== input.name) {
939
+ scrubAgent(ctx, old);
940
+ }
941
+ ctx.register(instance, true);
942
+ const npub = instance.identity.npub;
943
+ return textResult(`Loaded and switched to agent "${input.name}" (${npub}).`);
936
944
  }
937
945
  }),
938
946
  defineTool({
@@ -994,25 +1002,64 @@ Solana: ${solanaSigner.address}
994
1002
  if (!agent) {
995
1003
  return errorResult(`Agent "${input.name}" is not loaded.`);
996
1004
  }
997
- agent.client.close();
998
- if (agent.solanaKeypair) {
999
- agent.solanaKeypair.secretKey.fill(0);
1000
- }
1001
- agent.identity.scrub();
1002
- ctx.registry.delete(input.name);
1005
+ scrubAgent(ctx, agent);
1003
1006
  return textResult(`Agent "${input.name}" stopped and removed.`);
1004
1007
  }
1005
1008
  })
1006
1009
  ];
1007
1010
  var execFileP = promisify(execFile);
1008
1011
  var MAX_INPUT_PATH_LEN = 4096;
1012
+ var SENSITIVE_NAME_RE = /(^|[/\\])(\.secrets\.json|\.env(\..+)?|id_rsa|id_dsa|id_ecdsa|id_ed25519|.*-keypair\.json|.*\.pem|.*\.key)$/i;
1013
+ var SENSITIVE_DIR_SEGMENTS = /* @__PURE__ */ new Set([".elisym", ".ssh", ".aws", ".gnupg"]);
1014
+ function isSensitiveInputPath(absPath) {
1015
+ if (SENSITIVE_NAME_RE.test(absPath)) {
1016
+ return true;
1017
+ }
1018
+ if (absPath === "/proc" || absPath.startsWith("/proc/")) {
1019
+ return true;
1020
+ }
1021
+ const segments = absPath.split(/[/\\]+/);
1022
+ return segments.some((segment) => SENSITIVE_DIR_SEGMENTS.has(segment.toLowerCase()));
1023
+ }
1009
1024
  var GIT_TIMEOUT_MS = 3e4;
1010
1025
  var GIT_MAX_BUFFER = MAX_INPUT_LEN * 2;
1011
- async function readJobInputFile(inputPath) {
1026
+ var GIT_SAFETY_ARGS = [
1027
+ "-c",
1028
+ "core.fsmonitor=",
1029
+ "-c",
1030
+ "diff.external=",
1031
+ "-c",
1032
+ "core.hooksPath=/dev/null"
1033
+ ];
1034
+ function isValidGitRef(ref) {
1035
+ if (ref.length === 0 || ref.length > 256) {
1036
+ return false;
1037
+ }
1038
+ if (ref.startsWith("-") || ref.includes("..")) {
1039
+ return false;
1040
+ }
1041
+ return /^[A-Za-z0-9._/@~^-]+$/.test(ref);
1042
+ }
1043
+ async function readJobInputFile(inputPath, options) {
1012
1044
  if (inputPath.length > MAX_INPUT_PATH_LEN) {
1013
1045
  throw new Error(`input_path too long: ${inputPath.length} chars (max ${MAX_INPUT_PATH_LEN}).`);
1014
1046
  }
1015
- const absPath = isAbsolute(inputPath) ? inputPath : resolve(process.cwd(), inputPath);
1047
+ const cwd = resolve(process.cwd());
1048
+ const absPath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(cwd, inputPath);
1049
+ if (isSensitiveInputPath(absPath)) {
1050
+ throw new Error(
1051
+ `Refusing to read a sensitive file as job input: ${absPath}. Secret keys, .env, SSH/keypair files, ~/.elisym and /proc are blocked.`
1052
+ );
1053
+ }
1054
+ if (!options?.allowOutsideCwd) {
1055
+ const rel = relative(cwd, absPath);
1056
+ const insideCwd = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1057
+ if (!insideCwd) {
1058
+ throw new Error(
1059
+ `input_path "${absPath}" resolves outside the working directory (${cwd}). Move the file under the working directory or pass allow_outside_cwd: true.`
1060
+ );
1061
+ }
1062
+ }
1016
1063
  let stats;
1017
1064
  try {
1018
1065
  stats = await stat(absPath);
@@ -1041,11 +1088,11 @@ async function readJobInputFile(inputPath) {
1041
1088
  }
1042
1089
  async function execGit(repoPath, args) {
1043
1090
  try {
1044
- const { stdout } = await execFileP("git", args, {
1091
+ const { stdout } = await execFileP("git", [...GIT_SAFETY_ARGS, ...args], {
1045
1092
  cwd: repoPath,
1046
1093
  timeout: GIT_TIMEOUT_MS,
1047
1094
  maxBuffer: GIT_MAX_BUFFER,
1048
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1095
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1" }
1049
1096
  });
1050
1097
  return stdout;
1051
1098
  } catch (e) {
@@ -1099,18 +1146,23 @@ async function computeGitDiff(repoPath, base) {
1099
1146
  let args;
1100
1147
  let describedRange;
1101
1148
  if (base) {
1102
- args = ["diff", `${base}...HEAD`];
1149
+ if (!isValidGitRef(base)) {
1150
+ throw new Error(
1151
+ `Invalid "base": ${base}. Use a branch/tag/commit ref (letters, digits, ". _ / @ ~ ^ -", no leading "-", no "..").`
1152
+ );
1153
+ }
1154
+ args = ["diff", "--no-ext-diff", "--end-of-options", `${base}...HEAD`];
1103
1155
  describedRange = `${base}...HEAD`;
1104
1156
  } else if (await isDirty(absRepo)) {
1105
- args = ["diff", "HEAD"];
1157
+ args = ["diff", "--no-ext-diff", "HEAD"];
1106
1158
  describedRange = "HEAD (working tree, uncommitted changes)";
1107
1159
  } else {
1108
1160
  const detected = await detectDefaultBase(absRepo);
1109
1161
  if (detected) {
1110
- args = ["diff", `${detected}...HEAD`];
1162
+ args = ["diff", "--no-ext-diff", "--end-of-options", `${detected}...HEAD`];
1111
1163
  describedRange = `${detected}...HEAD`;
1112
1164
  } else {
1113
- args = ["diff", "HEAD"];
1165
+ args = ["diff", "--no-ext-diff", "HEAD"];
1114
1166
  describedRange = "HEAD (no main/master detected)";
1115
1167
  }
1116
1168
  }
@@ -1277,8 +1329,11 @@ var INJECTION_PATTERNS = [
1277
1329
  var STRICT_INJECTION_PATTERNS = INJECTION_PATTERNS.filter((p) => !p.noisy);
1278
1330
  function stripDangerousUnicode(text) {
1279
1331
  return text.replace(
1332
+ // C0 (minus \n,\t) + C1 controls; bidi marks/overrides/isolates (061C, 200E-200F,
1333
+ // 202A-202E, 2066-2069); zero-width + word-joiner + invisible-operator format chars
1334
+ // (200B-200D, 2060-2064, 180E, FEFF); tag chars; replacement char.
1280
1335
  // eslint-disable-next-line no-control-regex
1281
- /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\u202A-\u202E\u2066-\u2069\u200B-\u200D\uFEFF\uFFFD]|[\uDB40][\uDC01-\uDC7F]/g,
1336
+ /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\uFFFD]|[\uDB40][\uDC01-\uDC7F]/g,
1282
1337
  ""
1283
1338
  );
1284
1339
  }
@@ -1294,9 +1349,15 @@ function detectInjections(text, includeNoisy) {
1294
1349
  if (normalized.length <= INJECTION_SCAN_BUDGET) {
1295
1350
  return patterns.some((p) => p.pattern.test(normalized));
1296
1351
  }
1297
- const head = normalized.slice(0, INJECTION_SCAN_BUDGET);
1298
- const tail = normalized.slice(-INJECTION_SCAN_BUDGET);
1299
- return patterns.some((p) => p.pattern.test(head) || p.pattern.test(tail));
1352
+ const OVERLAP = 256;
1353
+ const step = INJECTION_SCAN_BUDGET - OVERLAP;
1354
+ for (let start = 0; start < normalized.length; start += step) {
1355
+ const window = normalized.slice(start, start + INJECTION_SCAN_BUDGET);
1356
+ if (patterns.some((p) => p.pattern.test(window))) {
1357
+ return true;
1358
+ }
1359
+ }
1360
+ return false;
1300
1361
  }
1301
1362
  function scanForInjections(text, mode = "full") {
1302
1363
  return detectInjections(text, mode === "full");
@@ -1305,8 +1366,11 @@ function isLikelyBase64(s) {
1305
1366
  if (s.length < 64) {
1306
1367
  return false;
1307
1368
  }
1308
- const base64Chars = s.replace(/[A-Za-z0-9+/=\s]/g, "");
1309
- return base64Chars.length / s.length < 0.05;
1369
+ if (/\s/.test(s)) {
1370
+ return false;
1371
+ }
1372
+ const nonBase64Chars = s.replace(/[A-Za-z0-9+/=]/g, "");
1373
+ return nonBase64Chars.length / s.length < 0.02;
1310
1374
  }
1311
1375
  function sanitizeUntrusted(input, kind = "text", options) {
1312
1376
  let text = stripDangerousUnicode(input);
@@ -1363,14 +1427,12 @@ var writeLocks = /* @__PURE__ */ new Map();
1363
1427
  function withLock(path, fn) {
1364
1428
  const previous = writeLocks.get(path) ?? Promise.resolve();
1365
1429
  const next = previous.then(fn, fn);
1366
- writeLocks.set(
1367
- path,
1368
- next.finally(() => {
1369
- if (writeLocks.get(path) === next) {
1370
- writeLocks.delete(path);
1371
- }
1372
- })
1373
- );
1430
+ const wrapped = next.finally(() => {
1431
+ if (writeLocks.get(path) === wrapped) {
1432
+ writeLocks.delete(path);
1433
+ }
1434
+ });
1435
+ writeLocks.set(path, wrapped);
1374
1436
  return next;
1375
1437
  }
1376
1438
  function pathFor(agentDir) {
@@ -1483,7 +1545,10 @@ var SubmitAndPayJobFromFileSchema = z.object({
1483
1545
  capability: z.string().min(1).max(64).default("general"),
1484
1546
  kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
1485
1547
  timeout_secs: z.number().int().min(1).max(600).default(300),
1486
- max_price_lamports: z.number().int().optional()
1548
+ max_price_lamports: z.number().int().optional(),
1549
+ allow_outside_cwd: z.boolean().default(false).describe(
1550
+ "Allow reading a file outside the MCP server working directory. Off by default - the file content is forwarded to the provider before payment and is invisible in the transcript, so reads are confined to the working dir unless this is set. Sensitive files (secret keys, .env, SSH/keypair, ~/.elisym, /proc) are always refused."
1551
+ )
1487
1552
  });
1488
1553
  var SubmitDiffReviewSchema = z.object({
1489
1554
  provider_npub: z.string(),
@@ -2132,7 +2197,9 @@ ${wrapped}`);
2132
2197
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2133
2198
  let payload;
2134
2199
  try {
2135
- payload = await readJobInputFile(input.input_path);
2200
+ payload = await readJobInputFile(input.input_path, {
2201
+ allowOutsideCwd: input.allow_outside_cwd
2202
+ });
2136
2203
  } catch (e) {
2137
2204
  return errorResult(e instanceof Error ? e.message : String(e));
2138
2205
  }
@@ -2214,10 +2281,14 @@ ${diffResult.diff}`;
2214
2281
  card = provider.cards[0];
2215
2282
  }
2216
2283
  if (!card) {
2217
- const available = provider.cards.map((c) => `${c.name} (${c.capabilities?.join(", ")})`).join("; ");
2218
- return errorResult(
2219
- `No capability "${input.capability}" found for provider. Available: ${available}`
2284
+ const available = provider.cards.map(
2285
+ (providerCard) => `${sanitizeField(providerCard.name ?? "", 64)} (${(providerCard.capabilities ?? []).map((capability) => sanitizeField(capability, 64)).join(", ")})`
2286
+ ).join("; ");
2287
+ const { text } = sanitizeUntrusted(
2288
+ `No capability "${input.capability}" found for provider. Available: ${available}`,
2289
+ "text"
2220
2290
  );
2291
+ return errorResult(text);
2221
2292
  }
2222
2293
  const price = card.payment?.job_price ?? 0;
2223
2294
  const cardAsset = assetFromCardPayment(card.payment);
@@ -2228,13 +2299,18 @@ ${diffResult.diff}`;
2228
2299
  }
2229
2300
  if (price > 0 && input.max_price_lamports === void 0) {
2230
2301
  const gasLine = await gasHintForCardAsset(agent, cardAsset);
2302
+ const safeProviderName = sanitizeField(provider.name || input.provider_npub, 64);
2303
+ const { text } = sanitizeUntrusted(
2304
+ `Capability "${input.capability}" from "${safeProviderName}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.${gasLine}
2305
+
2306
+ To confirm, call buy_capability again with max_price_lamports set (e.g. ${price} or higher).`,
2307
+ "text"
2308
+ );
2231
2309
  return {
2232
2310
  content: [
2233
2311
  {
2234
2312
  type: "text",
2235
- text: `Capability "${input.capability}" from "${provider.name || input.provider_npub}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.${gasLine}
2236
-
2237
- To confirm, call buy_capability again with max_price_lamports set (e.g. ${price} or higher).`
2313
+ text
2238
2314
  }
2239
2315
  ]
2240
2316
  };
@@ -2360,6 +2436,16 @@ ${result}${tip}`);
2360
2436
  }
2361
2437
  })
2362
2438
  ];
2439
+ var MAX_CAPABILITY_TAG_LEN = 64;
2440
+ function withTimeout(work, timeoutMs) {
2441
+ let timer;
2442
+ const timeout = new Promise((_resolve, reject) => {
2443
+ timer = setTimeout(() => {
2444
+ reject(new Error(`dashboard query timed out after ${timeoutMs}ms`));
2445
+ }, timeoutMs);
2446
+ });
2447
+ return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
2448
+ }
2363
2449
  var GetDashboardSchema = z.object({
2364
2450
  top_n: z.number().int().min(1).max(100).default(10),
2365
2451
  chain: z.enum(["solana"]).default("solana"),
@@ -2374,27 +2460,38 @@ var dashboardTools = [
2374
2460
  async handler(ctx, input) {
2375
2461
  const agent = ctx.active();
2376
2462
  const network = input.network ?? agent.network;
2377
- const agents = await agent.client.discovery.fetchAgents(network);
2463
+ let agents;
2464
+ try {
2465
+ agents = await withTimeout(
2466
+ agent.client.discovery.fetchAgents(network),
2467
+ input.timeout_secs * 1e3
2468
+ );
2469
+ } catch (e) {
2470
+ return textResult(e instanceof Error ? e.message : String(e));
2471
+ }
2378
2472
  const filtered = agents.filter(
2379
- (a) => a.cards.some((c) => (c.payment?.chain ?? "solana") === input.chain)
2473
+ (candidate) => candidate.cards.some((card) => (card.payment?.chain ?? "solana") === input.chain)
2380
2474
  );
2381
- const rows = filtered.map((a) => {
2382
- const mainCard = a.cards[0];
2475
+ const rows = filtered.map((candidate) => {
2476
+ const mainCard = candidate.cards[0];
2383
2477
  const mainAsset = assetFromCardPayment(mainCard?.payment);
2384
2478
  const mainPrice = mainCard?.payment?.job_price;
2479
+ const capabilities = (mainCard?.capabilities ?? []).map((capability) => sanitizeField(capability, MAX_CAPABILITY_TAG_LEN)).join(", ");
2385
2480
  return {
2386
- name: sanitizeField(a.name || mainCard?.name || "unknown", 30),
2387
- npub: a.npub,
2388
- capabilities: (mainCard?.capabilities ?? []).join(", "),
2481
+ name: sanitizeField(candidate.name || mainCard?.name || "unknown", 30),
2482
+ npub: candidate.npub,
2483
+ capabilities,
2389
2484
  price: mainPrice ? formatAssetAmount(mainAsset, BigInt(mainPrice)) : "free",
2390
- cards_count: a.cards.length
2485
+ cards_count: candidate.cards.length
2391
2486
  };
2392
2487
  }).slice(0, input.top_n);
2393
2488
  if (rows.length === 0) {
2394
2489
  return textResult(`No agents found on ${network} (${input.chain}).`);
2395
2490
  }
2396
2491
  const header = `elisym Network Dashboard (${network}, ${input.chain})`;
2397
- const table = rows.map((r, i) => `${i + 1}. ${r.name} | ${r.capabilities} | ${r.price} | ${r.npub}`).join("\n");
2492
+ const table = rows.map(
2493
+ (row, index) => `${index + 1}. ${row.name} | ${row.capabilities} | ${row.price} | ${row.npub}`
2494
+ ).join("\n");
2398
2495
  const { text } = sanitizeUntrusted(table, "structured");
2399
2496
  return textResult(`${header}
2400
2497
  ${"=".repeat(header.length)}
@@ -2971,7 +3068,8 @@ var feedbackContactsTools = [
2971
3068
  contact.name ? ` name: ${contact.name}` : null,
2972
3069
  contact.lastCapability ? ` last capability: ${contact.lastCapability}` : null
2973
3070
  ].filter((line) => line !== null);
2974
- return textResult(lines.join("\n"));
3071
+ const { text } = sanitizeUntrusted(lines.join("\n"), "text");
3072
+ return textResult(text);
2975
3073
  }
2976
3074
  }),
2977
3075
  defineTool({
@@ -3044,18 +3142,26 @@ var policiesTools = [
3044
3142
  }
3045
3143
  const agent = ctx.active();
3046
3144
  const policies = await agent.client.policies.fetchPolicies(pubkey);
3047
- const limited = policies.map((policy) => ({
3048
- type: policy.type,
3049
- version: policy.version,
3050
- title: sanitizeField(policy.title, LIMITS.MAX_POLICY_TITLE_LENGTH),
3051
- summary: policy.summary ? sanitizeField(policy.summary, LIMITS.MAX_POLICY_SUMMARY_LENGTH) : void 0,
3052
- content: sanitizeInner(policy.content),
3053
- naddr: policy.naddr,
3054
- published_at: policy.publishedAt
3055
- }));
3145
+ let freetextSuspicious = false;
3146
+ const limited = policies.map((policy) => {
3147
+ const cleanedContent = sanitizeInner(policy.content);
3148
+ if (scanForInjections(cleanedContent, "full") || scanForInjections(policy.title ?? "", "full") || (policy.summary ? scanForInjections(policy.summary, "full") : false)) {
3149
+ freetextSuspicious = true;
3150
+ }
3151
+ return {
3152
+ type: policy.type,
3153
+ version: policy.version,
3154
+ title: sanitizeField(policy.title, LIMITS.MAX_POLICY_TITLE_LENGTH),
3155
+ summary: policy.summary ? sanitizeField(policy.summary, LIMITS.MAX_POLICY_SUMMARY_LENGTH) : void 0,
3156
+ content: cleanedContent,
3157
+ naddr: policy.naddr,
3158
+ published_at: policy.publishedAt
3159
+ };
3160
+ });
3056
3161
  const { text } = sanitizeUntrusted(
3057
3162
  JSON.stringify({ count: limited.length, policies: limited }, null, 2),
3058
- "structured"
3163
+ "structured",
3164
+ { extraInjectionSignal: freetextSuspicious }
3059
3165
  );
3060
3166
  return textResult(text);
3061
3167
  }
@@ -3158,7 +3264,6 @@ var walletTools = [
3158
3264
  const rpc = rpcFor(agent);
3159
3265
  const walletAddress = address(agent.solanaKeypair.publicKey);
3160
3266
  const { value: balanceLamports } = await rpc.getBalance(walletAddress).send();
3161
- const balance = Number(balanceLamports);
3162
3267
  const usdcBalanceRaw = await fetchUsdcBalance(rpc, walletAddress);
3163
3268
  const usdcLine = `USDC balance: ${formatAssetAmount(USDC_SOLANA_DEVNET, usdcBalanceRaw)}`;
3164
3269
  const sessionLines = formatSessionSpendLines(ctx);
@@ -3167,7 +3272,7 @@ ${sessionLines.join("\n")}` : "";
3167
3272
  return textResult(
3168
3273
  `Address: ${agent.solanaKeypair.publicKey}
3169
3274
  Network: ${agent.network}
3170
- Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3275
+ Balance: ${formatSol(balanceLamports)} (${balanceLamports.toString()} lamports)
3171
3276
  ` + usdcLine + sessionBlock
3172
3277
  );
3173
3278
  }
@@ -3266,7 +3371,17 @@ Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3266
3371
  releaseSpend(ctx, sendAsset, sendAmount);
3267
3372
  throw e;
3268
3373
  }
3269
- const { value: balanceLamports } = await rpc.getBalance(address(agent.solanaKeypair.publicKey)).send();
3374
+ let remainingBalanceLine = "";
3375
+ try {
3376
+ const { value: balanceLamports } = await rpc.getBalance(address(agent.solanaKeypair.publicKey)).send();
3377
+ remainingBalanceLine = ` Remaining SOL balance: ${formatSol(balanceLamports)}
3378
+ `;
3379
+ } catch (e) {
3380
+ logger.warn(
3381
+ { event: "post_payment_balance_fetch_failed", agent: agent.name },
3382
+ `Payment succeeded but the post-confirmation balance fetch failed: ${e instanceof Error ? e.message : String(e)}`
3383
+ );
3384
+ }
3270
3385
  const warnings = takeSpendWarnings(ctx, sendAsset);
3271
3386
  for (const line of warnings) {
3272
3387
  logger.warn({ event: "session_spend_threshold", agent: agent.name }, line);
@@ -3279,8 +3394,7 @@ Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3279
3394
  Signature: ${signature}
3280
3395
  Amount: ${formatAssetAmount(paidAsset, BigInt(requestData.amount))}
3281
3396
  Recipient: ${requestData.recipient}
3282
- Remaining SOL balance: ${formatSol(balanceLamports)}
3283
- Explorer: ${explorerUrl(agent, signature)}`
3397
+ ` + remainingBalanceLine + ` Explorer: ${explorerUrl(agent, signature)}`
3284
3398
  );
3285
3399
  }
3286
3400
  }),
@@ -3586,6 +3700,9 @@ if (toolMap.size !== allTools.length) {
3586
3700
  `Tool registry invariant violated: ${allTools.length} tools registered, ${toolMap.size} unique names`
3587
3701
  );
3588
3702
  }
3703
+ function redactSecrets(text) {
3704
+ return text.replace(/\bnsec1[02-9ac-hj-np-z]{20,}\b/gi, "[REDACTED]").replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]").replace(/\b[0-9a-fA-F]{64}\b/g, "[REDACTED]").replace(/\b[1-9A-HJ-NP-Za-km-z]{80,}\b/g, "[REDACTED]");
3705
+ }
3589
3706
  function safeError(context, e) {
3590
3707
  const message = e instanceof Error ? e.message : String(e);
3591
3708
  const stack = e instanceof Error ? e.stack : void 0;
@@ -3603,7 +3720,10 @@ function safeError(context, e) {
3603
3720
  msg = String(e).slice(0, 300);
3604
3721
  }
3605
3722
  return {
3606
- content: [{ type: "text", text: msg }],
3723
+ // pino redact does not cover error-message string contents, so scrub key/secret
3724
+ // shapes from the LLM-facing message itself (e.g. a JSON parse error that echoes
3725
+ // a secrets file, or an RPC error embedding a key).
3726
+ content: [{ type: "text", text: redactSecrets(msg) }],
3607
3727
  isError: true
3608
3728
  };
3609
3729
  }
@@ -3697,7 +3817,6 @@ async function startServer(ctx) {
3697
3817
  }
3698
3818
  const rpc = createSolanaRpc(rpcUrlFor(agent.network));
3699
3819
  const { value: balanceLamports } = await rpc.getBalance(address(agent.solanaKeypair.publicKey)).send();
3700
- const balance = Number(balanceLamports);
3701
3820
  return {
3702
3821
  contents: [
3703
3822
  {
@@ -3707,8 +3826,8 @@ async function startServer(ctx) {
3707
3826
  {
3708
3827
  address: agent.solanaKeypair.publicKey,
3709
3828
  network: agent.network,
3710
- balance_lamports: balance,
3711
- balance_sol: formatSolNumeric(BigInt(balance)),
3829
+ balance_lamports: balanceLamports.toString(),
3830
+ balance_sol: formatSolNumeric(balanceLamports),
3712
3831
  chain: "solana"
3713
3832
  },
3714
3833
  null,
@@ -3985,11 +4104,8 @@ function resolveAssetOrThrow(chain, token, mint) {
3985
4104
  }
3986
4105
  async function setSessionLimit(amount, chain, token, mint) {
3987
4106
  const asset = resolveAssetOrThrow(chain, token, mint);
3988
- parseAssetAmount(asset, amount);
3989
- const parsedAmount = Number(amount);
3990
- if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
3991
- throw new Error(`amount "${amount}" must be a positive decimal`);
3992
- }
4107
+ const trimmedAmount = amount.trim();
4108
+ parseAssetAmount(asset, trimmedAmount);
3993
4109
  const path = globalConfigPath();
3994
4110
  const cfg = await loadGlobalConfig(path);
3995
4111
  const entries = cfg.session_spend_limits ? [...cfg.session_spend_limits] : [];
@@ -4001,7 +4117,7 @@ async function setSessionLimit(amount, chain, token, mint) {
4001
4117
  chain: asset.chain,
4002
4118
  token: asset.token,
4003
4119
  mint: asset.mint,
4004
- amount: parsedAmount
4120
+ amount: trimmedAmount
4005
4121
  };
4006
4122
  if (idx >= 0) {
4007
4123
  entries[idx] = newEntry;
@@ -4010,7 +4126,7 @@ async function setSessionLimit(amount, chain, token, mint) {
4010
4126
  }
4011
4127
  await writeGlobalConfig(path, { session_spend_limits: entries });
4012
4128
  console.log(
4013
- `Session spend limit set to ${amount} ${asset.symbol} (process-wide). Restart the MCP server to apply.`
4129
+ `Session spend limit set to ${trimmedAmount} ${asset.symbol} (process-wide). Restart the MCP server to apply.`
4014
4130
  );
4015
4131
  }
4016
4132
  async function clearSessionLimit(chain, token, mint, all) {