@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/README.md +76 -331
- package/dist/cli.js +188 -70
- package/dist/cli.mjs +188 -70
- package/dist/dashboard.mjs +4 -2
- package/dist/index.js +90 -29
- package/dist/index.mjs +90 -29
- package/package.json +1 -1
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 &&
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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}"
|
|
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 (
|
|
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 (
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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}"
|
|
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 (
|
|
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 (
|
|
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 &&
|
|
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.
|
|
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",
|