@node9/proxy 2.8.4 → 2.9.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
@@ -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();
@@ -2274,6 +2544,10 @@ function analyzeShellCommand(command) {
2274
2544
  return { actions, paths, allTokens };
2275
2545
  }
2276
2546
  var DEFAULT_EGRESS_ALLOWLIST = [
2547
+ // node9's own control plane (api, app, dev-api, staging and the apex).
2548
+ // Without it, turning egress on asks the user to approve node9 itself.
2549
+ // A user `deny` entry still wins over this list, see evaluateEgress.
2550
+ "*.node9.ai",
2277
2551
  "*.github.com",
2278
2552
  "*.githubusercontent.com",
2279
2553
  "*.npmjs.org",
@@ -2416,7 +2690,7 @@ function isSensitivePath(p) {
2416
2690
  return SENSITIVE_PATTERNS.some((re) => re.test(p));
2417
2691
  }
2418
2692
  function splitOnPipe(cmd) {
2419
- const segments = [];
2693
+ const segments2 = [];
2420
2694
  let current = "";
2421
2695
  let inSingle = false;
2422
2696
  let inDouble = false;
@@ -2429,21 +2703,21 @@ function splitOnPipe(cmd) {
2429
2703
  inDouble = !inDouble;
2430
2704
  current += ch;
2431
2705
  } else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
2432
- segments.push(current.trim());
2706
+ segments2.push(current.trim());
2433
2707
  current = "";
2434
2708
  } else {
2435
2709
  current += ch;
2436
2710
  }
2437
2711
  }
2438
- if (current.trim()) segments.push(current.trim());
2439
- return segments.filter(Boolean);
2712
+ if (current.trim()) segments2.push(current.trim());
2713
+ return segments2.filter(Boolean);
2440
2714
  }
2441
2715
  function positionalTokens(segment) {
2442
2716
  return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2443
2717
  }
2444
2718
  function analyzePipeChain(command) {
2445
- const segments = splitOnPipe(command);
2446
- if (segments.length < 2) {
2719
+ const segments2 = splitOnPipe(command);
2720
+ if (segments2.length < 2) {
2447
2721
  return {
2448
2722
  isPipeline: false,
2449
2723
  hasSensitiveSource: false,
@@ -2459,7 +2733,7 @@ function analyzePipeChain(command) {
2459
2733
  let hasSensitiveSource = false;
2460
2734
  let hasExternalSink = false;
2461
2735
  let hasObfuscation = false;
2462
- for (const segment of segments) {
2736
+ for (const segment of segments2) {
2463
2737
  const tokens = segment.split(/\s+/).filter(Boolean);
2464
2738
  if (tokens.length === 0) continue;
2465
2739
  const binary = tokens[0].toLowerCase();
@@ -2497,8 +2771,8 @@ function analyzePipeChain(command) {
2497
2771
  };
2498
2772
  }
2499
2773
  function basename(p) {
2500
- const segments = p.split(/[\\/]/);
2501
- return segments[segments.length - 1] || "";
2774
+ const segments2 = p.split(/[\\/]/);
2775
+ return segments2[segments2.length - 1] || "";
2502
2776
  }
2503
2777
  var FLAGS_WITH_VALUES = {
2504
2778
  curl: /* @__PURE__ */ new Set([
@@ -2661,6 +2935,204 @@ function extractAllSshHosts(tokens) {
2661
2935
  }
2662
2936
  return [...hosts].filter(Boolean);
2663
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
+ }
2664
3136
  function resolveCheck(v) {
2665
3137
  return v === "off" || v === "block" ? v : "review";
2666
3138
  }
@@ -2893,6 +3365,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2893
3365
  }
2894
3366
  const builtin = strictestVerdict(candidates);
2895
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
+ }
2896
3384
  if (config.policy.egress?.enabled) {
2897
3385
  const dests = extractShellDestinations(shellCommand);
2898
3386
  if (dests.length > 0) {
@@ -3820,7 +4308,7 @@ assertBuiltinShieldRegexesAreSafe();
3820
4308
  var LOOP_MAX_RECORDS = 500;
3821
4309
  function computeArgsHash(args) {
3822
4310
  const str = JSON.stringify(args ?? "");
3823
- 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);
3824
4312
  }
3825
4313
  function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3826
4314
  const hash = computeArgsHash(args);
@@ -3834,28 +4322,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
3834
4322
  var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3835
4323
  var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3836
4324
  var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3837
- 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
+ }
3838
4348
  function detectPii(text) {
3839
4349
  const found = /* @__PURE__ */ new Set();
3840
4350
  if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
3841
4351
  if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
3842
4352
  if (PII_PHONE_RE.test(text)) found.add("Phone");
3843
- 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");
3844
4355
  return [...found];
3845
4356
  }
3846
- var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
4357
+ var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
3847
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
+ }
3848
4376
  function detectArgsPii(args) {
3849
4377
  if (args === null || args === void 0) return [];
3850
- let text;
4378
+ const found = /* @__PURE__ */ new Set();
4379
+ let budget = MAX_PII_SCAN_BYTES;
3851
4380
  try {
3852
- 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
+ }
3853
4389
  } catch {
3854
4390
  return [];
3855
4391
  }
3856
- if (typeof text !== "string") return [];
3857
- if (text.length > MAX_PII_SCAN_BYTES) text = text.slice(0, MAX_PII_SCAN_BYTES);
3858
- 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
+ }
3859
4555
  }
3860
4556
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3861
4557
 
@@ -3985,6 +4681,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
3985
4681
  if (Array.isArray(managed.deny) && managed.deny.length > 0) {
3986
4682
  next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
3987
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
+ }
3988
4690
  if (typeof managed.allowPrivate === "boolean") {
3989
4691
  next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
3990
4692
  }
@@ -4066,9 +4768,9 @@ var B = "[\\s/\\\\]";
4066
4768
  var SEP = "[/\\\\]";
4067
4769
  function pathToRegexFragment(rawPath) {
4068
4770
  const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
4069
- const segments = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
4070
- if (segments.length === 0) return "";
4071
- 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}|$)`;
4072
4774
  }
4073
4775
  function pathMatchesFragment(candidate, rawPath) {
4074
4776
  const value = pathToRegexFragment(rawPath);
@@ -4173,6 +4875,20 @@ function isTrustedHost(host) {
4173
4875
  }
4174
4876
 
4175
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
+ }
4176
4892
  var DANGEROUS_WORDS = [
4177
4893
  "mkfs",
4178
4894
  // formats/wipes a filesystem partition
@@ -4182,6 +4898,7 @@ var DANGEROUS_WORDS = [
4182
4898
  var DEFAULT_CONFIG = {
4183
4899
  version: "1.0",
4184
4900
  policySource: "local",
4901
+ ssrfStrictSource: "default",
4185
4902
  settings: {
4186
4903
  mode: "standard",
4187
4904
  autoStartDaemon: true,
@@ -4365,7 +5082,17 @@ var DEFAULT_CONFIG = {
4365
5082
  }
4366
5083
  ],
4367
5084
  dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
4368
- 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
+ },
4369
5096
  loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
4370
5097
  injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
4371
5098
  skillPinning: { enabled: false, mode: "warn", roots: [] },
@@ -4548,7 +5275,8 @@ function getConfig(cwd) {
4548
5275
  egress: {
4549
5276
  ...DEFAULT_CONFIG.policy.egress,
4550
5277
  allow: [...DEFAULT_CONFIG.policy.egress.allow],
4551
- deny: [...DEFAULT_CONFIG.policy.egress.deny]
5278
+ deny: [...DEFAULT_CONFIG.policy.egress.deny],
5279
+ ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
4552
5280
  },
4553
5281
  loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
4554
5282
  injectionScan: {
@@ -4574,6 +5302,7 @@ function getConfig(cwd) {
4574
5302
  };
4575
5303
  const pr2Creds = getCredentials();
4576
5304
  const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
5305
+ let ssrfStrictSource = "default";
4577
5306
  const applyLayer = (source, isProject = false, isCloud = false) => {
4578
5307
  if (!source) return;
4579
5308
  const s = source.settings || {};
@@ -4656,6 +5385,15 @@ function getConfig(cwd) {
4656
5385
  if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
4657
5386
  if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
4658
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
+ }
4659
5397
  }
4660
5398
  if (p.loopDetection) {
4661
5399
  const ld = p.loopDetection;
@@ -4762,6 +5500,13 @@ function getConfig(cwd) {
4762
5500
  if (deny) mergedPolicy.egress.deny = deny;
4763
5501
  if (typeof e.allowPrivate === "boolean")
4764
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.");
4765
5510
  } else {
4766
5511
  mergedPolicy.egress = applyManagedEgress(
4767
5512
  mergedPolicy.egress,
@@ -4770,7 +5515,16 @@ function getConfig(cwd) {
4770
5515
  mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
4771
5516
  allow: hosts(mc.egress.allow),
4772
5517
  deny: hosts(mc.egress.deny),
4773
- 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
+ })()
4774
5528
  },
4775
5529
  locked,
4776
5530
  egressModeUserSet
@@ -4865,13 +5619,13 @@ function getConfig(cwd) {
4865
5619
  }
4866
5620
  if (Array.isArray(mc.jailPaths)) {
4867
5621
  for (const jp of mc.jailPaths) {
4868
- const path14 = typeof jp?.path === "string" ? jp.path.trim() : "";
4869
- if (!path14) continue;
5622
+ const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
5623
+ if (!path15) continue;
4870
5624
  const verdict = jp?.verdict === "review" ? "review" : "block";
4871
- for (const r of pathRules(path14, verdict, "org-managed jail")) {
5625
+ for (const r of pathRules(path15, verdict, "org-managed jail")) {
4872
5626
  mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
4873
5627
  }
4874
- mergedPolicy.managedJailPaths.push({ path: path14, verdict });
5628
+ mergedPolicy.managedJailPaths.push({ path: path15, verdict });
4875
5629
  }
4876
5630
  }
4877
5631
  if (Array.isArray(mc.trustedHosts)) {
@@ -5000,6 +5754,7 @@ function getConfig(cwd) {
5000
5754
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
5001
5755
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
5002
5756
  mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
5757
+ const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
5003
5758
  const result = {
5004
5759
  settings: mergedSettings,
5005
5760
  policy: mergedPolicy,
@@ -5007,7 +5762,10 @@ function getConfig(cwd) {
5007
5762
  // PR-2 — the one truth introspection reads: which of the two working
5008
5763
  // modes this machine is in. 'workspace' = keyed, policy from the cloud;
5009
5764
  // 'local' = the local stack (incl. --local / named-profile keys).
5010
- 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
5011
5769
  };
5012
5770
  if (!cwd) cachedConfig = result;
5013
5771
  return result;
@@ -5537,7 +6295,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
5537
6295
  }
5538
6296
 
5539
6297
  // src/auth/orchestrator.ts
5540
- var import_crypto4 = require("crypto");
6298
+ var import_crypto5 = require("crypto");
5541
6299
 
5542
6300
  // src/ui/native.ts
5543
6301
  var import_child_process = require("child_process");
@@ -5873,13 +6631,91 @@ end run`;
5873
6631
  });
5874
6632
  }
5875
6633
 
5876
- // src/auth/orchestrator.ts
5877
- 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"));
5878
6638
 
5879
- // src/auth/cloud.ts
6639
+ // src/shields/jail.ts
5880
6640
  var import_fs9 = __toESM(require("fs"));
5881
6641
  var import_os8 = __toESM(require("os"));
5882
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"));
5883
6719
  init_audit();
5884
6720
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
5885
6721
  const controller = new AbortController();
@@ -5888,10 +6724,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5888
6724
  let ciContext;
5889
6725
  if (process.env.CI) {
5890
6726
  try {
5891
- const ciContextPath = import_path11.default.join(import_os8.default.homedir(), ".node9", "ci-context.json");
5892
- 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);
5893
6729
  if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
5894
- const raw = import_fs9.default.readFileSync(ciContextPath, "utf8");
6730
+ const raw = import_fs11.default.readFileSync(ciContextPath, "utf8");
5895
6731
  const parsed = JSON.parse(raw);
5896
6732
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5897
6733
  throw new Error("ci-context.json is not a plain object");
@@ -5924,9 +6760,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
5924
6760
  context: {
5925
6761
  agent: meta?.agent,
5926
6762
  mcpServer: meta?.mcpServer,
5927
- hostname: import_os8.default.hostname(),
6763
+ hostname: import_os10.default.hostname(),
5928
6764
  cwd: process.cwd(),
5929
- platform: import_os8.default.platform()
6765
+ platform: import_os10.default.platform()
5930
6766
  },
5931
6767
  ...riskMetadata && { riskMetadata },
5932
6768
  ...ciContext && { ciContext },
@@ -5990,14 +6826,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
5990
6826
  });
5991
6827
  clearTimeout(timer);
5992
6828
  if (!res.ok) {
5993
- import_fs9.default.appendFileSync(
6829
+ import_fs11.default.appendFileSync(
5994
6830
  HOOK_DEBUG_LOG,
5995
6831
  `[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
5996
6832
  `
5997
6833
  );
5998
6834
  }
5999
6835
  } catch (err) {
6000
- import_fs9.default.appendFileSync(
6836
+ import_fs11.default.appendFileSync(
6001
6837
  HOOK_DEBUG_LOG,
6002
6838
  `[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
6003
6839
  `
@@ -6006,16 +6842,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
6006
6842
  }
6007
6843
 
6008
6844
  // src/loop-detector.ts
6009
- var import_fs10 = __toESM(require("fs"));
6010
- var import_path12 = __toESM(require("path"));
6011
- 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"));
6012
6848
  function loopStateFile() {
6013
- 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");
6014
6850
  }
6015
6851
  function readState() {
6016
6852
  try {
6017
- if (!import_fs10.default.existsSync(loopStateFile())) return [];
6018
- 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");
6019
6855
  const parsed = JSON.parse(raw);
6020
6856
  if (!Array.isArray(parsed)) return [];
6021
6857
  return parsed;
@@ -6024,11 +6860,11 @@ function readState() {
6024
6860
  }
6025
6861
  }
6026
6862
  function writeState(records) {
6027
- const dir = import_path12.default.dirname(loopStateFile());
6028
- if (!import_fs10.default.existsSync(dir)) import_fs10.default.mkdirSync(dir, { recursive: true });
6029
- const tmpPath = `${loopStateFile()}.${import_os9.default.hostname()}.${process.pid}.tmp`;
6030
- import_fs10.default.writeFileSync(tmpPath, JSON.stringify(records));
6031
- 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());
6032
6868
  }
6033
6869
  function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6034
6870
  try {
@@ -6040,44 +6876,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
6040
6876
  }
6041
6877
  }
6042
6878
 
6043
- // src/shields/jail.ts
6044
- var import_fs11 = __toESM(require("fs"));
6045
- var import_os10 = __toESM(require("os"));
6046
- var import_path13 = __toESM(require("path"));
6047
- var USER_JAIL_SHIELD = "user-jail";
6048
- function jailStorePath() {
6049
- return import_path13.default.join(import_os10.default.homedir(), ".node9", "jail-paths.json");
6050
- }
6051
- function readJailPaths() {
6052
- let text;
6053
- try {
6054
- text = import_fs11.default.readFileSync(jailStorePath(), "utf8");
6055
- } catch (err) {
6056
- if (err.code === "ENOENT") return [];
6057
- throw err;
6058
- }
6059
- let parsed;
6060
- try {
6061
- parsed = JSON.parse(text);
6062
- } catch {
6063
- throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
6064
- }
6065
- if (!Array.isArray(parsed.paths)) return [];
6066
- return parsed.paths.filter(
6067
- (p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
6068
- );
6069
- }
6070
- function findJailedPath(candidate) {
6071
- return findJailedPathIn(candidate, readJailPaths());
6072
- }
6073
- function findJailedPathIn(candidate, paths) {
6074
- if (!candidate) return null;
6075
- for (const entry of paths) {
6076
- if (pathMatchesFragment(candidate, entry.path)) return entry;
6077
- }
6078
- return null;
6079
- }
6080
-
6081
6879
  // src/auth/orchestrator.ts
6082
6880
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
6083
6881
  "write",
@@ -6135,7 +6933,7 @@ async function hasReachableHumanApprover(opts) {
6135
6933
  }
6136
6934
  async function authorizeHeadless(toolName, args, meta, options) {
6137
6935
  if (!options?.calledFromDaemon) {
6138
- const actId = (0, import_crypto4.randomUUID)();
6936
+ const actId = (0, import_crypto5.randomUUID)();
6139
6937
  const actTs = Date.now();
6140
6938
  const stripAnsi = (s) => s.replace(/\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g, "");
6141
6939
  const sanitizedAgent = meta?.agent ? stripAnsi(meta.agent).slice(0, 80) : void 0;
@@ -6263,6 +7061,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
6263
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.`;
6264
7062
  }
6265
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
+ }
6266
7111
  if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
6267
7112
  const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
6268
7113
  const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
@@ -6918,6 +7763,20 @@ async function authorizeAction(toolName, args) {
6918
7763
  const result = await authorizeHeadless(toolName, args);
6919
7764
  return result.approved;
6920
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
+ }
6921
7780
 
6922
7781
  // src/index.ts
6923
7782
  function protect(toolName, fn) {