@elisym/mcp 0.12.1 → 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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, formatAssetAmount, estimateNetworkBaseline, formatSol as formatSol$1, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, formatNetworkBaseline, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, KNOWN_ASSETS } from '@elisym/sdk';
2
+ import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, JobWaitTimeoutError, formatAssetAmount, estimateNetworkBaseline, formatSol as formatSol$1, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, formatNetworkBaseline, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, KNOWN_ASSETS } from '@elisym/sdk';
3
3
  import { listAgents, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, resolveAgent, loadAgent, globalConfigPath, writeYaml, writeFileAtomic as writeFileAtomic$1 } from '@elisym/sdk/agent-store';
4
4
  import { loadGlobalConfig, writeGlobalConfig } from '@elisym/sdk/node';
5
5
  import { getBase58Encoder, getBase58Decoder, generateKeyPairSigner, createSolanaRpc, address, createSolanaRpcSubscriptions, sendAndConfirmTransactionFactory, getSignatureFromTransaction, pipe, createTransactionMessage, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, signTransactionMessageWithSigners, createKeyPairSignerFromBytes, isAddress } from '@solana/kit';
@@ -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(),
@@ -1536,9 +1601,6 @@ function buildJobCompletionTip(jobId, providerNpub) {
1536
1601
 
1537
1602
  Tip: rate this provider with submit_feedback (job_event_id="${jobId}", rating="positive"|"negative"), or save them with add_contact (npub="${providerNpub}").`;
1538
1603
  }
1539
- function classifyJobFailure(message) {
1540
- return /timed out/i.test(message) ? "timeout" : "failed";
1541
- }
1542
1604
  function pendingJobResult(jobId, paymentSig, submittedAt, warningBlock) {
1543
1605
  const elapsedSecs = Math.round((Date.now() - submittedAt) / 1e3);
1544
1606
  return textResult(
@@ -1803,6 +1865,9 @@ ${sanitized.text}`);
1803
1865
  onFeedback: payHandler.onFeedback,
1804
1866
  onError(error) {
1805
1867
  reject(new Error(`Job error: ${error}`));
1868
+ },
1869
+ onTimeout(timeoutMs) {
1870
+ reject(new JobWaitTimeoutError(timeoutMs));
1806
1871
  }
1807
1872
  },
1808
1873
  timeoutMs: params.timeoutMs,
@@ -1831,8 +1896,9 @@ ${sanitized.text}`);
1831
1896
  ${result}${tip}`);
1832
1897
  } catch (e) {
1833
1898
  const msg = e instanceof Error ? e.message : String(e);
1834
- const failure = classifyJobFailure(msg);
1835
- const pending = failure === "timeout" && paymentSig !== void 0;
1899
+ const isTimeout = e instanceof JobWaitTimeoutError;
1900
+ const failure = isTimeout ? "timeout" : "failed";
1901
+ const pending = isTimeout && paymentSig !== void 0;
1836
1902
  await recordJobOutcome(agent, {
1837
1903
  jobEventId: jobId,
1838
1904
  capability: params.dTag,
@@ -1893,10 +1959,7 @@ function awaitJobResult(agent, options, fn, safetyTimeoutMs) {
1893
1959
  const resolvedOptions = fn({ resolve: safeResolve, reject: safeReject });
1894
1960
  closeFn = agent.client.marketplace.subscribeToJobUpdates(resolvedOptions);
1895
1961
  if (safetyTimeoutMs) {
1896
- safetyTimer = setTimeout(
1897
- () => safeReject(new Error("Subscription timed out (safety fallback).")),
1898
- safetyTimeoutMs
1899
- );
1962
+ safetyTimer = setTimeout(() => safeReject(new JobWaitTimeoutError()), safetyTimeoutMs);
1900
1963
  }
1901
1964
  });
1902
1965
  }
@@ -1967,6 +2030,9 @@ var customerTools = [
1967
2030
  },
1968
2031
  onError(error) {
1969
2032
  reject(new Error(`Job error: ${error}`));
2033
+ },
2034
+ onTimeout(timeoutMs) {
2035
+ reject(new JobWaitTimeoutError(timeoutMs));
1970
2036
  }
1971
2037
  },
1972
2038
  timeoutMs: timeout,
@@ -1978,7 +2044,7 @@ var customerTools = [
1978
2044
  );
1979
2045
  } catch (e) {
1980
2046
  const msg = e instanceof Error ? e.message : String(e);
1981
- if (classifyJobFailure(msg) === "timeout") {
2047
+ if (e instanceof JobWaitTimeoutError) {
1982
2048
  return textResult(
1983
2049
  `event_id="${input.job_event_id}": result not ready yet (nothing within ${timeout / 1e3}s). This is NOT an error - the provider may still be working. Retry get_job_result later (optionally widen lookback_secs); results persist on the relays.`
1984
2050
  );
@@ -2131,7 +2197,9 @@ ${wrapped}`);
2131
2197
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2132
2198
  let payload;
2133
2199
  try {
2134
- payload = await readJobInputFile(input.input_path);
2200
+ payload = await readJobInputFile(input.input_path, {
2201
+ allowOutsideCwd: input.allow_outside_cwd
2202
+ });
2135
2203
  } catch (e) {
2136
2204
  return errorResult(e instanceof Error ? e.message : String(e));
2137
2205
  }
@@ -2213,10 +2281,14 @@ ${diffResult.diff}`;
2213
2281
  card = provider.cards[0];
2214
2282
  }
2215
2283
  if (!card) {
2216
- const available = provider.cards.map((c) => `${c.name} (${c.capabilities?.join(", ")})`).join("; ");
2217
- return errorResult(
2218
- `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"
2219
2290
  );
2291
+ return errorResult(text);
2220
2292
  }
2221
2293
  const price = card.payment?.job_price ?? 0;
2222
2294
  const cardAsset = assetFromCardPayment(card.payment);
@@ -2227,13 +2299,18 @@ ${diffResult.diff}`;
2227
2299
  }
2228
2300
  if (price > 0 && input.max_price_lamports === void 0) {
2229
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
+ );
2230
2309
  return {
2231
2310
  content: [
2232
2311
  {
2233
2312
  type: "text",
2234
- text: `Capability "${input.capability}" from "${provider.name || input.provider_npub}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.${gasLine}
2235
-
2236
- To confirm, call buy_capability again with max_price_lamports set (e.g. ${price} or higher).`
2313
+ text
2237
2314
  }
2238
2315
  ]
2239
2316
  };
@@ -2302,6 +2379,9 @@ ${sanitized.text}`
2302
2379
  onFeedback: payHandler.onFeedback,
2303
2380
  onError(error) {
2304
2381
  reject(new Error(`Job error: ${error}`));
2382
+ },
2383
+ onTimeout(timeoutMs) {
2384
+ reject(new JobWaitTimeoutError(timeoutMs));
2305
2385
  }
2306
2386
  },
2307
2387
  timeoutMs: timeout,
@@ -2330,8 +2410,9 @@ ${sanitized.text}`
2330
2410
  ${result}${tip}`);
2331
2411
  } catch (e) {
2332
2412
  const msg = e instanceof Error ? e.message : String(e);
2333
- const failure = classifyJobFailure(msg);
2334
- const pending = failure === "timeout" && paymentSig !== void 0;
2413
+ const isTimeout = e instanceof JobWaitTimeoutError;
2414
+ const failure = isTimeout ? "timeout" : "failed";
2415
+ const pending = isTimeout && paymentSig !== void 0;
2335
2416
  await recordJobOutcome(agent, {
2336
2417
  jobEventId: jobId,
2337
2418
  capability: dTag,
@@ -2355,6 +2436,16 @@ ${result}${tip}`);
2355
2436
  }
2356
2437
  })
2357
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
+ }
2358
2449
  var GetDashboardSchema = z.object({
2359
2450
  top_n: z.number().int().min(1).max(100).default(10),
2360
2451
  chain: z.enum(["solana"]).default("solana"),
@@ -2369,27 +2460,38 @@ var dashboardTools = [
2369
2460
  async handler(ctx, input) {
2370
2461
  const agent = ctx.active();
2371
2462
  const network = input.network ?? agent.network;
2372
- 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
+ }
2373
2472
  const filtered = agents.filter(
2374
- (a) => a.cards.some((c) => (c.payment?.chain ?? "solana") === input.chain)
2473
+ (candidate) => candidate.cards.some((card) => (card.payment?.chain ?? "solana") === input.chain)
2375
2474
  );
2376
- const rows = filtered.map((a) => {
2377
- const mainCard = a.cards[0];
2475
+ const rows = filtered.map((candidate) => {
2476
+ const mainCard = candidate.cards[0];
2378
2477
  const mainAsset = assetFromCardPayment(mainCard?.payment);
2379
2478
  const mainPrice = mainCard?.payment?.job_price;
2479
+ const capabilities = (mainCard?.capabilities ?? []).map((capability) => sanitizeField(capability, MAX_CAPABILITY_TAG_LEN)).join(", ");
2380
2480
  return {
2381
- name: sanitizeField(a.name || mainCard?.name || "unknown", 30),
2382
- npub: a.npub,
2383
- capabilities: (mainCard?.capabilities ?? []).join(", "),
2481
+ name: sanitizeField(candidate.name || mainCard?.name || "unknown", 30),
2482
+ npub: candidate.npub,
2483
+ capabilities,
2384
2484
  price: mainPrice ? formatAssetAmount(mainAsset, BigInt(mainPrice)) : "free",
2385
- cards_count: a.cards.length
2485
+ cards_count: candidate.cards.length
2386
2486
  };
2387
2487
  }).slice(0, input.top_n);
2388
2488
  if (rows.length === 0) {
2389
2489
  return textResult(`No agents found on ${network} (${input.chain}).`);
2390
2490
  }
2391
2491
  const header = `elisym Network Dashboard (${network}, ${input.chain})`;
2392
- 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");
2393
2495
  const { text } = sanitizeUntrusted(table, "structured");
2394
2496
  return textResult(`${header}
2395
2497
  ${"=".repeat(header.length)}
@@ -2966,7 +3068,8 @@ var feedbackContactsTools = [
2966
3068
  contact.name ? ` name: ${contact.name}` : null,
2967
3069
  contact.lastCapability ? ` last capability: ${contact.lastCapability}` : null
2968
3070
  ].filter((line) => line !== null);
2969
- return textResult(lines.join("\n"));
3071
+ const { text } = sanitizeUntrusted(lines.join("\n"), "text");
3072
+ return textResult(text);
2970
3073
  }
2971
3074
  }),
2972
3075
  defineTool({
@@ -3039,18 +3142,26 @@ var policiesTools = [
3039
3142
  }
3040
3143
  const agent = ctx.active();
3041
3144
  const policies = await agent.client.policies.fetchPolicies(pubkey);
3042
- const limited = policies.map((policy) => ({
3043
- type: policy.type,
3044
- version: policy.version,
3045
- title: sanitizeField(policy.title, LIMITS.MAX_POLICY_TITLE_LENGTH),
3046
- summary: policy.summary ? sanitizeField(policy.summary, LIMITS.MAX_POLICY_SUMMARY_LENGTH) : void 0,
3047
- content: sanitizeInner(policy.content),
3048
- naddr: policy.naddr,
3049
- published_at: policy.publishedAt
3050
- }));
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
+ });
3051
3161
  const { text } = sanitizeUntrusted(
3052
3162
  JSON.stringify({ count: limited.length, policies: limited }, null, 2),
3053
- "structured"
3163
+ "structured",
3164
+ { extraInjectionSignal: freetextSuspicious }
3054
3165
  );
3055
3166
  return textResult(text);
3056
3167
  }
@@ -3153,7 +3264,6 @@ var walletTools = [
3153
3264
  const rpc = rpcFor(agent);
3154
3265
  const walletAddress = address(agent.solanaKeypair.publicKey);
3155
3266
  const { value: balanceLamports } = await rpc.getBalance(walletAddress).send();
3156
- const balance = Number(balanceLamports);
3157
3267
  const usdcBalanceRaw = await fetchUsdcBalance(rpc, walletAddress);
3158
3268
  const usdcLine = `USDC balance: ${formatAssetAmount(USDC_SOLANA_DEVNET, usdcBalanceRaw)}`;
3159
3269
  const sessionLines = formatSessionSpendLines(ctx);
@@ -3162,7 +3272,7 @@ ${sessionLines.join("\n")}` : "";
3162
3272
  return textResult(
3163
3273
  `Address: ${agent.solanaKeypair.publicKey}
3164
3274
  Network: ${agent.network}
3165
- Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3275
+ Balance: ${formatSol(balanceLamports)} (${balanceLamports.toString()} lamports)
3166
3276
  ` + usdcLine + sessionBlock
3167
3277
  );
3168
3278
  }
@@ -3261,7 +3371,17 @@ Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3261
3371
  releaseSpend(ctx, sendAsset, sendAmount);
3262
3372
  throw e;
3263
3373
  }
3264
- 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
+ }
3265
3385
  const warnings = takeSpendWarnings(ctx, sendAsset);
3266
3386
  for (const line of warnings) {
3267
3387
  logger.warn({ event: "session_spend_threshold", agent: agent.name }, line);
@@ -3274,8 +3394,7 @@ Balance: ${formatSol(BigInt(balance))} (${balance} lamports)
3274
3394
  Signature: ${signature}
3275
3395
  Amount: ${formatAssetAmount(paidAsset, BigInt(requestData.amount))}
3276
3396
  Recipient: ${requestData.recipient}
3277
- Remaining SOL balance: ${formatSol(balanceLamports)}
3278
- Explorer: ${explorerUrl(agent, signature)}`
3397
+ ` + remainingBalanceLine + ` Explorer: ${explorerUrl(agent, signature)}`
3279
3398
  );
3280
3399
  }
3281
3400
  }),
@@ -3581,6 +3700,9 @@ if (toolMap.size !== allTools.length) {
3581
3700
  `Tool registry invariant violated: ${allTools.length} tools registered, ${toolMap.size} unique names`
3582
3701
  );
3583
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
+ }
3584
3706
  function safeError(context, e) {
3585
3707
  const message = e instanceof Error ? e.message : String(e);
3586
3708
  const stack = e instanceof Error ? e.stack : void 0;
@@ -3598,7 +3720,10 @@ function safeError(context, e) {
3598
3720
  msg = String(e).slice(0, 300);
3599
3721
  }
3600
3722
  return {
3601
- 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) }],
3602
3727
  isError: true
3603
3728
  };
3604
3729
  }
@@ -3692,7 +3817,6 @@ async function startServer(ctx) {
3692
3817
  }
3693
3818
  const rpc = createSolanaRpc(rpcUrlFor(agent.network));
3694
3819
  const { value: balanceLamports } = await rpc.getBalance(address(agent.solanaKeypair.publicKey)).send();
3695
- const balance = Number(balanceLamports);
3696
3820
  return {
3697
3821
  contents: [
3698
3822
  {
@@ -3702,8 +3826,8 @@ async function startServer(ctx) {
3702
3826
  {
3703
3827
  address: agent.solanaKeypair.publicKey,
3704
3828
  network: agent.network,
3705
- balance_lamports: balance,
3706
- balance_sol: formatSolNumeric(BigInt(balance)),
3829
+ balance_lamports: balanceLamports.toString(),
3830
+ balance_sol: formatSolNumeric(balanceLamports),
3707
3831
  chain: "solana"
3708
3832
  },
3709
3833
  null,
@@ -3980,11 +4104,8 @@ function resolveAssetOrThrow(chain, token, mint) {
3980
4104
  }
3981
4105
  async function setSessionLimit(amount, chain, token, mint) {
3982
4106
  const asset = resolveAssetOrThrow(chain, token, mint);
3983
- parseAssetAmount(asset, amount);
3984
- const parsedAmount = Number(amount);
3985
- if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
3986
- throw new Error(`amount "${amount}" must be a positive decimal`);
3987
- }
4107
+ const trimmedAmount = amount.trim();
4108
+ parseAssetAmount(asset, trimmedAmount);
3988
4109
  const path = globalConfigPath();
3989
4110
  const cfg = await loadGlobalConfig(path);
3990
4111
  const entries = cfg.session_spend_limits ? [...cfg.session_spend_limits] : [];
@@ -3996,7 +4117,7 @@ async function setSessionLimit(amount, chain, token, mint) {
3996
4117
  chain: asset.chain,
3997
4118
  token: asset.token,
3998
4119
  mint: asset.mint,
3999
- amount: parsedAmount
4120
+ amount: trimmedAmount
4000
4121
  };
4001
4122
  if (idx >= 0) {
4002
4123
  entries[idx] = newEntry;
@@ -4005,7 +4126,7 @@ async function setSessionLimit(amount, chain, token, mint) {
4005
4126
  }
4006
4127
  await writeGlobalConfig(path, { session_spend_limits: entries });
4007
4128
  console.log(
4008
- `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.`
4009
4130
  );
4010
4131
  }
4011
4132
  async function clearSessionLimit(chain, token, mint, all) {