@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/index.js CHANGED
@@ -3630,6 +3630,38 @@ function classifySsrf(host) {
3630
3630
  return null;
3631
3631
  }
3632
3632
  }
3633
+ function ssrfExemptMatches(entries, normalized) {
3634
+ if (!entries?.length || !normalized) return false;
3635
+ const target = bitsOf(normalized);
3636
+ for (const raw of entries) {
3637
+ const entry = raw.trim().toLowerCase();
3638
+ if (!entry) continue;
3639
+ const slash = entry.indexOf("/");
3640
+ if (slash === -1) {
3641
+ if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
3642
+ continue;
3643
+ }
3644
+ if (!target) continue;
3645
+ const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
3646
+ const prefixText = entry.slice(slash + 1);
3647
+ const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
3648
+ if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
3649
+ // differ, so `0.0.0.0/0` does not release `::1`.
3650
+ base.length !== target.length || prefix > base.length) {
3651
+ continue;
3652
+ }
3653
+ if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
3654
+ }
3655
+ return false;
3656
+ }
3657
+ function bitsOf(normalized) {
3658
+ const o = v4Octets(normalized);
3659
+ if (o) {
3660
+ return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
3661
+ }
3662
+ const g = expandIpv6(normalized);
3663
+ return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
3664
+ }
3633
3665
  var TIER_REASON = {
3634
3666
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3635
3667
  "link-local": "a link-local address",
@@ -3639,14 +3671,11 @@ var TIER_REASON = {
3639
3671
  private: "a loopback or private address"
3640
3672
  };
3641
3673
  function ssrfFloor(tokens, opts = {}) {
3642
- const exempt = new Set(
3643
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3644
- );
3645
3674
  for (const { token, binary } of tokens) {
3646
3675
  const m = classifySsrf(token);
3647
3676
  if (!m) continue;
3648
3677
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3649
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3678
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3650
3679
  return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3651
3680
  }
3652
3681
  return null;
@@ -3675,18 +3704,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3675
3704
  "*.ubuntu.com"
3676
3705
  ];
3677
3706
  function hostMatches(host, pattern) {
3678
- const h = host.toLowerCase();
3679
- const p = pattern.toLowerCase().trim();
3707
+ const p = pattern.trim().toLowerCase();
3680
3708
  if (!p) return false;
3681
3709
  if (p === "*") return true;
3710
+ const h = canonicalHost(host);
3682
3711
  if (p.startsWith("*.")) {
3683
- const suffix = p.slice(2);
3712
+ const suffix = canonicalHost(p.slice(2));
3684
3713
  return h === suffix || h.endsWith("." + suffix);
3685
3714
  }
3686
- return h === p;
3715
+ return h === canonicalHost(p);
3687
3716
  }
3688
3717
  function matchesAny(host, patterns) {
3689
- for (const p of patterns) if (hostMatches(host, p)) return true;
3718
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3690
3719
  return false;
3691
3720
  }
3692
3721
  var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
@@ -3697,13 +3726,17 @@ function isUniqueLocalV6(host) {
3697
3726
  return g !== null && (g[0] & 65024) === 64512;
3698
3727
  }
3699
3728
  function isPrivateHost(host) {
3700
- const h = host.trim().toLowerCase();
3729
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3701
3730
  const m = classifySsrf(h);
3702
3731
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3703
3732
  if (h === "localhost") return true;
3704
3733
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3705
3734
  return isUniqueLocalV6(h);
3706
3735
  }
3736
+ function canonicalHost(host) {
3737
+ const ip = normalizeIpLiteral(host);
3738
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3739
+ }
3707
3740
  function evaluateEgress(dests, policy) {
3708
3741
  if (!policy.enabled) return null;
3709
3742
  let review = null;
@@ -4085,11 +4118,26 @@ function hostOf(value) {
4085
4118
  return null;
4086
4119
  }
4087
4120
  }
4121
+ function extractToolDestinations(toolName, args) {
4122
+ try {
4123
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4124
+ if (!paths) return [];
4125
+ const out = [];
4126
+ for (const path16 of paths) {
4127
+ for (const value of valuesAt(args, path16)) {
4128
+ const host = hostOf(value);
4129
+ if (host) out.push({ host, binary: toolName, raw: value });
4130
+ }
4131
+ }
4132
+ return out;
4133
+ } catch {
4134
+ return [];
4135
+ }
4136
+ }
4088
4137
  function ssrfDestinationFloor(toolName, args, opts = {}) {
4089
4138
  try {
4090
4139
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4091
4140
  if (!paths) return null;
4092
- const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
4093
4141
  for (const path16 of paths) {
4094
4142
  for (const value of valuesAt(args, path16)) {
4095
4143
  const host = hostOf(value);
@@ -4097,7 +4145,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
4097
4145
  const m = classifySsrf(host);
4098
4146
  if (!m) continue;
4099
4147
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
4100
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
4148
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
4101
4149
  return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
4102
4150
  }
4103
4151
  }
@@ -4192,6 +4240,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
4192
4240
  tier: 3
4193
4241
  };
4194
4242
  }
4243
+ function egressPolicyVerdict(eg) {
4244
+ return {
4245
+ decision: eg.verdict,
4246
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4247
+ reason: eg.reason,
4248
+ ruleName: `egress:${eg.binary}:${eg.host}`,
4249
+ ruleDescription: eg.reason,
4250
+ tier: eg.verdict === "block" ? 3 : 4
4251
+ };
4252
+ }
4195
4253
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
4196
4254
  const { agent, cwd, activeEnvironment } = context;
4197
4255
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -4226,7 +4284,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4226
4284
  };
4227
4285
  }
4228
4286
  }
4229
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
4287
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
4288
+ const dests = extractToolDestinations(toolName, args);
4289
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4290
+ return eg ? egressPolicyVerdict(eg) : void 0;
4291
+ })() : void 0;
4292
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
4293
+ return pendingToolEgress ?? { decision: "allow" };
4294
+ }
4230
4295
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
4231
4296
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
4232
4297
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -4315,6 +4380,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4315
4380
  }
4316
4381
  let allTokens = [];
4317
4382
  let pathTokens = [];
4383
+ if (pendingToolEgress) return pendingToolEgress;
4318
4384
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
4319
4385
  if (shellCommand) {
4320
4386
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4380,16 +4446,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4380
4446
  const dests = extractShellDestinations(shellCommand);
4381
4447
  if (dests.length > 0) {
4382
4448
  const eg = evaluateEgress(dests, config.policy.egress);
4383
- if (eg) {
4384
- return {
4385
- decision: eg.verdict,
4386
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4387
- reason: eg.reason,
4388
- ruleName: `egress:${eg.binary}:${eg.host}`,
4389
- ruleDescription: eg.reason,
4390
- tier: eg.verdict === "block" ? 3 : 4
4391
- };
4392
- }
4449
+ if (eg) return egressPolicyVerdict(eg);
4393
4450
  }
4394
4451
  }
4395
4452
  const firstToken = analyzed.actions[0] ?? "";
@@ -5921,10 +5978,12 @@ function safeApiUrl(raw, onReject) {
5921
5978
  function sanitizeSsrfAllow(entries, source) {
5922
5979
  const kept = [];
5923
5980
  for (const entry of entries) {
5924
- const m = classifySsrf(entry);
5981
+ const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
5982
+ const m = classifySsrf(base);
5925
5983
  if (m && !m.overridable) {
5984
+ const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
5926
5985
  process.emitWarning(
5927
- `[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
5986
+ `[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
5928
5987
  );
5929
5988
  continue;
5930
5989
  }
@@ -8329,8 +8388,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8329
8388
  };
8330
8389
  }
8331
8390
  }
8391
+ const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
8392
+ const judge = !isIgnoredTool2(toolName) || declaredEgress;
8332
8393
  if (isObserveMode) {
8333
- if (!isIgnoredTool2(toolName)) {
8394
+ if (judge) {
8334
8395
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8335
8396
  const wouldBlock = policyResult.decision === "block";
8336
8397
  if (!isManual)
@@ -8355,7 +8416,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8355
8416
  return { approved: true, checkedBy: "audit" };
8356
8417
  }
8357
8418
  if (config.settings.mode === "audit") {
8358
- if (!isIgnoredTool2(toolName)) {
8419
+ if (judge) {
8359
8420
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8360
8421
  if (policyResult.decision === "review") {
8361
8422
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
@@ -8394,9 +8455,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8394
8455
  appPermReviewTool = bareTool;
8395
8456
  }
8396
8457
  }
8397
- if (!taintWarning && !isIgnoredTool2(toolName)) {
8458
+ if (!taintWarning && judge) {
8398
8459
  const ld = config.policy.loopDetection;
8399
- if (ld.enabled && !appPermReview) {
8460
+ if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
8400
8461
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
8401
8462
  if (loopResult.looping) {
8402
8463
  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?`;
package/dist/index.mjs CHANGED
@@ -3600,6 +3600,38 @@ function classifySsrf(host) {
3600
3600
  return null;
3601
3601
  }
3602
3602
  }
3603
+ function ssrfExemptMatches(entries, normalized) {
3604
+ if (!entries?.length || !normalized) return false;
3605
+ const target = bitsOf(normalized);
3606
+ for (const raw of entries) {
3607
+ const entry = raw.trim().toLowerCase();
3608
+ if (!entry) continue;
3609
+ const slash = entry.indexOf("/");
3610
+ if (slash === -1) {
3611
+ if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
3612
+ continue;
3613
+ }
3614
+ if (!target) continue;
3615
+ const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
3616
+ const prefixText = entry.slice(slash + 1);
3617
+ const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
3618
+ if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
3619
+ // differ, so `0.0.0.0/0` does not release `::1`.
3620
+ base.length !== target.length || prefix > base.length) {
3621
+ continue;
3622
+ }
3623
+ if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
3624
+ }
3625
+ return false;
3626
+ }
3627
+ function bitsOf(normalized) {
3628
+ const o = v4Octets(normalized);
3629
+ if (o) {
3630
+ return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
3631
+ }
3632
+ const g = expandIpv6(normalized);
3633
+ return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
3634
+ }
3603
3635
  var TIER_REASON = {
3604
3636
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3605
3637
  "link-local": "a link-local address",
@@ -3609,14 +3641,11 @@ var TIER_REASON = {
3609
3641
  private: "a loopback or private address"
3610
3642
  };
3611
3643
  function ssrfFloor(tokens, opts = {}) {
3612
- const exempt = new Set(
3613
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3614
- );
3615
3644
  for (const { token, binary } of tokens) {
3616
3645
  const m = classifySsrf(token);
3617
3646
  if (!m) continue;
3618
3647
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3619
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3648
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
3620
3649
  return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3621
3650
  }
3622
3651
  return null;
@@ -3645,18 +3674,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3645
3674
  "*.ubuntu.com"
3646
3675
  ];
3647
3676
  function hostMatches(host, pattern) {
3648
- const h = host.toLowerCase();
3649
- const p = pattern.toLowerCase().trim();
3677
+ const p = pattern.trim().toLowerCase();
3650
3678
  if (!p) return false;
3651
3679
  if (p === "*") return true;
3680
+ const h = canonicalHost(host);
3652
3681
  if (p.startsWith("*.")) {
3653
- const suffix = p.slice(2);
3682
+ const suffix = canonicalHost(p.slice(2));
3654
3683
  return h === suffix || h.endsWith("." + suffix);
3655
3684
  }
3656
- return h === p;
3685
+ return h === canonicalHost(p);
3657
3686
  }
3658
3687
  function matchesAny(host, patterns) {
3659
- for (const p of patterns) if (hostMatches(host, p)) return true;
3688
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3660
3689
  return false;
3661
3690
  }
3662
3691
  var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
@@ -3667,13 +3696,17 @@ function isUniqueLocalV6(host) {
3667
3696
  return g !== null && (g[0] & 65024) === 64512;
3668
3697
  }
3669
3698
  function isPrivateHost(host) {
3670
- const h = host.trim().toLowerCase();
3699
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3671
3700
  const m = classifySsrf(h);
3672
3701
  if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3673
3702
  if (h === "localhost") return true;
3674
3703
  if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3675
3704
  return isUniqueLocalV6(h);
3676
3705
  }
3706
+ function canonicalHost(host) {
3707
+ const ip = normalizeIpLiteral(host);
3708
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3709
+ }
3677
3710
  function evaluateEgress(dests, policy) {
3678
3711
  if (!policy.enabled) return null;
3679
3712
  let review = null;
@@ -4055,11 +4088,26 @@ function hostOf(value) {
4055
4088
  return null;
4056
4089
  }
4057
4090
  }
4091
+ function extractToolDestinations(toolName, args) {
4092
+ try {
4093
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4094
+ if (!paths) return [];
4095
+ const out = [];
4096
+ for (const path16 of paths) {
4097
+ for (const value of valuesAt(args, path16)) {
4098
+ const host = hostOf(value);
4099
+ if (host) out.push({ host, binary: toolName, raw: value });
4100
+ }
4101
+ }
4102
+ return out;
4103
+ } catch {
4104
+ return [];
4105
+ }
4106
+ }
4058
4107
  function ssrfDestinationFloor(toolName, args, opts = {}) {
4059
4108
  try {
4060
4109
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4061
4110
  if (!paths) return null;
4062
- const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
4063
4111
  for (const path16 of paths) {
4064
4112
  for (const value of valuesAt(args, path16)) {
4065
4113
  const host = hostOf(value);
@@ -4067,7 +4115,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
4067
4115
  const m = classifySsrf(host);
4068
4116
  if (!m) continue;
4069
4117
  if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
4070
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
4118
+ if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
4071
4119
  return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
4072
4120
  }
4073
4121
  }
@@ -4162,6 +4210,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
4162
4210
  tier: 3
4163
4211
  };
4164
4212
  }
4213
+ function egressPolicyVerdict(eg) {
4214
+ return {
4215
+ decision: eg.verdict,
4216
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4217
+ reason: eg.reason,
4218
+ ruleName: `egress:${eg.binary}:${eg.host}`,
4219
+ ruleDescription: eg.reason,
4220
+ tier: eg.verdict === "block" ? 3 : 4
4221
+ };
4222
+ }
4165
4223
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
4166
4224
  const { agent, cwd, activeEnvironment } = context;
4167
4225
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -4196,7 +4254,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4196
4254
  };
4197
4255
  }
4198
4256
  }
4199
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
4257
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
4258
+ const dests = extractToolDestinations(toolName, args);
4259
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4260
+ return eg ? egressPolicyVerdict(eg) : void 0;
4261
+ })() : void 0;
4262
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
4263
+ return pendingToolEgress ?? { decision: "allow" };
4264
+ }
4200
4265
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
4201
4266
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
4202
4267
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -4285,6 +4350,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4285
4350
  }
4286
4351
  let allTokens = [];
4287
4352
  let pathTokens = [];
4353
+ if (pendingToolEgress) return pendingToolEgress;
4288
4354
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
4289
4355
  if (shellCommand) {
4290
4356
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4350,16 +4416,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4350
4416
  const dests = extractShellDestinations(shellCommand);
4351
4417
  if (dests.length > 0) {
4352
4418
  const eg = evaluateEgress(dests, config.policy.egress);
4353
- if (eg) {
4354
- return {
4355
- decision: eg.verdict,
4356
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4357
- reason: eg.reason,
4358
- ruleName: `egress:${eg.binary}:${eg.host}`,
4359
- ruleDescription: eg.reason,
4360
- tier: eg.verdict === "block" ? 3 : 4
4361
- };
4362
- }
4419
+ if (eg) return egressPolicyVerdict(eg);
4363
4420
  }
4364
4421
  }
4365
4422
  const firstToken = analyzed.actions[0] ?? "";
@@ -5891,10 +5948,12 @@ function safeApiUrl(raw, onReject) {
5891
5948
  function sanitizeSsrfAllow(entries, source) {
5892
5949
  const kept = [];
5893
5950
  for (const entry of entries) {
5894
- const m = classifySsrf(entry);
5951
+ const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
5952
+ const m = classifySsrf(base);
5895
5953
  if (m && !m.overridable) {
5954
+ const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
5896
5955
  process.emitWarning(
5897
- `[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
5956
+ `[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
5898
5957
  );
5899
5958
  continue;
5900
5959
  }
@@ -8299,8 +8358,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8299
8358
  };
8300
8359
  }
8301
8360
  }
8361
+ const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
8362
+ const judge = !isIgnoredTool2(toolName) || declaredEgress;
8302
8363
  if (isObserveMode) {
8303
- if (!isIgnoredTool2(toolName)) {
8364
+ if (judge) {
8304
8365
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8305
8366
  const wouldBlock = policyResult.decision === "block";
8306
8367
  if (!isManual)
@@ -8325,7 +8386,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8325
8386
  return { approved: true, checkedBy: "audit" };
8326
8387
  }
8327
8388
  if (config.settings.mode === "audit") {
8328
- if (!isIgnoredTool2(toolName)) {
8389
+ if (judge) {
8329
8390
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8330
8391
  if (policyResult.decision === "review") {
8331
8392
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
@@ -8364,9 +8425,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8364
8425
  appPermReviewTool = bareTool;
8365
8426
  }
8366
8427
  }
8367
- if (!taintWarning && !isIgnoredTool2(toolName)) {
8428
+ if (!taintWarning && judge) {
8368
8429
  const ld = config.policy.loopDetection;
8369
- if (ld.enabled && !appPermReview) {
8430
+ if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
8370
8431
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
8371
8432
  if (loopResult.looping) {
8372
8433
  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?`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "2.16.2",
3
+ "version": "2.18.0",
4
4
  "description": "IAM for your AI agents. Set what Claude Code, Codex, Gemini, Cursor and any MCP server are allowed to do, review risky actions before they run, and keep every action on the record.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",