@node9/proxy 2.16.2 → 2.18.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/cli.js CHANGED
@@ -2251,32 +2251,61 @@ function classifySsrf(host) {
2251
2251
  return null;
2252
2252
  }
2253
2253
  }
2254
+ function ssrfExemptMatches(entries, normalized) {
2255
+ if (!entries?.length || !normalized) return false;
2256
+ const target = bitsOf(normalized);
2257
+ for (const raw of entries) {
2258
+ const entry = raw.trim().toLowerCase();
2259
+ if (!entry) continue;
2260
+ const slash = entry.indexOf("/");
2261
+ if (slash === -1) {
2262
+ if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
2263
+ continue;
2264
+ }
2265
+ if (!target) continue;
2266
+ const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
2267
+ const prefixText = entry.slice(slash + 1);
2268
+ const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
2269
+ if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
2270
+ // differ, so `0.0.0.0/0` does not release `::1`.
2271
+ base.length !== target.length || prefix > base.length) {
2272
+ continue;
2273
+ }
2274
+ if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
2275
+ }
2276
+ return false;
2277
+ }
2278
+ function bitsOf(normalized) {
2279
+ const o = v4Octets(normalized);
2280
+ if (o) {
2281
+ return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
2282
+ }
2283
+ const g = expandIpv6(normalized);
2284
+ return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
2285
+ }
2254
2286
  function ssrfFloor(tokens, opts = {}) {
2255
- const exempt2 = new Set(
2256
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
2257
- );
2258
2287
  for (const { token, binary } of tokens) {
2259
2288
  const m = classifySsrf(token);
2260
2289
  if (!m) continue;
2261
2290
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
2262
- if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
2291
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
2263
2292
  return { ...m, host: token, binary, reason: ssrfReason(m, token) };
2264
2293
  }
2265
2294
  return null;
2266
2295
  }
2267
2296
  function hostMatches(host, pattern) {
2268
- const h = host.toLowerCase();
2269
- const p = pattern.toLowerCase().trim();
2297
+ const p = pattern.trim().toLowerCase();
2270
2298
  if (!p) return false;
2271
2299
  if (p === "*") return true;
2300
+ const h = canonicalHost(host);
2272
2301
  if (p.startsWith("*.")) {
2273
- const suffix = p.slice(2);
2302
+ const suffix = canonicalHost(p.slice(2));
2274
2303
  return h === suffix || h.endsWith("." + suffix);
2275
2304
  }
2276
- return h === p;
2305
+ return h === canonicalHost(p);
2277
2306
  }
2278
2307
  function matchesAny(host, patterns) {
2279
- for (const p of patterns) if (hostMatches(host, p)) return true;
2308
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
2280
2309
  return false;
2281
2310
  }
2282
2311
  function isUniqueLocalV6(host) {
@@ -2286,13 +2315,17 @@ function isUniqueLocalV6(host) {
2286
2315
  return g !== null && (g[0] & 65024) === 64512;
2287
2316
  }
2288
2317
  function isPrivateHost(host) {
2289
- const h = host.trim().toLowerCase();
2318
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
2290
2319
  const m = classifySsrf(h);
2291
2320
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
2292
2321
  if (h === "localhost") return true;
2293
2322
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
2294
2323
  return isUniqueLocalV6(h);
2295
2324
  }
2325
+ function canonicalHost(host) {
2326
+ const ip = normalizeIpLiteral(host);
2327
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
2328
+ }
2296
2329
  function evaluateEgress(dests, policy) {
2297
2330
  if (!policy.enabled) return null;
2298
2331
  let review = null;
@@ -2544,11 +2577,26 @@ function hostOf(value) {
2544
2577
  return null;
2545
2578
  }
2546
2579
  }
2580
+ function extractToolDestinations(toolName, args) {
2581
+ try {
2582
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
2583
+ if (!paths) return [];
2584
+ const out = [];
2585
+ for (const path78 of paths) {
2586
+ for (const value of valuesAt(args, path78)) {
2587
+ const host = hostOf(value);
2588
+ if (host) out.push({ host, binary: toolName, raw: value });
2589
+ }
2590
+ }
2591
+ return out;
2592
+ } catch {
2593
+ return [];
2594
+ }
2595
+ }
2547
2596
  function ssrfDestinationFloor(toolName, args, opts = {}) {
2548
2597
  try {
2549
2598
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
2550
2599
  if (!paths) return null;
2551
- const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
2552
2600
  for (const path78 of paths) {
2553
2601
  for (const value of valuesAt(args, path78)) {
2554
2602
  const host = hostOf(value);
@@ -2556,7 +2604,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
2556
2604
  const m = classifySsrf(host);
2557
2605
  if (!m) continue;
2558
2606
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
2559
- if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
2607
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
2560
2608
  return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
2561
2609
  }
2562
2610
  }
@@ -2645,6 +2693,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
2645
2693
  tier: 3
2646
2694
  };
2647
2695
  }
2696
+ function egressPolicyVerdict(eg) {
2697
+ return {
2698
+ decision: eg.verdict,
2699
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
2700
+ reason: eg.reason,
2701
+ ruleName: `egress:${eg.binary}:${eg.host}`,
2702
+ ruleDescription: eg.reason,
2703
+ tier: eg.verdict === "block" ? 3 : 4
2704
+ };
2705
+ }
2648
2706
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
2649
2707
  const { agent, cwd, activeEnvironment } = context;
2650
2708
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -2679,7 +2737,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2679
2737
  };
2680
2738
  }
2681
2739
  }
2682
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
2740
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
2741
+ const dests = extractToolDestinations(toolName, args);
2742
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
2743
+ return eg ? egressPolicyVerdict(eg) : void 0;
2744
+ })() : void 0;
2745
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
2746
+ return pendingToolEgress ?? { decision: "allow" };
2747
+ }
2683
2748
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2684
2749
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2685
2750
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -2768,6 +2833,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2768
2833
  }
2769
2834
  let allTokens = [];
2770
2835
  let pathTokens = [];
2836
+ if (pendingToolEgress) return pendingToolEgress;
2771
2837
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
2772
2838
  if (shellCommand) {
2773
2839
  const analyzed = analyzeShellCommand(shellCommand);
@@ -2833,16 +2899,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2833
2899
  const dests = extractShellDestinations(shellCommand);
2834
2900
  if (dests.length > 0) {
2835
2901
  const eg = evaluateEgress(dests, config.policy.egress);
2836
- if (eg) {
2837
- return {
2838
- decision: eg.verdict,
2839
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
2840
- reason: eg.reason,
2841
- ruleName: `egress:${eg.binary}:${eg.host}`,
2842
- ruleDescription: eg.reason,
2843
- tier: eg.verdict === "block" ? 3 : 4
2844
- };
2845
- }
2902
+ if (eg) return egressPolicyVerdict(eg);
2846
2903
  }
2847
2904
  }
2848
2905
  const firstToken = analyzed.actions[0] ?? "";
@@ -6770,10 +6827,12 @@ var init_api_url = __esm({
6770
6827
  function sanitizeSsrfAllow(entries, source) {
6771
6828
  const kept = [];
6772
6829
  for (const entry of entries) {
6773
- const m = classifySsrf(entry);
6830
+ const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
6831
+ const m = classifySsrf(base);
6774
6832
  if (m && !m.overridable) {
6833
+ const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
6775
6834
  process.emitWarning(
6776
- `[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
6835
+ `[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
6777
6836
  );
6778
6837
  continue;
6779
6838
  }
@@ -9945,8 +10004,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
9945
10004
  };
9946
10005
  }
9947
10006
  }
10007
+ const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
10008
+ const judge = !isIgnoredTool2(toolName) || declaredEgress;
9948
10009
  if (isObserveMode) {
9949
- if (!isIgnoredTool2(toolName)) {
10010
+ if (judge) {
9950
10011
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
9951
10012
  const wouldBlock = policyResult.decision === "block";
9952
10013
  if (!isManual)
@@ -9971,7 +10032,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
9971
10032
  return { approved: true, checkedBy: "audit" };
9972
10033
  }
9973
10034
  if (config.settings.mode === "audit") {
9974
- if (!isIgnoredTool2(toolName)) {
10035
+ if (judge) {
9975
10036
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
9976
10037
  if (policyResult.decision === "review") {
9977
10038
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
@@ -10010,9 +10071,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
10010
10071
  appPermReviewTool = bareTool;
10011
10072
  }
10012
10073
  }
10013
- if (!taintWarning && !isIgnoredTool2(toolName)) {
10074
+ if (!taintWarning && judge) {
10014
10075
  const ld = config.policy.loopDetection;
10015
- if (ld.enabled && !appPermReview) {
10076
+ if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
10016
10077
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
10017
10078
  if (loopResult.looping) {
10018
10079
  const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
@@ -20013,19 +20074,19 @@ function evaluateEgressConfig(egress) {
20013
20074
  function checkEgressFloor(egress) {
20014
20075
  const detail = [
20015
20076
  "the cloud instance-metadata endpoint",
20016
- "link-local, multicast, unspecified and CGNAT (100.64/10) addresses",
20017
- egress.ssrfStrict ? "the strict tier is on: loopback and the private ranges are blocked too" : `the strict tier is off: loopback and the private ranges stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
20077
+ "link-local and multicast addresses",
20078
+ egress.ssrfStrict ? "the strict tier is on: loopback, the private ranges and CGNAT are blocked too" : `the strict tier is off: loopback, the private ranges and CGNAT (100.64/10) stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
20018
20079
  ];
20019
20080
  if (egress.ssrfAllow.length) {
20020
20081
  detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
20021
20082
  }
20022
- detail.push("not covered: WebFetch and MCP fetch tools reach a URL without this gate");
20083
+ detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
20023
20084
  return [
20024
20085
  {
20025
20086
  category: "Egress",
20026
20087
  severity: "advisory",
20027
- title: "The cloud metadata endpoint is blocked in shell commands",
20028
- what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh); a tool that fetches a URL itself does not pass this gate.",
20088
+ title: "The cloud metadata endpoint is blocked",
20089
+ what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh) and tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate); an interpreter one-liner does not reach it.",
20029
20090
  why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
20030
20091
  who: "An agent talked into fetching that address hands over the keys and cannot, here.",
20031
20092
  owner: "node9",
@@ -57072,10 +57133,11 @@ function addEgressHost(list, host) {
57072
57133
  writeEgressRawConfig(config);
57073
57134
  }
57074
57135
  function addSsrfExemption(address) {
57075
- const m = classifySsrf(address);
57136
+ const slash = address.indexOf("/");
57137
+ const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
57076
57138
  if (m && !m.overridable) {
57077
57139
  throw new Error(
57078
- `${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
57140
+ `${address} ${slash === -1 ? "is a protected address" : "covers only protected addresses"} (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
57079
57141
  );
57080
57142
  }
57081
57143
  const config = readEgressRawConfig();
@@ -57347,7 +57409,7 @@ var TOOLS = [
57347
57409
  },
57348
57410
  {
57349
57411
  name: "node9_egress_status",
57350
- description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked in SHELL COMMANDS before any policy is consulted (cloud metadata, link-local, multicast, CGNAT), which tools bypass it, whether the strict tier (loopback + private ranges) is on, and the exemptions in force. Read-only.",
57412
+ description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked before any policy is consulted (cloud metadata, link-local, multicast), the carriers it covers, whether the strict tier (loopback, private ranges, CGNAT) is on, and the exemptions in force. Read-only.",
57351
57413
  inputSchema: { type: "object", properties: {}, required: [] }
57352
57414
  },
57353
57415
  {
@@ -57507,11 +57569,12 @@ function handleEgressStatus() {
57507
57569
  // The SSRF floor. Without these lines an agent reading this answer
57508
57570
  // concludes that internal addresses are reachable, because nothing said
57509
57571
  // otherwise. The LIMITS are here for the same reason and matter more on
57510
- // this surface than on any other: an agent treats this as ground truth,
57511
- // and the first version told it the floor was absolute. It is not. It sees
57512
- // shell commands only, and `node9 pause` suspends it.
57513
- "Protected addresses, IN SHELL COMMANDS ONLY: cloud metadata, link-local, multicast and CGNAT (100.64/10) are blocked before any of the above is consulted. No setting releases them, though `node9 pause` suspends all enforcement.",
57514
- "NOT covered by that: a tool that fetches a URL itself (WebFetch, an MCP fetch tool) does not pass this gate at all.",
57572
+ // this surface than on any other: an agent treats this as ground truth.
57573
+ // The first version said the floor was absolute; the correction said it
57574
+ // was shell-only; both were wrong by the time they were read. What it
57575
+ // actually covers is measured in egress.integration.test.ts.
57576
+ "Protected addresses: cloud metadata, link-local and multicast are blocked before any of the above is consulted, in shell commands AND in tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate). No setting releases them, though `node9 pause` suspends all enforcement.",
57577
+ "NOT covered by that: an interpreter one-liner (node -e, python3 -c) carries its destination inside a program and does not reach this gate. CGNAT (100.64/10) is NOT in the always-blocked set \u2014 it is reachable until the strict tier is on, and an exemption can release it.",
57515
57578
  `Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
57516
57579
  `Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
57517
57580
  ];
@@ -60270,26 +60333,76 @@ function exempt(address) {
60270
60333
  if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
60271
60334
  return guard(() => addSsrfExemption(address));
60272
60335
  }
60336
+ var INTERNAL_FIELDS = {
60337
+ allowed: { ssrfStrict: false, allowPrivate: true },
60338
+ listed: { ssrfStrict: false, allowPrivate: false },
60339
+ blocked: { ssrfStrict: true, allowPrivate: false }
60340
+ };
60341
+ var INTERNAL_SAID = {
60342
+ allowed: "reachable without listing them",
60343
+ listed: "reachable only if they are on your allowlist",
60344
+ blocked: "blocked"
60345
+ };
60346
+ function readInternalState(e) {
60347
+ if (e.ssrfStrict === true) return "blocked";
60348
+ return e.allowPrivate === false ? "listed" : "allowed";
60349
+ }
60350
+ function setInternal(value) {
60351
+ const state = value.trim().toLowerCase();
60352
+ if (!(state in INTERNAL_FIELDS)) {
60353
+ console.error(
60354
+ import_chalk34.default.red(`
60355
+ \u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + import_chalk34.default.gray(
60356
+ "\n allowed loopback, 10/172.16/192.168 and CGNAT are reachable (default)\n listed they are reachable only if you allowlist them\n blocked they are blocked at the floor\n"
60357
+ )
60358
+ );
60359
+ process.exitCode = 1;
60360
+ return;
60361
+ }
60362
+ if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
60363
+ _resetConfigCache();
60364
+ const effective = readInternalState(getConfig().policy.egress);
60365
+ if (effective !== state) {
60366
+ console.log(
60367
+ import_chalk34.default.yellow(
60368
+ `
60369
+ \u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
60370
+ Change it in the dashboard, Enforcement \u2192 Network.
60371
+ `
60372
+ )
60373
+ );
60374
+ return;
60375
+ }
60376
+ const line = `
60377
+ \u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
60378
+ `;
60379
+ console.log(state === "allowed" ? import_chalk34.default.yellow(line) : import_chalk34.default.green(line));
60380
+ }
60273
60381
  function showFloor(e, ssrfStrictSource) {
60274
- console.log(import_chalk34.default.gray("\n Protected addresses") + import_chalk34.default.gray(" \u2014 in shell commands only"));
60382
+ console.log(
60383
+ import_chalk34.default.gray("\n Protected addresses") + import_chalk34.default.gray(" \u2014 in shell commands and declared URLs")
60384
+ );
60275
60385
  console.log(
60276
60386
  import_chalk34.default.gray(
60277
- " always blocked: cloud metadata, link-local, multicast, CGNAT (100.64/10)\n no setting releases these, though `node9 pause` suspends all enforcement"
60387
+ " always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
60278
60388
  )
60279
60389
  );
60280
- const strict = e.ssrfStrict === true;
60390
+ const state = readInternalState(e);
60281
60391
  const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
60392
+ const STATE_LABEL = {
60393
+ allowed: "allowed",
60394
+ listed: "allowlist only",
60395
+ blocked: "blocked"
60396
+ };
60282
60397
  console.log(
60283
- ` Internal addresses: ${strict ? import_chalk34.default.green("on") : import_chalk34.default.yellow("off")}` + import_chalk34.default.gray(
60284
- strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
60285
- )
60398
+ ` Internal addresses: ${state === "blocked" ? import_chalk34.default.green(STATE_LABEL[state]) : import_chalk34.default.yellow(STATE_LABEL[state])}` + import_chalk34.default.gray(` loopback, 10/172.16/192.168 and CGNAT are ${INTERNAL_SAID[state]}`)
60286
60399
  );
60287
60400
  console.log(import_chalk34.default.gray(` set by: ${by}`));
60288
60401
  const exemptions = e.ssrfAllow ?? [];
60289
60402
  console.log(import_chalk34.default.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
60290
60403
  console.log(
60291
60404
  import_chalk34.default.gray(
60292
- " Not covered: an agent tool that fetches a URL itself (WebFetch, an MCP\n fetch tool) does not pass this gate."
60405
+ " Covered: shell commands, and tools that declare a URL (WebFetch, an MCP\n fetch tool, browser navigate).\n Not covered: an interpreter one-liner (node -e, python3 -c) hides its\n destination inside a program and does not reach this gate.\n CGNAT (100.64/10) is NOT in the always-blocked set: it is reachable\n until Internal addresses is on, and an exemption can release it."
60293
60406
  )
60294
60407
  );
60295
60408
  }
@@ -60354,7 +60467,10 @@ function registerEgressCommand(program2) {
60354
60467
  import_chalk34.default.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
60355
60468
  );
60356
60469
  });
60357
- egress.command("strict <on|off>").description("Also block loopback and private ranges (the strict SSRF tier)").action((value) => {
60470
+ egress.command("internal <allowed|listed|blocked>").description(
60471
+ "How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
60472
+ ).action((value) => setInternal(value));
60473
+ egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
60358
60474
  const v = value.trim().toLowerCase();
60359
60475
  if (v !== "on" && v !== "off") {
60360
60476
  console.error(import_chalk34.default.red(`
@@ -60363,38 +60479,40 @@ function registerEgressCommand(program2) {
60363
60479
  process.exitCode = 1;
60364
60480
  return;
60365
60481
  }
60366
- if (!mutate(`egress strict ${v}`, { ssrfStrict: v === "on" })) return;
60367
- _resetConfigCache();
60368
- const effective = getConfig().policy.egress.ssrfStrict === true;
60369
- if (effective !== (v === "on")) {
60370
- console.log(
60371
- import_chalk34.default.yellow(
60372
- `
60373
- \u26A0 Saved, but not in effect: your workspace sets the strict tier ${effective ? "ON" : "OFF"} and that governs this machine.
60374
- Change it in the dashboard, Enforcement \u2192 Network.
60375
- `
60482
+ const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
60483
+ console.log(import_chalk34.default.gray(`
60484
+ (\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
60485
+ setInternal(state);
60486
+ });
60487
+ egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
60488
+ const a = normalizeEgressHost(address);
60489
+ const slash = a.indexOf("/");
60490
+ const base = slash === -1 ? a : a.slice(0, slash);
60491
+ const prefixText = slash === -1 ? null : a.slice(slash + 1);
60492
+ if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
60493
+ console.error(
60494
+ import_chalk34.default.red(`
60495
+ \u2717 "${address}" is not an address or a range.`) + import_chalk34.default.gray(
60496
+ "\n Exemptions are matched as an address (10.0.0.5) or a CIDR\n range (100.64.0.0/10), never a name.\n"
60376
60497
  )
60377
60498
  );
60499
+ process.exitCode = 1;
60378
60500
  return;
60379
60501
  }
60380
- console.log(
60381
- v === "on" ? import_chalk34.default.green("\n \u2713 Strict tier on \u2014 loopback and private ranges are blocked.\n") : import_chalk34.default.yellow("\n \u2713 Strict tier off \u2014 loopback and private ranges are reachable.\n")
60382
- );
60383
- });
60384
- egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
60385
- const a = normalizeEgressHost(address);
60386
- if (!normalizeIpLiteral(a)) {
60502
+ const m = classifySsrf(base);
60503
+ if (m && !m.overridable) {
60387
60504
  console.error(
60388
60505
  import_chalk34.default.red(`
60389
- \u2717 "${address}" is not an address.`) + import_chalk34.default.gray(
60390
- "\n Exemptions are matched as one exact address, not a name or a range.\n"
60506
+ \u2717 ${a} cannot be exempted.`) + import_chalk34.default.gray(
60507
+ `
60508
+ ${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
60509
+ `
60391
60510
  )
60392
60511
  );
60393
60512
  process.exitCode = 1;
60394
60513
  return;
60395
60514
  }
60396
60515
  if (!exempt(a)) return;
60397
- const m = classifySsrf(a);
60398
60516
  console.log(import_chalk34.default.green(`
60399
60517
  \u2713 ${a} is exempt from the floor.`));
60400
60518
  if (!m)