@node9/proxy 2.8.5 → 2.9.1

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
@@ -152,13 +152,21 @@ function filePathFromArgs(args) {
152
152
  return void 0;
153
153
  }
154
154
  function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
155
- const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
155
+ const isDlpRow = checkedBy.toLowerCase().includes("dlp") || checkedBy.toLowerCase().includes("pii") || Boolean(meta?.dlpPattern) || Boolean(meta?.piiPatterns) || Boolean(meta?.canaryId) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
156
156
  const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
157
157
  const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args), ...preview ? { argsPreview: preview } : {} } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
158
158
  const testRun = isTestCall(toolName, args) || process.env.NODE9_TESTING === "1" ? { testRun: true } : {};
159
159
  const ruleNameField = meta?.ruleName ? { ruleName: meta.ruleName } : {};
160
160
  const agentToolNameField = meta?.agentToolName ? { agentToolName: meta.agentToolName } : {};
161
161
  const dlpFields = meta?.dlpPattern ? { dlpPattern: meta.dlpPattern, dlpSample: meta.dlpSample } : {};
162
+ const canaryFields = meta?.canaryId ? {
163
+ canaryId: meta.canaryId,
164
+ ...meta.canaryHash && { canaryHash: meta.canaryHash },
165
+ ...meta.canaryKind && { canaryKind: meta.canaryKind },
166
+ ...meta.canaryPath && { canaryPath: meta.canaryPath },
167
+ ...meta.canaryView && { canaryView: meta.canaryView },
168
+ ...meta.canaryRetired && { canaryRetired: true }
169
+ } : {};
162
170
  const cloudLinkField = meta?.cloudRequestId ? { cloudRequestId: meta.cloudRequestId } : {};
163
171
  const workingDirField = meta?.workingDir ? { workingDir: meta.workingDir } : {};
164
172
  const shell = process.env.SHELL ? import_path.default.basename(process.env.SHELL) : void 0;
@@ -184,6 +192,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
184
192
  checkedBy,
185
193
  ...ruleNameField,
186
194
  ...dlpFields,
195
+ ...canaryFields,
187
196
  ...cloudLinkField,
188
197
  ...workingDirField,
189
198
  ...shellTypeField,
@@ -372,7 +381,11 @@ var ConfigFileSchema = import_zod.z.object({
372
381
  mode: import_zod.z.enum(["off", "review", "block"]).optional(),
373
382
  allow: import_zod.z.array(import_zod.z.string()).optional(),
374
383
  deny: import_zod.z.array(import_zod.z.string()).optional(),
375
- allowPrivate: import_zod.z.boolean().optional()
384
+ allowPrivate: import_zod.z.boolean().optional(),
385
+ // SSRF floor. `ssrfAllow` exempts OVERRIDABLE tiers only; a tier-1
386
+ // entry is dropped with a warning at load, never silently honoured.
387
+ ssrfAllow: import_zod.z.array(import_zod.z.string()).optional(),
388
+ ssrfStrict: import_zod.z.boolean().optional()
376
389
  }).optional(),
377
390
  loopDetection: import_zod.z.object({
378
391
  enabled: import_zod.z.boolean().optional(),
@@ -409,8 +422,8 @@ function sanitizeConfig(raw) {
409
422
  }
410
423
  }
411
424
  const lines = result.error.issues.map((issue) => {
412
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
413
- return ` \u2022 ${path14}: ${issue.message}`;
425
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
426
+ return ` \u2022 ${path15}: ${issue.message}`;
414
427
  });
415
428
  return {
416
429
  sanitized,
@@ -426,11 +439,178 @@ var import_os2 = __toESM(require("os"));
426
439
 
427
440
  // packages/policy-engine/dist/index.mjs
428
441
  var import_safe_regex2 = __toESM(require("safe-regex2"), 1);
442
+ var import_crypto3 = require("crypto");
429
443
  var import_mvdan_sh = __toESM(require("mvdan-sh"), 1);
430
444
  var import_picomatch = __toESM(require("picomatch"), 1);
431
445
  var import_safe_regex22 = __toESM(require("safe-regex2"), 1);
432
446
  var import_safe_regex23 = __toESM(require("safe-regex2"), 1);
433
- var import_crypto3 = __toESM(require("crypto"), 1);
447
+ var import_crypto4 = __toESM(require("crypto"), 1);
448
+ function validateLuhn(digits) {
449
+ if (!/^\d+$/.test(digits)) return false;
450
+ if (digits.length < 12) return false;
451
+ if (!/[1-9]/.test(digits)) return false;
452
+ let sum = 0;
453
+ let double = false;
454
+ for (let i = digits.length - 1; i >= 0; i--) {
455
+ let d = digits.charCodeAt(i) - 48;
456
+ if (double) {
457
+ d *= 2;
458
+ if (d > 9) d -= 9;
459
+ }
460
+ sum += d;
461
+ double = !double;
462
+ }
463
+ return sum % 10 === 0;
464
+ }
465
+ var IBAN_LENGTH = {
466
+ AD: 24,
467
+ AE: 23,
468
+ AL: 28,
469
+ AT: 20,
470
+ AZ: 28,
471
+ BA: 20,
472
+ BE: 16,
473
+ BG: 22,
474
+ BH: 22,
475
+ BI: 27,
476
+ BR: 29,
477
+ BY: 28,
478
+ CH: 21,
479
+ CR: 22,
480
+ CY: 28,
481
+ CZ: 24,
482
+ DE: 22,
483
+ DJ: 27,
484
+ DK: 18,
485
+ DO: 28,
486
+ EE: 20,
487
+ EG: 29,
488
+ ES: 24,
489
+ FI: 18,
490
+ FK: 18,
491
+ FO: 18,
492
+ FR: 27,
493
+ GB: 22,
494
+ GE: 22,
495
+ GI: 23,
496
+ GL: 18,
497
+ GR: 27,
498
+ GT: 28,
499
+ HN: 28,
500
+ HR: 21,
501
+ HU: 28,
502
+ IE: 22,
503
+ IL: 23,
504
+ IQ: 23,
505
+ IS: 26,
506
+ IT: 27,
507
+ JO: 30,
508
+ KW: 30,
509
+ KZ: 20,
510
+ LB: 28,
511
+ LC: 32,
512
+ LI: 21,
513
+ LT: 20,
514
+ LU: 20,
515
+ LV: 21,
516
+ LY: 25,
517
+ MC: 27,
518
+ MD: 24,
519
+ ME: 22,
520
+ MK: 19,
521
+ MN: 20,
522
+ MR: 27,
523
+ MT: 31,
524
+ MU: 30,
525
+ NI: 28,
526
+ NL: 18,
527
+ NO: 15,
528
+ OM: 23,
529
+ PK: 24,
530
+ PL: 28,
531
+ PS: 29,
532
+ PT: 25,
533
+ QA: 29,
534
+ RO: 24,
535
+ RS: 22,
536
+ RU: 33,
537
+ SA: 24,
538
+ SC: 31,
539
+ SD: 18,
540
+ SE: 24,
541
+ SI: 19,
542
+ SK: 24,
543
+ SM: 27,
544
+ SN: 28,
545
+ SO: 23,
546
+ ST: 25,
547
+ SV: 28,
548
+ TL: 23,
549
+ TN: 24,
550
+ TR: 26,
551
+ UA: 29,
552
+ VA: 22,
553
+ VG: 24,
554
+ XK: 20,
555
+ YE: 30
556
+ };
557
+ function validateIban(raw) {
558
+ const s = raw.replace(/[ -]/g, "").toUpperCase();
559
+ if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
560
+ const want = IBAN_LENGTH[s.slice(0, 2)];
561
+ if (want === void 0 || s.length < want) return false;
562
+ const iban = s.slice(0, want);
563
+ if (!/^[A-Z0-9]+$/.test(iban)) return false;
564
+ const rearranged = iban.slice(4) + iban.slice(0, 4);
565
+ let mod = 0;
566
+ for (const ch of rearranged) {
567
+ const code = ch.charCodeAt(0);
568
+ const digits = code >= 65 ? String(code - 55) : ch;
569
+ for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
570
+ }
571
+ return mod === 1;
572
+ }
573
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
574
+ var B58_INDEX = Object.fromEntries(
575
+ [...B58].map((c, i) => [c, i])
576
+ );
577
+ function validateBase58Check(s) {
578
+ if (!s) return null;
579
+ let n = 0n;
580
+ for (const c of s) {
581
+ const v = B58_INDEX[c];
582
+ if (v === void 0) return null;
583
+ n = n * 58n + BigInt(v);
584
+ }
585
+ let hex = n.toString(16);
586
+ if (hex.length % 2) hex = "0" + hex;
587
+ let zeros = 0;
588
+ for (const c of s) {
589
+ if (c !== "1") break;
590
+ zeros++;
591
+ }
592
+ const bytes = Buffer.concat([
593
+ Buffer.alloc(zeros),
594
+ n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
595
+ ]);
596
+ if (bytes.length < 5) return null;
597
+ const body = bytes.subarray(0, bytes.length - 4);
598
+ const check = bytes.subarray(bytes.length - 4);
599
+ const h = (0, import_crypto3.createHash)("sha256").update((0, import_crypto3.createHash)("sha256").update(body).digest()).digest();
600
+ return h.subarray(0, 4).equals(check) ? body : null;
601
+ }
602
+ function validateWif(s) {
603
+ const p = validateBase58Check(s);
604
+ if (!p || p[0] !== 128) return false;
605
+ return p.length === 33 || p.length === 34 && p[33] === 1;
606
+ }
607
+ var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
608
+ function validateXprv(s) {
609
+ const p = validateBase58Check(s);
610
+ if (!p || p.length !== 78) return false;
611
+ const version = p.readUInt32BE(0);
612
+ return XPRV_VERSIONS.has(version) && p[45] === 0;
613
+ }
434
614
  var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
435
615
  function isAssignmentContext(text) {
436
616
  return ASSIGNMENT_CONTEXT_RE.test(text);
@@ -623,6 +803,27 @@ var DLP_PATTERNS = [
623
803
  severity: "block",
624
804
  keywords: ["sg."]
625
805
  },
806
+ // ── Cryptocurrency private keys (base58check-validated) ───────────────────
807
+ // Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
808
+ // matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
809
+ // Monero address). Lookbehind fails safe-regex2; \b is the house style
810
+ // (see the card regexes). Mainnet only, matching validateWif / validateXprv;
811
+ // testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
812
+ // runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
813
+ // of prose, so no prefilter is warranted.
814
+ {
815
+ name: "Bitcoin WIF Private Key",
816
+ regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
817
+ severity: "block",
818
+ validate: validateWif
819
+ },
820
+ {
821
+ name: "Extended Private Key",
822
+ regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
823
+ severity: "block",
824
+ keywords: ["xprv", "yprv", "zprv"],
825
+ validate: validateXprv
826
+ },
626
827
  // ── Private keys (PEM) ────────────────────────────────────────────────────
627
828
  {
628
829
  name: "Private Key (PEM)",
@@ -979,6 +1180,33 @@ function maskSecret(raw, pattern) {
979
1180
  var MAX_DEPTH = 5;
980
1181
  var MAX_STRING_BYTES = 1e5;
981
1182
  var MAX_JSON_PARSE_BYTES = 1e4;
1183
+ function suppressed(pattern, raw) {
1184
+ if (pattern.validate) {
1185
+ let ok;
1186
+ try {
1187
+ ok = pattern.validate(raw);
1188
+ } catch {
1189
+ ok = true;
1190
+ }
1191
+ return !ok;
1192
+ }
1193
+ if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
1194
+ if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
1195
+ return false;
1196
+ }
1197
+ function firstAcceptedMatch(pattern, text) {
1198
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
1199
+ const re = new RegExp(pattern.regex.source, flags);
1200
+ let m;
1201
+ while ((m = re.exec(text)) !== null) {
1202
+ if (m[0].length === 0) {
1203
+ re.lastIndex = m.index + 1;
1204
+ continue;
1205
+ }
1206
+ if (!suppressed(pattern, m[0])) return m[0];
1207
+ }
1208
+ return null;
1209
+ }
982
1210
  function scanArgs(args, depth = 0, fieldPath = "args") {
983
1211
  if (depth > MAX_DEPTH || args === null || args === void 0) return null;
984
1212
  if (Array.isArray(args)) {
@@ -1003,18 +1231,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
1003
1231
  if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
1004
1232
  continue;
1005
1233
  }
1006
- if (pattern.regex.test(text)) {
1007
- const raw = text.match(pattern.regex)?.[0] ?? "";
1008
- if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
1009
- if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
1010
- const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
1011
- return {
1012
- patternName: pattern.name,
1013
- fieldPath,
1014
- redactedSample: maskSecret(text, pattern.regex),
1015
- severity
1016
- };
1017
- }
1234
+ const raw = firstAcceptedMatch(pattern, text);
1235
+ if (raw === null) continue;
1236
+ const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
1237
+ return {
1238
+ patternName: pattern.name,
1239
+ fieldPath,
1240
+ // Mask the ACCEPTED token, not the first regex hit in the field.
1241
+ redactedSample: maskSecret(raw, pattern.regex),
1242
+ severity
1243
+ };
1018
1244
  }
1019
1245
  if (text.length < MAX_JSON_PARSE_BYTES) {
1020
1246
  const trimmed = text.trim();
@@ -1078,13 +1304,13 @@ function matchesPattern(text, patterns) {
1078
1304
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1079
1305
  }
1080
1306
  var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1081
- function getNestedValue(obj, path14) {
1307
+ function getNestedValue(obj, path15) {
1082
1308
  if (!obj || typeof obj !== "object") return null;
1083
- const segments = path14.split(".");
1084
- for (const seg of segments) {
1309
+ const segments2 = path15.split(".");
1310
+ for (const seg of segments2) {
1085
1311
  if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
1086
1312
  }
1087
- return segments.reduce((prev, curr) => prev?.[curr], obj);
1313
+ return segments2.reduce((prev, curr) => prev?.[curr], obj);
1088
1314
  }
1089
1315
  function evaluateSmartConditions(args, rule) {
1090
1316
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -2052,6 +2278,50 @@ function extractShellDestinations(command) {
2052
2278
  }
2053
2279
  return out;
2054
2280
  }
2281
+ function extractShellDestTokens(command) {
2282
+ const f = parseShared(command);
2283
+ if (f === PARSE_FAIL) return [];
2284
+ const out = [];
2285
+ const seen = /* @__PURE__ */ new Set();
2286
+ try {
2287
+ syntax.Walk(f, (node) => {
2288
+ if (!node) return false;
2289
+ const n = node;
2290
+ if (syntax.NodeType(n) !== "CallExpr") return true;
2291
+ const callArgs = n.Args || [];
2292
+ if (callArgs.length === 0) return true;
2293
+ const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
2294
+ if (!NET_BINARIES.has(name)) return true;
2295
+ const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
2296
+ for (const raw of destTokensForBinary(name, rest)) {
2297
+ if (!raw) continue;
2298
+ let tok = raw.trim();
2299
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
2300
+ const hasScheme = scheme !== null;
2301
+ if (hasScheme) tok = tok.slice(scheme[0].length);
2302
+ tok = tok.split(/[/?#]/)[0];
2303
+ const at = tok.lastIndexOf("@");
2304
+ if (at >= 0) tok = tok.slice(at + 1);
2305
+ if (tok.startsWith("[")) {
2306
+ const close = tok.indexOf("]");
2307
+ if (close > 0) tok = tok.slice(0, close + 1);
2308
+ } else {
2309
+ tok = tok.split(":")[0];
2310
+ }
2311
+ if (!tok) continue;
2312
+ if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
2313
+ const key = `${name}:${tok}`;
2314
+ if (seen.has(key)) continue;
2315
+ seen.add(key);
2316
+ out.push({ token: tok, binary: name });
2317
+ }
2318
+ return true;
2319
+ });
2320
+ } catch {
2321
+ return out;
2322
+ }
2323
+ return out;
2324
+ }
2055
2325
  var FS_OP_CACHE_MAX = 5e3;
2056
2326
  var fsOpCache = /* @__PURE__ */ new Map();
2057
2327
  function analyzeFsOperation(command) {
@@ -2256,8 +2526,8 @@ function analyzeShellCommand(command) {
2256
2526
  if (allTokens.length === 0) {
2257
2527
  const normalized = command.replace(/\\(.)/g, "$1");
2258
2528
  const sanitized = normalized.replace(/["'<>]/g, " ");
2259
- const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
2260
- segments.forEach((segment) => {
2529
+ const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
2530
+ segments2.forEach((segment) => {
2261
2531
  const tokens = segment.trim().split(/\s+/).filter(Boolean);
2262
2532
  if (tokens.length > 0) {
2263
2533
  const action = tokens[0].toLowerCase();
@@ -2420,7 +2690,7 @@ function isSensitivePath(p) {
2420
2690
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2421
2691
  }
2422
2692
  function splitOnPipe(cmd) {
2423
- const segments = [];
2693
+ const segments2 = [];
2424
2694
  let current = "";
2425
2695
  let inSingle = false;
2426
2696
  let inDouble = false;
@@ -2433,21 +2703,21 @@ function splitOnPipe(cmd) {
2433
2703
  inDouble = !inDouble;
2434
2704
  current += ch;
2435
2705
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2436
- segments.push(current.trim());
2706
+ segments2.push(current.trim());
2437
2707
  current = "";
2438
2708
  } else {
2439
2709
  current += ch;
2440
2710
  }
2441
2711
  }
2442
- if (current.trim()) segments.push(current.trim());
2443
- return segments.filter(Boolean);
2712
+ if (current.trim()) segments2.push(current.trim());
2713
+ return segments2.filter(Boolean);
2444
2714
  }
2445
2715
  function positionalTokens(segment) {
2446
2716
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2447
2717
  }
2448
2718
  function analyzePipeChain(command) {
2449
- const segments = splitOnPipe(command);
2450
- if (segments.length < 2) {
2719
+ const segments2 = splitOnPipe(command);
2720
+ if (segments2.length < 2) {
2451
2721
  return {
2452
2722
  isPipeline: false,
2453
2723
  hasSensitiveSource: false,
@@ -2463,7 +2733,7 @@ function analyzePipeChain(command) {
2463
2733
  let hasSensitiveSource = false;
2464
2734
  let hasExternalSink = false;
2465
2735
  let hasObfuscation = false;
2466
- for (const segment of segments) {
2736
+ for (const segment of segments2) {
2467
2737
  const tokens = segment.split(/\s+/).filter(Boolean);
2468
2738
  if (tokens.length === 0) continue;
2469
2739
  const binary = tokens[0].toLowerCase();
@@ -2501,8 +2771,8 @@ function analyzePipeChain(command) {
2501
2771
  };
2502
2772
  }
2503
2773
  function basename(p) {
2504
- const segments = p.split(/[\\/]/);
2505
- return segments[segments.length - 1] || "";
2774
+ const segments2 = p.split(/[\\/]/);
2775
+ return segments2[segments2.length - 1] || "";
2506
2776
  }
2507
2777
  var FLAGS_WITH_VALUES = {
2508
2778
  curl: /* @__PURE__ */ new Set([
@@ -2665,6 +2935,204 @@ function extractAllSshHosts(tokens) {
2665
2935
  }
2666
2936
  return [...hosts].filter(Boolean);
2667
2937
  }
2938
+ var SSRF_MAX_HOST = 253;
2939
+ function parseComponent(s) {
2940
+ if (!s) return null;
2941
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
2942
+ if (s === "0") return 0;
2943
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
2944
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
2945
+ return null;
2946
+ }
2947
+ function parseIpv4(input) {
2948
+ let s = input;
2949
+ if (s.endsWith(".")) s = s.slice(0, -1);
2950
+ if (!s) return null;
2951
+ const parts = s.split(".");
2952
+ if (parts.length > 4) return null;
2953
+ const vals = [];
2954
+ for (const p of parts) {
2955
+ const v = parseComponent(p);
2956
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
2957
+ vals.push(v);
2958
+ }
2959
+ const n = vals.length;
2960
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
2961
+ const last = vals[n - 1];
2962
+ const remainingBytes = 4 - (n - 1);
2963
+ const limit = Math.pow(256, remainingBytes);
2964
+ if (last >= limit) return null;
2965
+ let value = last;
2966
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
2967
+ if (value > 4294967295) return null;
2968
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
2969
+ }
2970
+ function expandIpv6(input) {
2971
+ const s = input.toLowerCase();
2972
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
2973
+ if ((s.match(/::/g) ?? []).length > 1) return null;
2974
+ let head = s;
2975
+ let tailV4 = null;
2976
+ const lastColon = s.lastIndexOf(":");
2977
+ const afterLast = s.slice(lastColon + 1);
2978
+ if (afterLast.includes(".")) {
2979
+ const dotted = parseIpv4(afterLast);
2980
+ if (!dotted) return null;
2981
+ const o = dotted.split(".").map(Number);
2982
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
2983
+ head = s.slice(0, lastColon + 1) + "0";
2984
+ }
2985
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
2986
+ const toGroups = (part) => {
2987
+ if (!part) return [];
2988
+ const out = [];
2989
+ for (const g of part.split(":")) {
2990
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
2991
+ out.push(parseInt(g, 16));
2992
+ }
2993
+ return out;
2994
+ };
2995
+ const left = toGroups(lhs);
2996
+ if (left === null) return null;
2997
+ let right = [];
2998
+ if (rhs !== null) {
2999
+ const r = toGroups(rhs);
3000
+ if (r === null) return null;
3001
+ right = r;
3002
+ }
3003
+ if (tailV4) {
3004
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3005
+ else left.splice(left.length - 1, 1, ...tailV4);
3006
+ }
3007
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3008
+ if (rhs === null && groups.length !== 8) return null;
3009
+ if (rhs !== null && left.length + right.length > 8) return null;
3010
+ if (groups.length !== 8) return null;
3011
+ return groups;
3012
+ }
3013
+ function compressIpv6(g) {
3014
+ let bestStart = -1;
3015
+ let bestLen = 0;
3016
+ let i = 0;
3017
+ while (i < 8) {
3018
+ if (g[i] !== 0) {
3019
+ i++;
3020
+ continue;
3021
+ }
3022
+ let j = i;
3023
+ while (j < 8 && g[j] === 0) j++;
3024
+ if (j - i > bestLen) {
3025
+ bestLen = j - i;
3026
+ bestStart = i;
3027
+ }
3028
+ i = j;
3029
+ }
3030
+ const hex = g.map((x) => x.toString(16));
3031
+ if (bestLen < 2) return hex.join(":");
3032
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3033
+ }
3034
+ function normalizeIpLiteral(host) {
3035
+ try {
3036
+ if (typeof host !== "string") return null;
3037
+ let s = host.trim();
3038
+ if (!s || s.length > SSRF_MAX_HOST) return null;
3039
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3040
+ const zone = s.indexOf("%");
3041
+ if (zone >= 0) s = s.slice(0, zone);
3042
+ if (!s) return null;
3043
+ if (s.includes(":")) {
3044
+ const g = expandIpv6(s);
3045
+ if (!g) return null;
3046
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3047
+ if (mapped) {
3048
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3049
+ }
3050
+ return compressIpv6(g);
3051
+ }
3052
+ return parseIpv4(s);
3053
+ } catch {
3054
+ return null;
3055
+ }
3056
+ }
3057
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3058
+ "169.254.169.254",
3059
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
3060
+ "169.254.170.2",
3061
+ // AWS ECS task role
3062
+ "168.63.129.16",
3063
+ // Azure WireServer
3064
+ "fd00:ec2::254"
3065
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3066
+ ]);
3067
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3068
+ var v4Octets = (a) => {
3069
+ const p = a.split(".");
3070
+ return p.length === 4 ? p.map(Number) : null;
3071
+ };
3072
+ function classifySsrf(host) {
3073
+ try {
3074
+ if (typeof host !== "string" || !host) return null;
3075
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
3076
+ const ip = normalizeIpLiteral(host);
3077
+ if (ip === null) {
3078
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3079
+ }
3080
+ const hit = (tier, overridable) => ({
3081
+ tier,
3082
+ overridable,
3083
+ kind: "address",
3084
+ normalized: ip
3085
+ });
3086
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3087
+ const o = v4Octets(ip);
3088
+ if (o) {
3089
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
3090
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3091
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3092
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3093
+ if (o[0] === 127) return hit("private", true);
3094
+ if (o[0] === 10) return hit("private", true);
3095
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
3096
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3097
+ return null;
3098
+ }
3099
+ const g = expandIpv6(ip);
3100
+ if (!g) return null;
3101
+ if (g.every((x) => x === 0)) return hit("unspecified", false);
3102
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
3103
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
3104
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3105
+ return null;
3106
+ } catch {
3107
+ return null;
3108
+ }
3109
+ }
3110
+ var TIER_REASON = {
3111
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3112
+ "link-local": "a link-local address",
3113
+ multicast: "a multicast address",
3114
+ unspecified: "the unspecified address",
3115
+ cgnat: "a carrier-grade NAT address",
3116
+ private: "a loopback or private address"
3117
+ };
3118
+ function ssrfFloor(tokens, opts = {}) {
3119
+ const exempt = new Set(
3120
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3121
+ );
3122
+ for (const { token, binary } of tokens) {
3123
+ const m = classifySsrf(token);
3124
+ if (!m) continue;
3125
+ if (m.tier === "private" && !opts.ssrfStrict) continue;
3126
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3127
+ return {
3128
+ ...m,
3129
+ host: token,
3130
+ binary,
3131
+ reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
3132
+ };
3133
+ }
3134
+ return null;
3135
+ }
2668
3136
  function resolveCheck(v) {
2669
3137
  return v === "off" || v === "block" ? v : "review";
2670
3138
  }
@@ -2897,6 +3365,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2897
3365
  }
2898
3366
  const builtin = strictestVerdict(candidates);
2899
3367
  if (builtin) return builtin;
3368
+ {
3369
+ const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
3370
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3371
+ ssrfStrict: config.policy.egress?.ssrfStrict
3372
+ });
3373
+ if (ssrf) {
3374
+ return {
3375
+ decision: "block",
3376
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3377
+ reason: ssrf.reason,
3378
+ ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
3379
+ ruleDescription: ssrf.reason,
3380
+ tier: 3
3381
+ };
3382
+ }
3383
+ }
2900
3384
  if (config.policy.egress?.enabled) {
2901
3385
  const dests = extractShellDestinations(shellCommand);
2902
3386
  if (dests.length > 0) {
@@ -3824,7 +4308,7 @@ assertBuiltinShieldRegexesAreSafe();
3824
4308
  var LOOP_MAX_RECORDS = 500;
3825
4309
  function computeArgsHash(args) {
3826
4310
  const str = JSON.stringify(args ?? "");
3827
- return import_crypto3.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
4311
+ return import_crypto4.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
3828
4312
  }
3829
4313
  function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3830
4314
  const hash = computeArgsHash(args);
@@ -3838,28 +4322,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3838
4322
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3839
4323
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3840
4324
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3841
- var PII_CC_RE = /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
4325
+ var PII_CC16_RE = /\b(?:4\d{3}|5[1-5]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
4326
+ var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
4327
+ var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
4328
+ function hasValidCard(text) {
4329
+ for (const base of [PII_CC16_RE, PII_CC15_RE]) {
4330
+ const re = new RegExp(base.source, "g");
4331
+ let m;
4332
+ while ((m = re.exec(text)) !== null) {
4333
+ if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
4334
+ re.lastIndex = m.index + 1;
4335
+ }
4336
+ }
4337
+ return false;
4338
+ }
4339
+ function hasValidIban(text) {
4340
+ const re = new RegExp(PII_IBAN_RE.source, "g");
4341
+ let m;
4342
+ while ((m = re.exec(text)) !== null) {
4343
+ if (validateIban(m[0])) return true;
4344
+ re.lastIndex = m.index + 1;
4345
+ }
4346
+ return false;
4347
+ }
3842
4348
  function detectPii(text) {
3843
4349
  const found = /* @__PURE__ */ new Set();
3844
4350
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3845
4351
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3846
4352
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3847
- if (PII_CC_RE.test(text)) found.add("Credit Card");
4353
+ if (hasValidCard(text)) found.add("Credit Card");
4354
+ if (hasValidIban(text)) found.add("IBAN");
3848
4355
  return [...found];
3849
4356
  }
3850
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4357
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3851
4358
  var MAX_PII_SCAN_BYTES = 1e5;
4359
+ function* stringLeaves(v, depth = 0) {
4360
+ if (depth > 6) return;
4361
+ if (typeof v === "string") {
4362
+ if (v.length > 0) yield v;
4363
+ return;
4364
+ }
4365
+ if (typeof v === "number") {
4366
+ if (Number.isFinite(v)) yield String(v);
4367
+ return;
4368
+ }
4369
+ if (!v || typeof v !== "object") return;
4370
+ if (Array.isArray(v)) {
4371
+ for (const x of v) yield* stringLeaves(x, depth + 1);
4372
+ return;
4373
+ }
4374
+ for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
4375
+ }
3852
4376
  function detectArgsPii(args) {
3853
4377
  if (args === null || args === void 0) return [];
3854
- let text;
4378
+ const found = /* @__PURE__ */ new Set();
4379
+ let budget = MAX_PII_SCAN_BYTES;
3855
4380
  try {
3856
- text = typeof args === "string" ? args : JSON.stringify(args);
4381
+ for (const leaf of stringLeaves(args)) {
4382
+ if (budget <= 0) break;
4383
+ const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
4384
+ budget -= t.length;
4385
+ for (const p of detectPii(t)) {
4386
+ if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
4387
+ }
4388
+ }
3857
4389
  } catch {
3858
4390
  return [];
3859
4391
  }
3860
- if (typeof text !== "string") return [];
3861
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3862
- return detectPii(text).filter((p) => REALTIME_PII_PATTERNS.includes(p));
4392
+ return [...found];
4393
+ }
4394
+ var CANARY_MIN_LENGTH = 16;
4395
+ var MAX_TEXT = 1e5;
4396
+ var MAX_DEPTH2 = 6;
4397
+ var MAX_JSON_PARSE = 1e4;
4398
+ var URL_DEPTH = 4;
4399
+ var B64_DEPTH = 3;
4400
+ var MIN_SEGMENT = 16;
4401
+ var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
4402
+ var stripSeparators = (s) => s.replace(SEPARATORS, "");
4403
+ function percentDecodeOnce(s) {
4404
+ try {
4405
+ return decodeURIComponent(s);
4406
+ } catch {
4407
+ return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
4408
+ }
4409
+ }
4410
+ function segments(s, alphabet) {
4411
+ const out = /* @__PURE__ */ new Set();
4412
+ if (alphabet.test(s)) out.add(s);
4413
+ for (const seg of s.split(/[?&\s"'<>]+/)) {
4414
+ if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
4415
+ for (const part of seg.split("=")) {
4416
+ if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
4417
+ }
4418
+ }
4419
+ return [...out];
4420
+ }
4421
+ var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
4422
+ var CANARY_DECODERS = {
4423
+ url: (s) => {
4424
+ const out = [];
4425
+ let cur = s;
4426
+ for (let i = 0; i < URL_DEPTH; i++) {
4427
+ const d = percentDecodeOnce(cur);
4428
+ if (d === cur) break;
4429
+ out.push(d);
4430
+ cur = d;
4431
+ }
4432
+ return out;
4433
+ },
4434
+ base64: (s) => {
4435
+ const out = [];
4436
+ let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
4437
+ for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
4438
+ const next = [];
4439
+ for (const c of frontier) {
4440
+ const d = Buffer.from(c, "base64").toString("utf8");
4441
+ if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
4442
+ out.push(d);
4443
+ next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
4444
+ }
4445
+ frontier = next;
4446
+ }
4447
+ return out;
4448
+ },
4449
+ hex: (s) => {
4450
+ const out = [];
4451
+ for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
4452
+ if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
4453
+ const d = Buffer.from(c, "hex").toString("utf8");
4454
+ if (looksText(d)) out.push(d);
4455
+ }
4456
+ return out;
4457
+ },
4458
+ separators: (s) => [stripSeparators(s)]
4459
+ };
4460
+ function lowestOffset(cands, needles, stripped) {
4461
+ let best = null;
4462
+ for (const c of cands) {
4463
+ for (const n of needles) {
4464
+ const off = c.indexOf(stripped ? n.stripped : n.raw);
4465
+ if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
4466
+ }
4467
+ }
4468
+ return best?.v ?? null;
4469
+ }
4470
+ var VIEWS = [
4471
+ { view: "url-decoded", decoder: "url", stripped: false },
4472
+ { view: "base64-decoded", decoder: "base64", stripped: false },
4473
+ { view: "hex-decoded", decoder: "hex", stripped: false },
4474
+ { view: "separators-stripped", decoder: "separators", stripped: true }
4475
+ ];
4476
+ function prepare(values) {
4477
+ const out = [];
4478
+ for (const v of values) {
4479
+ if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
4480
+ console.error(
4481
+ `[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
4482
+ );
4483
+ continue;
4484
+ }
4485
+ out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
4486
+ }
4487
+ return out;
4488
+ }
4489
+ function matchPrepared(text, needles) {
4490
+ if (!text || needles.length === 0) return null;
4491
+ const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
4492
+ const raw = lowestOffset([t], needles, false);
4493
+ if (raw) return { v: raw, view: "raw" };
4494
+ for (const { view, decoder, stripped } of VIEWS) {
4495
+ let cands;
4496
+ try {
4497
+ cands = CANARY_DECODERS[decoder](t);
4498
+ } catch (e) {
4499
+ console.error(
4500
+ `[node9 engine] canary view ${view} failed, skipped:`,
4501
+ e instanceof Error ? e.message : String(e)
4502
+ );
4503
+ continue;
4504
+ }
4505
+ const hit = lowestOffset(cands, needles, stripped);
4506
+ if (hit) return { v: hit, view };
4507
+ }
4508
+ return null;
4509
+ }
4510
+ function matchCanaryArgs(args, values) {
4511
+ if (values.length === 0) return null;
4512
+ const needles = prepare(values);
4513
+ if (needles.length === 0) return null;
4514
+ const walk = (v, depth, fieldPath) => {
4515
+ if (depth > MAX_DEPTH2) return null;
4516
+ if (typeof v === "string") {
4517
+ const hit = matchPrepared(v, needles);
4518
+ if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
4519
+ if (v.length < MAX_JSON_PARSE) {
4520
+ const trimmed = v.trim();
4521
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
4522
+ try {
4523
+ return walk(JSON.parse(v), depth + 1, fieldPath);
4524
+ } catch {
4525
+ }
4526
+ }
4527
+ }
4528
+ return null;
4529
+ }
4530
+ if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
4531
+ if (Array.isArray(v)) {
4532
+ for (let i = 0; i < v.length; i++) {
4533
+ const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
4534
+ if (h) return h;
4535
+ }
4536
+ return null;
4537
+ }
4538
+ if (v && typeof v === "object") {
4539
+ for (const [k, child] of Object.entries(v)) {
4540
+ const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
4541
+ if (h) return h;
4542
+ }
4543
+ }
4544
+ return null;
4545
+ };
4546
+ try {
4547
+ return walk(args, 0, "");
4548
+ } catch (e) {
4549
+ console.error(
4550
+ "[node9 engine] canary args walk failed:",
4551
+ e instanceof Error ? e.message : String(e)
4552
+ );
4553
+ return null;
4554
+ }
3863
4555
  }
3864
4556
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3865
4557
 
@@ -3989,6 +4681,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
3989
4681
  if (Array.isArray(managed.deny) && managed.deny.length > 0) {
3990
4682
  next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
3991
4683
  }
4684
+ if (typeof managed.ssrfStrict === "boolean") {
4685
+ next.ssrfStrict = managed.ssrfStrict;
4686
+ }
4687
+ if (Array.isArray(managed.ssrfAllow)) {
4688
+ next.ssrfAllow = [...managed.ssrfAllow];
4689
+ }
3992
4690
  if (typeof managed.allowPrivate === "boolean") {
3993
4691
  next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
3994
4692
  }
@@ -4070,9 +4768,9 @@ var B = "[\\s/\\\\]";
4070
4768
  var SEP = "[/\\\\]";
4071
4769
  function pathToRegexFragment(rawPath) {
4072
4770
  const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
4073
- const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4074
- if (segments.length === 0) return "";
4075
- return `(^|${B})${segments.join(SEP)}(${B}|$)`;
4771
+ const segments2 = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4772
+ if (segments2.length === 0) return "";
4773
+ return `(^|${B})${segments2.join(SEP)}(${B}|$)`;
4076
4774
  }
4077
4775
  function pathMatchesFragment(candidate, rawPath) {
4078
4776
  const value = pathToRegexFragment(rawPath);
@@ -4177,6 +4875,20 @@ function isTrustedHost(host) {
4177
4875
  }
4178
4876
 
4179
4877
  // src/config/index.ts
4878
+ function sanitizeSsrfAllow(entries, source) {
4879
+ const kept = [];
4880
+ for (const entry of entries) {
4881
+ const m = classifySsrf(entry);
4882
+ if (m && !m.overridable) {
4883
+ process.emitWarning(
4884
+ `[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
4885
+ );
4886
+ continue;
4887
+ }
4888
+ kept.push(entry);
4889
+ }
4890
+ return kept;
4891
+ }
4180
4892
  var DANGEROUS_WORDS = [
4181
4893
  "mkfs",
4182
4894
  // formats/wipes a filesystem partition
@@ -4186,6 +4898,7 @@ var DANGEROUS_WORDS = [
4186
4898
  var DEFAULT_CONFIG = {
4187
4899
  version: "1.0",
4188
4900
  policySource: "local",
4901
+ ssrfStrictSource: "default",
4189
4902
  settings: {
4190
4903
  mode: "standard",
4191
4904
  autoStartDaemon: true,
@@ -4369,7 +5082,17 @@ var DEFAULT_CONFIG = {
4369
5082
  }
4370
5083
  ],
4371
5084
  dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
4372
- egress: { enabled: false, mode: "review", allow: [], deny: [], allowPrivate: true },
5085
+ egress: {
5086
+ enabled: false,
5087
+ mode: "review",
5088
+ allow: [],
5089
+ deny: [],
5090
+ allowPrivate: true,
5091
+ // The SSRF floor is always on and needs no default; these two only
5092
+ // widen or narrow it. See doc/roadmap/active/ssrf-floor-design.md.
5093
+ ssrfAllow: [],
5094
+ ssrfStrict: false
5095
+ },
4373
5096
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
4374
5097
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
4375
5098
  skillPinning: { enabled: false, mode: "warn", roots: [] },
@@ -4552,7 +5275,8 @@ function getConfig(cwd) {
4552
5275
  egress: {
4553
5276
  ...DEFAULT_CONFIG.policy.egress,
4554
5277
  allow: [...DEFAULT_CONFIG.policy.egress.allow],
4555
- deny: [...DEFAULT_CONFIG.policy.egress.deny]
5278
+ deny: [...DEFAULT_CONFIG.policy.egress.deny],
5279
+ ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
4556
5280
  },
4557
5281
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
4558
5282
  injectionScan: {
@@ -4578,6 +5302,7 @@ function getConfig(cwd) {
4578
5302
  };
4579
5303
  const pr2Creds = getCredentials();
4580
5304
  const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
5305
+ let ssrfStrictSource = "default";
4581
5306
  const applyLayer = (source, isProject = false, isCloud = false) => {
4582
5307
  if (!source) return;
4583
5308
  const s = source.settings || {};
@@ -4660,6 +5385,15 @@ function getConfig(cwd) {
4660
5385
  if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
4661
5386
  if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
4662
5387
  mergedPolicy.egress.allowPrivate = e.allowPrivate;
5388
+ if (!isProject) {
5389
+ if (Array.isArray(e.ssrfAllow)) {
5390
+ mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(e.ssrfAllow, "egress.");
5391
+ }
5392
+ if (e.ssrfStrict !== void 0) {
5393
+ mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
5394
+ ssrfStrictSource = "local";
5395
+ }
5396
+ }
4663
5397
  }
4664
5398
  if (p.loopDetection) {
4665
5399
  const ld = p.loopDetection;
@@ -4766,6 +5500,13 @@ function getConfig(cwd) {
4766
5500
  if (deny) mergedPolicy.egress.deny = deny;
4767
5501
  if (typeof e.allowPrivate === "boolean")
4768
5502
  mergedPolicy.egress.allowPrivate = e.allowPrivate;
5503
+ if (typeof e.ssrfStrict === "boolean") {
5504
+ mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
5505
+ ssrfStrictSource = "workspace";
5506
+ }
5507
+ const ssrfAllow = hosts(e.ssrfAllow);
5508
+ if (ssrfAllow)
5509
+ mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(ssrfAllow, "managed egress.");
4769
5510
  } else {
4770
5511
  mergedPolicy.egress = applyManagedEgress(
4771
5512
  mergedPolicy.egress,
@@ -4774,7 +5515,16 @@ function getConfig(cwd) {
4774
5515
  mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
4775
5516
  allow: hosts(mc.egress.allow),
4776
5517
  deny: hosts(mc.egress.deny),
4777
- allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0
5518
+ allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0,
5519
+ ssrfStrict: (() => {
5520
+ if (typeof mc.egress.ssrfStrict !== "boolean") return void 0;
5521
+ ssrfStrictSource = "workspace";
5522
+ return mc.egress.ssrfStrict;
5523
+ })(),
5524
+ ssrfAllow: (() => {
5525
+ const list = hosts(mc.egress.ssrfAllow);
5526
+ return list ? sanitizeSsrfAllow(list, "managed egress.") : void 0;
5527
+ })()
4778
5528
  },
4779
5529
  locked,
4780
5530
  egressModeUserSet
@@ -4869,13 +5619,13 @@ function getConfig(cwd) {
4869
5619
  }
4870
5620
  if (Array.isArray(mc.jailPaths)) {
4871
5621
  for (const jp of mc.jailPaths) {
4872
- const path14 = typeof jp?.path === "string" ? jp.path.trim() : "";
4873
- if (!path14) continue;
5622
+ const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
5623
+ if (!path15) continue;
4874
5624
  const verdict = jp?.verdict === "review" ? "review" : "block";
4875
- for (const r of pathRules(path14, verdict, "org-managed jail")) {
5625
+ for (const r of pathRules(path15, verdict, "org-managed jail")) {
4876
5626
  mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4877
5627
  }
4878
- mergedPolicy.managedJailPaths.push({ path: path14, verdict });
5628
+ mergedPolicy.managedJailPaths.push({ path: path15, verdict });
4879
5629
  }
4880
5630
  }
4881
5631
  if (Array.isArray(mc.trustedHosts)) {
@@ -5004,6 +5754,7 @@ function getConfig(cwd) {
5004
5754
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
5005
5755
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
5006
5756
  mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
5757
+ const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
5007
5758
  const result = {
5008
5759
  settings: mergedSettings,
5009
5760
  policy: mergedPolicy,
@@ -5011,7 +5762,10 @@ function getConfig(cwd) {
5011
5762
  // PR-2 — the one truth introspection reads: which of the two working
5012
5763
  // modes this machine is in. 'workspace' = keyed, policy from the cloud;
5013
5764
  // 'local' = the local stack (incl. --local / named-profile keys).
5014
- policySource: keyed ? "workspace" : "local"
5765
+ policySource: keyed ? "workspace" : "local",
5766
+ // A keyed machine drops the local policy layers wholesale, so a 'local'
5767
+ // provenance recorded before the fork cannot survive into the result.
5768
+ ssrfStrictSource: resolvedSsrfStrictSource
5015
5769
  };
5016
5770
  if (!cwd) cachedConfig = result;
5017
5771
  return result;
@@ -5541,7 +6295,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
5541
6295
  }
5542
6296
 
5543
6297
  // src/auth/orchestrator.ts
5544
- var import_crypto4 = require("crypto");
6298
+ var import_crypto5 = require("crypto");
5545
6299
 
5546
6300
  // src/ui/native.ts
5547
6301
  var import_child_process = require("child_process");
@@ -5877,13 +6631,91 @@ end run`;
5877
6631
  });
5878
6632
  }
5879
6633
 
5880
- // src/auth/orchestrator.ts
5881
- init_audit();
6634
+ // src/canary/registry.ts
6635
+ var import_fs10 = __toESM(require("fs"));
6636
+ var import_os9 = __toESM(require("os"));
6637
+ var import_path12 = __toESM(require("path"));
5882
6638
 
5883
- // src/auth/cloud.ts
6639
+ // src/shields/jail.ts
5884
6640
  var import_fs9 = __toESM(require("fs"));
5885
6641
  var import_os8 = __toESM(require("os"));
5886
6642
  var import_path11 = __toESM(require("path"));
6643
+ var USER_JAIL_SHIELD = "user-jail";
6644
+ function jailStorePath() {
6645
+ return import_path11.default.join(import_os8.default.homedir(), ".node9", "jail-paths.json");
6646
+ }
6647
+ function readJailPaths() {
6648
+ let text;
6649
+ try {
6650
+ text = import_fs9.default.readFileSync(jailStorePath(), "utf8");
6651
+ } catch (err) {
6652
+ if (err.code === "ENOENT") return [];
6653
+ throw err;
6654
+ }
6655
+ let parsed;
6656
+ try {
6657
+ parsed = JSON.parse(text);
6658
+ } catch {
6659
+ throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6660
+ }
6661
+ if (!Array.isArray(parsed.paths)) return [];
6662
+ return parsed.paths.filter(
6663
+ (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6664
+ );
6665
+ }
6666
+ function findJailedPath(candidate) {
6667
+ return findJailedPathIn(candidate, readJailPaths());
6668
+ }
6669
+ function findJailedPathIn(candidate, paths) {
6670
+ if (!candidate) return null;
6671
+ for (const entry of paths) {
6672
+ if (pathMatchesFragment(candidate, entry.path)) return entry;
6673
+ }
6674
+ return null;
6675
+ }
6676
+
6677
+ // src/canary/registry.ts
6678
+ function canaryStorePath() {
6679
+ return import_path12.default.join(import_os9.default.homedir(), ".node9", "canaries.json");
6680
+ }
6681
+ function isRecord(x) {
6682
+ if (!x || typeof x !== "object") return false;
6683
+ const r = x;
6684
+ return typeof r.id === "string" && typeof r.value === "string" && typeof r.valueHash === "string";
6685
+ }
6686
+ function loadCanaries(opts) {
6687
+ const p = canaryStorePath();
6688
+ let raw;
6689
+ try {
6690
+ raw = import_fs10.default.readFileSync(p, "utf-8");
6691
+ } catch (e) {
6692
+ if (e.code === "ENOENT") return [];
6693
+ throw e;
6694
+ }
6695
+ let parsed;
6696
+ try {
6697
+ parsed = JSON.parse(raw);
6698
+ } catch (e) {
6699
+ throw new Error(
6700
+ `[node9] ${p} is not valid JSON; refusing to touch it (${e instanceof Error ? e.message : String(e)})`
6701
+ );
6702
+ }
6703
+ const recs = parsed?.records;
6704
+ if (!Array.isArray(recs)) return [];
6705
+ const out = recs.filter(isRecord);
6706
+ return opts?.includeRetired === false ? out.filter((r) => !r.retiredAt) : out;
6707
+ }
6708
+ function canaryValues() {
6709
+ return loadCanaries().map((r) => ({ id: r.id, value: r.value, retired: Boolean(r.retiredAt) }));
6710
+ }
6711
+
6712
+ // src/auth/orchestrator.ts
6713
+ init_audit();
6714
+
6715
+ // src/auth/cloud.ts
6716
+ var import_fs11 = __toESM(require("fs"));
6717
+ var import_os10 = __toESM(require("os"));
6718
+ var import_path13 = __toESM(require("path"));
5887
6719
  init_audit();
5888
6720
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
5889
6721
  const controller = new AbortController();
@@ -5892,10 +6724,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5892
6724
  let ciContext;
5893
6725
  if (process.env.CI) {
5894
6726
  try {
5895
- const ciContextPath = import_path11.default.join(import_os8.default.homedir(), ".node9", "ci-context.json");
5896
- const stats = import_fs9.default.statSync(ciContextPath);
6727
+ const ciContextPath = import_path13.default.join(import_os10.default.homedir(), ".node9", "ci-context.json");
6728
+ const stats = import_fs11.default.statSync(ciContextPath);
5897
6729
  if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
5898
- const raw = import_fs9.default.readFileSync(ciContextPath, "utf8");
6730
+ const raw = import_fs11.default.readFileSync(ciContextPath, "utf8");
5899
6731
  const parsed = JSON.parse(raw);
5900
6732
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5901
6733
  throw new Error("ci-context.json is not a plain object");
@@ -5928,9 +6760,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5928
6760
  context: {
5929
6761
  agent: meta?.agent,
5930
6762
  mcpServer: meta?.mcpServer,
5931
- hostname: import_os8.default.hostname(),
6763
+ hostname: import_os10.default.hostname(),
5932
6764
  cwd: process.cwd(),
5933
- platform: import_os8.default.platform()
6765
+ platform: import_os10.default.platform()
5934
6766
  },
5935
6767
  ...riskMetadata && { riskMetadata },
5936
6768
  ...ciContext && { ciContext },
@@ -5994,14 +6826,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5994
6826
  });
5995
6827
  clearTimeout(timer);
5996
6828
  if (!res.ok) {
5997
- import_fs9.default.appendFileSync(
6829
+ import_fs11.default.appendFileSync(
5998
6830
  HOOK_DEBUG_LOG,
5999
6831
  `[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
6000
6832
  `
6001
6833
  );
6002
6834
  }
6003
6835
  } catch (err) {
6004
- import_fs9.default.appendFileSync(
6836
+ import_fs11.default.appendFileSync(
6005
6837
  HOOK_DEBUG_LOG,
6006
6838
  `[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
6007
6839
  `
@@ -6010,16 +6842,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
6010
6842
  }
6011
6843
 
6012
6844
  // src/loop-detector.ts
6013
- var import_fs10 = __toESM(require("fs"));
6014
- var import_path12 = __toESM(require("path"));
6015
- var import_os9 = __toESM(require("os"));
6845
+ var import_fs12 = __toESM(require("fs"));
6846
+ var import_path14 = __toESM(require("path"));
6847
+ var import_os11 = __toESM(require("os"));
6016
6848
  function loopStateFile() {
6017
- return import_path12.default.join(import_os9.default.homedir(), ".node9", "loop-state.json");
6849
+ return import_path14.default.join(import_os11.default.homedir(), ".node9", "loop-state.json");
6018
6850
  }
6019
6851
  function readState() {
6020
6852
  try {
6021
- if (!import_fs10.default.existsSync(loopStateFile())) return [];
6022
- const raw = import_fs10.default.readFileSync(loopStateFile(), "utf-8");
6853
+ if (!import_fs12.default.existsSync(loopStateFile())) return [];
6854
+ const raw = import_fs12.default.readFileSync(loopStateFile(), "utf-8");
6023
6855
  const parsed = JSON.parse(raw);
6024
6856
  if (!Array.isArray(parsed)) return [];
6025
6857
  return parsed;
@@ -6028,11 +6860,11 @@ function readState() {
6028
6860
  }
6029
6861
  }
6030
6862
  function writeState(records) {
6031
- const dir = import_path12.default.dirname(loopStateFile());
6032
- if (!import_fs10.default.existsSync(dir)) import_fs10.default.mkdirSync(dir, { recursive: true });
6033
- const tmpPath = `${loopStateFile()}.${import_os9.default.hostname()}.${process.pid}.tmp`;
6034
- import_fs10.default.writeFileSync(tmpPath, JSON.stringify(records));
6035
- import_fs10.default.renameSync(tmpPath, loopStateFile());
6863
+ const dir = import_path14.default.dirname(loopStateFile());
6864
+ if (!import_fs12.default.existsSync(dir)) import_fs12.default.mkdirSync(dir, { recursive: true });
6865
+ const tmpPath = `${loopStateFile()}.${import_os11.default.hostname()}.${process.pid}.tmp`;
6866
+ import_fs12.default.writeFileSync(tmpPath, JSON.stringify(records));
6867
+ import_fs12.default.renameSync(tmpPath, loopStateFile());
6036
6868
  }
6037
6869
  function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6038
6870
  try {
@@ -6044,44 +6876,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6044
6876
  }
6045
6877
  }
6046
6878
 
6047
- // src/shields/jail.ts
6048
- var import_fs11 = __toESM(require("fs"));
6049
- var import_os10 = __toESM(require("os"));
6050
- var import_path13 = __toESM(require("path"));
6051
- var USER_JAIL_SHIELD = "user-jail";
6052
- function jailStorePath() {
6053
- return import_path13.default.join(import_os10.default.homedir(), ".node9", "jail-paths.json");
6054
- }
6055
- function readJailPaths() {
6056
- let text;
6057
- try {
6058
- text = import_fs11.default.readFileSync(jailStorePath(), "utf8");
6059
- } catch (err) {
6060
- if (err.code === "ENOENT") return [];
6061
- throw err;
6062
- }
6063
- let parsed;
6064
- try {
6065
- parsed = JSON.parse(text);
6066
- } catch {
6067
- throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6068
- }
6069
- if (!Array.isArray(parsed.paths)) return [];
6070
- return parsed.paths.filter(
6071
- (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6072
- );
6073
- }
6074
- function findJailedPath(candidate) {
6075
- return findJailedPathIn(candidate, readJailPaths());
6076
- }
6077
- function findJailedPathIn(candidate, paths) {
6078
- if (!candidate) return null;
6079
- for (const entry of paths) {
6080
- if (pathMatchesFragment(candidate, entry.path)) return entry;
6081
- }
6082
- return null;
6083
- }
6084
-
6085
6879
  // src/auth/orchestrator.ts
6086
6880
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
6087
6881
  "write",
@@ -6139,7 +6933,7 @@ async function hasReachableHumanApprover(opts) {
6139
6933
  }
6140
6934
  async function authorizeHeadless(toolName, args, meta, options) {
6141
6935
  if (!options?.calledFromDaemon) {
6142
- const actId = (0, import_crypto4.randomUUID)();
6936
+ const actId = (0, import_crypto5.randomUUID)();
6143
6937
  const actTs = Date.now();
6144
6938
  const stripAnsi = (s) => s.replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "");
6145
6939
  const sanitizedAgent = meta?.agent ? stripAnsi(meta.agent).slice(0, 80) : void 0;
@@ -6267,6 +7061,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
6267
7061
  taintWarning = `\u26A0\uFE0F node9 flagged this session \u2014 earlier tool output contained ${sessionTaint.record.source}. Approve this ${isWriteTool(toolName) ? "write" : "network"} action before it proceeds.`;
6268
7062
  }
6269
7063
  }
7064
+ const canaryVals = safeCanaryValues();
7065
+ if (canaryVals.length > 0) {
7066
+ const canaryHit = matchCanaryArgs(args, canaryVals);
7067
+ if (canaryHit) {
7068
+ const rec = canaryRecordById(canaryHit.id);
7069
+ const shape = scanArgs(args);
7070
+ const canaryReason = `\u{1F6A8} DECOY CREDENTIAL: the fake ${rec?.kind ?? "credential"} node9 planted at ${rec?.path ?? "a decoy file"} appeared in field "${canaryHit.fieldPath || "args"}". Something read that file; nothing legitimate does.`;
7071
+ if (!isManual)
7072
+ appendLocalAudit(
7073
+ toolName,
7074
+ args,
7075
+ "deny",
7076
+ isObserveMode ? "observe-mode-dlp-canary-would-block" : "dlp-canary-block",
7077
+ {
7078
+ ...meta,
7079
+ canaryId: canaryHit.id,
7080
+ canaryHash: rec?.valueHash,
7081
+ canaryKind: rec?.kind,
7082
+ canaryPath: rec?.path,
7083
+ canaryView: canaryHit.view,
7084
+ canaryRetired: canaryHit.retired,
7085
+ ...shape ? { dlpPattern: shape.patternName, dlpSample: shape.redactedSample } : {}
7086
+ },
7087
+ true
7088
+ );
7089
+ if (isObserveMode) {
7090
+ return {
7091
+ approved: true,
7092
+ checkedBy: "audit",
7093
+ observeWouldBlock: true,
7094
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)"
7095
+ };
7096
+ }
7097
+ return {
7098
+ approved: false,
7099
+ reason: canaryReason,
7100
+ blockedBy: "local-config",
7101
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)",
7102
+ // The /dev/tty banner renders ruleDescription under "Triggered by".
7103
+ // Without it the terminal said only "Decoy Credential" and never named
7104
+ // the file, while `node9 canary plant` promises node9 tells you which
7105
+ // file was read. Witnessed by canary-block-message.spec.ts, which calls
7106
+ // the orchestrator directly: this field never reaches the hook stdout.
7107
+ ruleDescription: `The fake credential node9 planted in ${rec?.path ?? "a decoy file"} just left that file. Nothing legitimate reads it.`
7108
+ };
7109
+ }
7110
+ }
6270
7111
  if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
6271
7112
  const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
6272
7113
  const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
@@ -6922,6 +7763,20 @@ async function authorizeAction(toolName, args) {
6922
7763
  const result = await authorizeHeadless(toolName, args);
6923
7764
  return result.approved;
6924
7765
  }
7766
+ function safeCanaryValues() {
7767
+ try {
7768
+ return canaryValues();
7769
+ } catch {
7770
+ return [];
7771
+ }
7772
+ }
7773
+ function canaryRecordById(id) {
7774
+ try {
7775
+ return loadCanaries().find((r) => r.id === id) ?? null;
7776
+ } catch {
7777
+ return null;
7778
+ }
7779
+ }
6925
7780
 
6926
7781
  // src/index.ts
6927
7782
  function protect(toolName, fn) {