@node9/proxy 2.16.1 → 2.17.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.mjs CHANGED
@@ -3416,6 +3416,211 @@ function analyzeShellCommand(command) {
3416
3416
  }
3417
3417
  return { actions, paths, allTokens };
3418
3418
  }
3419
+ var SSRF_MAX_HOST = 253;
3420
+ function parseComponent(s) {
3421
+ if (!s) return null;
3422
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
3423
+ if (s === "0") return 0;
3424
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
3425
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
3426
+ return null;
3427
+ }
3428
+ function parseIpv4(input) {
3429
+ let s = input;
3430
+ if (s.endsWith(".")) s = s.slice(0, -1);
3431
+ if (!s) return null;
3432
+ const parts = s.split(".");
3433
+ if (parts.length > 4) return null;
3434
+ const vals = [];
3435
+ for (const p of parts) {
3436
+ const v = parseComponent(p);
3437
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
3438
+ vals.push(v);
3439
+ }
3440
+ const n = vals.length;
3441
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
3442
+ const last = vals[n - 1];
3443
+ const remainingBytes = 4 - (n - 1);
3444
+ const limit = Math.pow(256, remainingBytes);
3445
+ if (last >= limit) return null;
3446
+ let value = last;
3447
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
3448
+ if (value > 4294967295) return null;
3449
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
3450
+ }
3451
+ function expandIpv6(input) {
3452
+ const s = input.toLowerCase();
3453
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
3454
+ if ((s.match(/::/g) ?? []).length > 1) return null;
3455
+ let head = s;
3456
+ let tailV4 = null;
3457
+ const lastColon = s.lastIndexOf(":");
3458
+ const afterLast = s.slice(lastColon + 1);
3459
+ if (afterLast.includes(".")) {
3460
+ const dotted = parseIpv4(afterLast);
3461
+ if (!dotted) return null;
3462
+ const o = dotted.split(".").map(Number);
3463
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
3464
+ head = s.slice(0, lastColon + 1) + "0";
3465
+ }
3466
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
3467
+ const toGroups = (part) => {
3468
+ if (!part) return [];
3469
+ const out = [];
3470
+ for (const g of part.split(":")) {
3471
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
3472
+ out.push(parseInt(g, 16));
3473
+ }
3474
+ return out;
3475
+ };
3476
+ const left = toGroups(lhs);
3477
+ if (left === null) return null;
3478
+ let right = [];
3479
+ if (rhs !== null) {
3480
+ const r = toGroups(rhs);
3481
+ if (r === null) return null;
3482
+ right = r;
3483
+ }
3484
+ if (tailV4) {
3485
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3486
+ else left.splice(left.length - 1, 1, ...tailV4);
3487
+ }
3488
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3489
+ if (rhs === null && groups.length !== 8) return null;
3490
+ if (rhs !== null && left.length + right.length > 8) return null;
3491
+ if (groups.length !== 8) return null;
3492
+ return groups;
3493
+ }
3494
+ function compressIpv6(g) {
3495
+ let bestStart = -1;
3496
+ let bestLen = 0;
3497
+ let i = 0;
3498
+ while (i < 8) {
3499
+ if (g[i] !== 0) {
3500
+ i++;
3501
+ continue;
3502
+ }
3503
+ let j = i;
3504
+ while (j < 8 && g[j] === 0) j++;
3505
+ if (j - i > bestLen) {
3506
+ bestLen = j - i;
3507
+ bestStart = i;
3508
+ }
3509
+ i = j;
3510
+ }
3511
+ const hex = g.map((x) => x.toString(16));
3512
+ if (bestLen < 2) return hex.join(":");
3513
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3514
+ }
3515
+ function normalizeIpLiteral(host) {
3516
+ try {
3517
+ if (typeof host !== "string") return null;
3518
+ let s = host.trim();
3519
+ if (!s || s.length > SSRF_MAX_HOST) return null;
3520
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3521
+ const zone = s.indexOf("%");
3522
+ if (zone >= 0) s = s.slice(0, zone);
3523
+ if (!s) return null;
3524
+ if (s.includes(":")) {
3525
+ const g = expandIpv6(s);
3526
+ if (!g) return null;
3527
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3528
+ if (mapped) {
3529
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3530
+ }
3531
+ return compressIpv6(g);
3532
+ }
3533
+ return parseIpv4(s);
3534
+ } catch {
3535
+ return null;
3536
+ }
3537
+ }
3538
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3539
+ "169.254.169.254",
3540
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
3541
+ "169.254.170.2",
3542
+ // AWS ECS task role
3543
+ "168.63.129.16",
3544
+ // Azure WireServer
3545
+ "fd00:ec2::254",
3546
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3547
+ // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
3548
+ // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
3549
+ // live credential endpoint. It has to be named here, above the range check,
3550
+ // and it is the reason relaxing cgnat is safe.
3551
+ "100.100.100.200"
3552
+ ]);
3553
+ var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
3554
+ function isStrictGatedTier(tier) {
3555
+ return STRICT_TIERS.has(tier);
3556
+ }
3557
+ function ssrfReason(m, asWritten) {
3558
+ return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
3559
+ }
3560
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3561
+ var v4Octets = (a) => {
3562
+ const p = a.split(".");
3563
+ return p.length === 4 ? p.map(Number) : null;
3564
+ };
3565
+ function classifySsrf(host) {
3566
+ try {
3567
+ if (typeof host !== "string" || !host) return null;
3568
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
3569
+ const ip = normalizeIpLiteral(host);
3570
+ if (ip === null) {
3571
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3572
+ }
3573
+ const hit = (tier, overridable) => ({
3574
+ tier,
3575
+ overridable,
3576
+ kind: "address",
3577
+ normalized: ip
3578
+ });
3579
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3580
+ const o = v4Octets(ip);
3581
+ if (o) {
3582
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
3583
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3584
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3585
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3586
+ if (o[0] === 127) return hit("private", true);
3587
+ if (o[0] === 10) return hit("private", true);
3588
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
3589
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3590
+ return null;
3591
+ }
3592
+ const g = expandIpv6(ip);
3593
+ if (!g) return null;
3594
+ if (g.every((x) => x === 0)) return hit("unspecified", true);
3595
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
3596
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
3597
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3598
+ return null;
3599
+ } catch {
3600
+ return null;
3601
+ }
3602
+ }
3603
+ var TIER_REASON = {
3604
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3605
+ "link-local": "a link-local address",
3606
+ multicast: "a multicast address",
3607
+ unspecified: "the unspecified address, which reaches this host",
3608
+ cgnat: "a carrier-grade NAT address",
3609
+ private: "a loopback or private address"
3610
+ };
3611
+ function ssrfFloor(tokens, opts = {}) {
3612
+ const exempt = new Set(
3613
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3614
+ );
3615
+ for (const { token, binary } of tokens) {
3616
+ const m = classifySsrf(token);
3617
+ if (!m) continue;
3618
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3619
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3620
+ return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3621
+ }
3622
+ return null;
3623
+ }
3419
3624
  var DEFAULT_EGRESS_ALLOWLIST = [
3420
3625
  // node9's own control plane (api, app, dev-api, staging and the apex).
3421
3626
  // Without it, turning egress on asks the user to approve node9 itself.
@@ -3440,29 +3645,38 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3440
3645
  "*.ubuntu.com"
3441
3646
  ];
3442
3647
  function hostMatches(host, pattern) {
3443
- const h = host.toLowerCase();
3444
- const p = pattern.toLowerCase().trim();
3648
+ const p = pattern.trim().toLowerCase();
3445
3649
  if (!p) return false;
3446
3650
  if (p === "*") return true;
3651
+ const h = canonicalHost(host);
3447
3652
  if (p.startsWith("*.")) {
3448
- const suffix = p.slice(2);
3653
+ const suffix = canonicalHost(p.slice(2));
3449
3654
  return h === suffix || h.endsWith("." + suffix);
3450
3655
  }
3451
- return h === p;
3656
+ return h === canonicalHost(p);
3452
3657
  }
3453
3658
  function matchesAny(host, patterns) {
3454
- for (const p of patterns) if (hostMatches(host, p)) return true;
3659
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3455
3660
  return false;
3456
3661
  }
3662
+ var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
3663
+ function isUniqueLocalV6(host) {
3664
+ const ip = normalizeIpLiteral(host);
3665
+ if (!ip || !ip.includes(":")) return false;
3666
+ const g = expandIpv6(ip);
3667
+ return g !== null && (g[0] & 65024) === 64512;
3668
+ }
3457
3669
  function isPrivateHost(host) {
3458
- const h = host.toLowerCase();
3459
- if (h === "localhost" || h === "0.0.0.0") return true;
3460
- if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
3461
- if (/^127\./.test(h)) return true;
3462
- if (/^10\./.test(h)) return true;
3463
- if (/^192\.168\./.test(h)) return true;
3464
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
3465
- return false;
3670
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3671
+ const m = classifySsrf(h);
3672
+ if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3673
+ if (h === "localhost") return true;
3674
+ if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3675
+ return isUniqueLocalV6(h);
3676
+ }
3677
+ function canonicalHost(host) {
3678
+ const ip = normalizeIpLiteral(host);
3679
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3466
3680
  }
3467
3681
  function evaluateEgress(dests, policy) {
3468
3682
  if (!policy.enabled) return null;
@@ -3805,211 +4019,6 @@ function extractAllSshHosts(tokens) {
3805
4019
  }
3806
4020
  return [...hosts].filter(Boolean);
3807
4021
  }
3808
- var SSRF_MAX_HOST = 253;
3809
- function parseComponent(s) {
3810
- if (!s) return null;
3811
- if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
3812
- if (s === "0") return 0;
3813
- if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
3814
- if (/^[1-9][0-9]*$/.test(s)) return Number(s);
3815
- return null;
3816
- }
3817
- function parseIpv4(input) {
3818
- let s = input;
3819
- if (s.endsWith(".")) s = s.slice(0, -1);
3820
- if (!s) return null;
3821
- const parts = s.split(".");
3822
- if (parts.length > 4) return null;
3823
- const vals = [];
3824
- for (const p of parts) {
3825
- const v = parseComponent(p);
3826
- if (v === null || !Number.isFinite(v) || v < 0) return null;
3827
- vals.push(v);
3828
- }
3829
- const n = vals.length;
3830
- for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
3831
- const last = vals[n - 1];
3832
- const remainingBytes = 4 - (n - 1);
3833
- const limit = Math.pow(256, remainingBytes);
3834
- if (last >= limit) return null;
3835
- let value = last;
3836
- for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
3837
- if (value > 4294967295) return null;
3838
- return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
3839
- }
3840
- function expandIpv6(input) {
3841
- const s = input.toLowerCase();
3842
- if (!/^[0-9a-f:.]+$/.test(s)) return null;
3843
- if ((s.match(/::/g) ?? []).length > 1) return null;
3844
- let head = s;
3845
- let tailV4 = null;
3846
- const lastColon = s.lastIndexOf(":");
3847
- const afterLast = s.slice(lastColon + 1);
3848
- if (afterLast.includes(".")) {
3849
- const dotted = parseIpv4(afterLast);
3850
- if (!dotted) return null;
3851
- const o = dotted.split(".").map(Number);
3852
- tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
3853
- head = s.slice(0, lastColon + 1) + "0";
3854
- }
3855
- const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
3856
- const toGroups = (part) => {
3857
- if (!part) return [];
3858
- const out = [];
3859
- for (const g of part.split(":")) {
3860
- if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
3861
- out.push(parseInt(g, 16));
3862
- }
3863
- return out;
3864
- };
3865
- const left = toGroups(lhs);
3866
- if (left === null) return null;
3867
- let right = [];
3868
- if (rhs !== null) {
3869
- const r = toGroups(rhs);
3870
- if (r === null) return null;
3871
- right = r;
3872
- }
3873
- if (tailV4) {
3874
- if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3875
- else left.splice(left.length - 1, 1, ...tailV4);
3876
- }
3877
- const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3878
- if (rhs === null && groups.length !== 8) return null;
3879
- if (rhs !== null && left.length + right.length > 8) return null;
3880
- if (groups.length !== 8) return null;
3881
- return groups;
3882
- }
3883
- function compressIpv6(g) {
3884
- let bestStart = -1;
3885
- let bestLen = 0;
3886
- let i = 0;
3887
- while (i < 8) {
3888
- if (g[i] !== 0) {
3889
- i++;
3890
- continue;
3891
- }
3892
- let j = i;
3893
- while (j < 8 && g[j] === 0) j++;
3894
- if (j - i > bestLen) {
3895
- bestLen = j - i;
3896
- bestStart = i;
3897
- }
3898
- i = j;
3899
- }
3900
- const hex = g.map((x) => x.toString(16));
3901
- if (bestLen < 2) return hex.join(":");
3902
- return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3903
- }
3904
- function normalizeIpLiteral(host) {
3905
- try {
3906
- if (typeof host !== "string") return null;
3907
- let s = host.trim();
3908
- if (!s || s.length > SSRF_MAX_HOST) return null;
3909
- if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3910
- const zone = s.indexOf("%");
3911
- if (zone >= 0) s = s.slice(0, zone);
3912
- if (!s) return null;
3913
- if (s.includes(":")) {
3914
- const g = expandIpv6(s);
3915
- if (!g) return null;
3916
- const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3917
- if (mapped) {
3918
- return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3919
- }
3920
- return compressIpv6(g);
3921
- }
3922
- return parseIpv4(s);
3923
- } catch {
3924
- return null;
3925
- }
3926
- }
3927
- var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3928
- "169.254.169.254",
3929
- // AWS / Azure / DigitalOcean / OpenStack IMDS
3930
- "169.254.170.2",
3931
- // AWS ECS task role
3932
- "168.63.129.16",
3933
- // Azure WireServer
3934
- "fd00:ec2::254",
3935
- // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3936
- // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
3937
- // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
3938
- // live credential endpoint. It has to be named here, above the range check,
3939
- // and it is the reason relaxing cgnat is safe.
3940
- "100.100.100.200"
3941
- ]);
3942
- var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
3943
- function isStrictGatedTier(tier) {
3944
- return STRICT_TIERS.has(tier);
3945
- }
3946
- function ssrfReason(m, asWritten) {
3947
- return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
3948
- }
3949
- var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3950
- var v4Octets = (a) => {
3951
- const p = a.split(".");
3952
- return p.length === 4 ? p.map(Number) : null;
3953
- };
3954
- function classifySsrf(host) {
3955
- try {
3956
- if (typeof host !== "string" || !host) return null;
3957
- const lower = host.trim().toLowerCase().replace(/\.$/, "");
3958
- const ip = normalizeIpLiteral(host);
3959
- if (ip === null) {
3960
- return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3961
- }
3962
- const hit = (tier, overridable) => ({
3963
- tier,
3964
- overridable,
3965
- kind: "address",
3966
- normalized: ip
3967
- });
3968
- if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3969
- const o = v4Octets(ip);
3970
- if (o) {
3971
- if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
3972
- if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3973
- if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3974
- if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3975
- if (o[0] === 127) return hit("private", true);
3976
- if (o[0] === 10) return hit("private", true);
3977
- if (o[0] === 192 && o[1] === 168) return hit("private", true);
3978
- if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3979
- return null;
3980
- }
3981
- const g = expandIpv6(ip);
3982
- if (!g) return null;
3983
- if (g.every((x) => x === 0)) return hit("unspecified", true);
3984
- if ((g[0] & 65472) === 65152) return hit("link-local", false);
3985
- if ((g[0] & 65280) === 65280) return hit("multicast", false);
3986
- if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3987
- return null;
3988
- } catch {
3989
- return null;
3990
- }
3991
- }
3992
- var TIER_REASON = {
3993
- metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3994
- "link-local": "a link-local address",
3995
- multicast: "a multicast address",
3996
- unspecified: "the unspecified address, which reaches this host",
3997
- cgnat: "a carrier-grade NAT address",
3998
- private: "a loopback or private address"
3999
- };
4000
- function ssrfFloor(tokens, opts = {}) {
4001
- const exempt = new Set(
4002
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
4003
- );
4004
- for (const { token, binary } of tokens) {
4005
- const m = classifySsrf(token);
4006
- if (!m) continue;
4007
- if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
4008
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
4009
- return { ...m, host: token, binary, reason: ssrfReason(m, token) };
4010
- }
4011
- return null;
4012
- }
4013
4022
  var DESTINATION_ARGS = /* @__PURE__ */ new Map([
4014
4023
  ["webfetch", ["url"]],
4015
4024
  ["fetch", ["url", "uri"]],
@@ -4050,6 +4059,22 @@ function hostOf(value) {
4050
4059
  return null;
4051
4060
  }
4052
4061
  }
4062
+ function extractToolDestinations(toolName, args) {
4063
+ try {
4064
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
4065
+ if (!paths) return [];
4066
+ const out = [];
4067
+ for (const path16 of paths) {
4068
+ for (const value of valuesAt(args, path16)) {
4069
+ const host = hostOf(value);
4070
+ if (host) out.push({ host, binary: toolName, raw: value });
4071
+ }
4072
+ }
4073
+ return out;
4074
+ } catch {
4075
+ return [];
4076
+ }
4077
+ }
4053
4078
  function ssrfDestinationFloor(toolName, args, opts = {}) {
4054
4079
  try {
4055
4080
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
@@ -4157,6 +4182,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
4157
4182
  tier: 3
4158
4183
  };
4159
4184
  }
4185
+ function egressPolicyVerdict(eg) {
4186
+ return {
4187
+ decision: eg.verdict,
4188
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4189
+ reason: eg.reason,
4190
+ ruleName: `egress:${eg.binary}:${eg.host}`,
4191
+ ruleDescription: eg.reason,
4192
+ tier: eg.verdict === "block" ? 3 : 4
4193
+ };
4194
+ }
4160
4195
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
4161
4196
  const { agent, cwd, activeEnvironment } = context;
4162
4197
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -4191,7 +4226,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4191
4226
  };
4192
4227
  }
4193
4228
  }
4194
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
4229
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
4230
+ const dests = extractToolDestinations(toolName, args);
4231
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
4232
+ return eg ? egressPolicyVerdict(eg) : void 0;
4233
+ })() : void 0;
4234
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
4235
+ return pendingToolEgress ?? { decision: "allow" };
4236
+ }
4195
4237
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
4196
4238
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
4197
4239
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -4280,6 +4322,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4280
4322
  }
4281
4323
  let allTokens = [];
4282
4324
  let pathTokens = [];
4325
+ if (pendingToolEgress) return pendingToolEgress;
4283
4326
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
4284
4327
  if (shellCommand) {
4285
4328
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4345,16 +4388,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4345
4388
  const dests = extractShellDestinations(shellCommand);
4346
4389
  if (dests.length > 0) {
4347
4390
  const eg = evaluateEgress(dests, config.policy.egress);
4348
- if (eg) {
4349
- return {
4350
- decision: eg.verdict,
4351
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4352
- reason: eg.reason,
4353
- ruleName: `egress:${eg.binary}:${eg.host}`,
4354
- ruleDescription: eg.reason,
4355
- tier: eg.verdict === "block" ? 3 : 4
4356
- };
4357
- }
4391
+ if (eg) return egressPolicyVerdict(eg);
4358
4392
  }
4359
4393
  }
4360
4394
  const firstToken = analyzed.actions[0] ?? "";
@@ -8294,8 +8328,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8294
8328
  };
8295
8329
  }
8296
8330
  }
8331
+ const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
8332
+ const judge = !isIgnoredTool2(toolName) || declaredEgress;
8297
8333
  if (isObserveMode) {
8298
- if (!isIgnoredTool2(toolName)) {
8334
+ if (judge) {
8299
8335
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8300
8336
  const wouldBlock = policyResult.decision === "block";
8301
8337
  if (!isManual)
@@ -8320,7 +8356,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8320
8356
  return { approved: true, checkedBy: "audit" };
8321
8357
  }
8322
8358
  if (config.settings.mode === "audit") {
8323
- if (!isIgnoredTool2(toolName)) {
8359
+ if (judge) {
8324
8360
  const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
8325
8361
  if (policyResult.decision === "review") {
8326
8362
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
@@ -8359,9 +8395,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
8359
8395
  appPermReviewTool = bareTool;
8360
8396
  }
8361
8397
  }
8362
- if (!taintWarning && !isIgnoredTool2(toolName)) {
8398
+ if (!taintWarning && judge) {
8363
8399
  const ld = config.policy.loopDetection;
8364
- if (ld.enabled && !appPermReview) {
8400
+ if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
8365
8401
  const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
8366
8402
  if (loopResult.looping) {
8367
8403
  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.1",
3
+ "version": "2.17.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",