@node9/proxy 1.9.3 → 1.10.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 +32 -3
- package/dist/cli.js +1123 -610
- package/dist/cli.mjs +1105 -592
- package/dist/index.js +97 -10
- package/dist/index.mjs +95 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -76,6 +76,11 @@ __export(audit_exports, {
|
|
|
76
76
|
appendToLog: () => appendToLog,
|
|
77
77
|
redactSecrets: () => redactSecrets
|
|
78
78
|
});
|
|
79
|
+
function isTestCall(toolName, args) {
|
|
80
|
+
if (toolName !== "Bash" && toolName !== "bash") return false;
|
|
81
|
+
const cmd = args?.command;
|
|
82
|
+
return typeof cmd === "string" && TEST_COMMAND_RE.test(cmd);
|
|
83
|
+
}
|
|
79
84
|
function redactSecrets(text) {
|
|
80
85
|
if (!text) return text;
|
|
81
86
|
let redacted = text;
|
|
@@ -111,12 +116,14 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
|
|
|
111
116
|
}
|
|
112
117
|
function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
|
|
113
118
|
const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
|
|
119
|
+
const testRun = isTestCall(toolName, args) ? { testRun: true } : {};
|
|
114
120
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
115
121
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
116
122
|
tool: toolName,
|
|
117
123
|
...argsField,
|
|
118
124
|
decision,
|
|
119
125
|
checkedBy,
|
|
126
|
+
...testRun,
|
|
120
127
|
agent: meta?.agent,
|
|
121
128
|
mcpServer: meta?.mcpServer,
|
|
122
129
|
hostname: import_os.default.hostname()
|
|
@@ -129,7 +136,7 @@ function appendConfigAudit(entry) {
|
|
|
129
136
|
hostname: import_os.default.hostname()
|
|
130
137
|
});
|
|
131
138
|
}
|
|
132
|
-
var import_fs, import_path, import_os, LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG;
|
|
139
|
+
var import_fs, import_path, import_os, LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG, TEST_COMMAND_RE;
|
|
133
140
|
var init_audit = __esm({
|
|
134
141
|
"src/audit/index.ts"() {
|
|
135
142
|
"use strict";
|
|
@@ -139,6 +146,7 @@ var init_audit = __esm({
|
|
|
139
146
|
init_hasher();
|
|
140
147
|
LOCAL_AUDIT_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
|
|
141
148
|
HOOK_DEBUG_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
|
|
149
|
+
TEST_COMMAND_RE = /(?:^|\s)(npm\s+(?:run\s+)?test|npx\s+(?:vitest|jest|mocha)|yarn\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|vitest|jest|mocha|pytest|py\.test|cargo\s+test|go\s+test|bundle\s+exec\s+rspec|rspec|phpunit|dotnet\s+test)\b/i;
|
|
142
150
|
}
|
|
143
151
|
});
|
|
144
152
|
|
|
@@ -246,6 +254,11 @@ var ConfigFileSchema = import_zod.z.object({
|
|
|
246
254
|
dlp: import_zod.z.object({
|
|
247
255
|
enabled: import_zod.z.boolean().optional(),
|
|
248
256
|
scanIgnoredTools: import_zod.z.boolean().optional()
|
|
257
|
+
}).optional(),
|
|
258
|
+
loopDetection: import_zod.z.object({
|
|
259
|
+
enabled: import_zod.z.boolean().optional(),
|
|
260
|
+
threshold: import_zod.z.number().min(2).optional(),
|
|
261
|
+
windowSeconds: import_zod.z.number().min(10).optional()
|
|
249
262
|
}).optional()
|
|
250
263
|
}).optional(),
|
|
251
264
|
environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
|
|
@@ -267,8 +280,8 @@ function sanitizeConfig(raw) {
|
|
|
267
280
|
}
|
|
268
281
|
}
|
|
269
282
|
const lines = result.error.issues.map((issue) => {
|
|
270
|
-
const
|
|
271
|
-
return ` \u2022 ${
|
|
283
|
+
const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
284
|
+
return ` \u2022 ${path15}: ${issue.message}`;
|
|
272
285
|
});
|
|
273
286
|
return {
|
|
274
287
|
sanitized,
|
|
@@ -601,7 +614,8 @@ var DEFAULT_CONFIG = {
|
|
|
601
614
|
description: "The AI wants to download a script from the internet and run it immediately, without you seeing what it contains. This is one of the most common ways malware gets installed."
|
|
602
615
|
}
|
|
603
616
|
],
|
|
604
|
-
dlp: { enabled: true, scanIgnoredTools: true }
|
|
617
|
+
dlp: { enabled: true, scanIgnoredTools: true },
|
|
618
|
+
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
|
|
605
619
|
},
|
|
606
620
|
environments: {}
|
|
607
621
|
};
|
|
@@ -723,7 +737,8 @@ function getConfig(cwd) {
|
|
|
723
737
|
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
724
738
|
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
725
739
|
},
|
|
726
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
740
|
+
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
741
|
+
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
|
|
727
742
|
};
|
|
728
743
|
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
729
744
|
const applyLayer = (source) => {
|
|
@@ -762,6 +777,13 @@ function getConfig(cwd) {
|
|
|
762
777
|
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
763
778
|
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
764
779
|
}
|
|
780
|
+
if (p.loopDetection) {
|
|
781
|
+
const ld = p.loopDetection;
|
|
782
|
+
if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
|
|
783
|
+
if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
|
|
784
|
+
if (ld.windowSeconds !== void 0)
|
|
785
|
+
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
786
|
+
}
|
|
765
787
|
const envs = source.environments || {};
|
|
766
788
|
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
767
789
|
if (envConfig && typeof envConfig === "object") {
|
|
@@ -1536,9 +1558,9 @@ function matchesPattern(text, patterns) {
|
|
|
1536
1558
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1537
1559
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1538
1560
|
}
|
|
1539
|
-
function getNestedValue(obj,
|
|
1561
|
+
function getNestedValue(obj, path15) {
|
|
1540
1562
|
if (!obj || typeof obj !== "object") return null;
|
|
1541
|
-
return
|
|
1563
|
+
return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1542
1564
|
}
|
|
1543
1565
|
function evaluateSmartConditions(args, rule) {
|
|
1544
1566
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -2106,7 +2128,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
|
|
|
2106
2128
|
}
|
|
2107
2129
|
|
|
2108
2130
|
// src/auth/orchestrator.ts
|
|
2109
|
-
var
|
|
2131
|
+
var import_crypto3 = require("crypto");
|
|
2110
2132
|
|
|
2111
2133
|
// src/ui/native.ts
|
|
2112
2134
|
var import_child_process2 = require("child_process");
|
|
@@ -2574,6 +2596,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
2574
2596
|
}
|
|
2575
2597
|
}
|
|
2576
2598
|
|
|
2599
|
+
// src/loop-detector.ts
|
|
2600
|
+
var import_fs10 = __toESM(require("fs"));
|
|
2601
|
+
var import_path14 = __toESM(require("path"));
|
|
2602
|
+
var import_os9 = __toESM(require("os"));
|
|
2603
|
+
var import_crypto2 = __toESM(require("crypto"));
|
|
2604
|
+
function loopStateFile() {
|
|
2605
|
+
return import_path14.default.join(import_os9.default.homedir(), ".node9", "loop-state.json");
|
|
2606
|
+
}
|
|
2607
|
+
var MAX_RECORDS = 500;
|
|
2608
|
+
function computeArgsHash(args) {
|
|
2609
|
+
const str = JSON.stringify(args ?? "");
|
|
2610
|
+
return import_crypto2.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
2611
|
+
}
|
|
2612
|
+
function readState() {
|
|
2613
|
+
try {
|
|
2614
|
+
if (!import_fs10.default.existsSync(loopStateFile())) return [];
|
|
2615
|
+
const raw = import_fs10.default.readFileSync(loopStateFile(), "utf-8");
|
|
2616
|
+
const parsed = JSON.parse(raw);
|
|
2617
|
+
if (!Array.isArray(parsed)) return [];
|
|
2618
|
+
return parsed;
|
|
2619
|
+
} catch {
|
|
2620
|
+
return [];
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
function writeState(records) {
|
|
2624
|
+
const dir = import_path14.default.dirname(loopStateFile());
|
|
2625
|
+
if (!import_fs10.default.existsSync(dir)) import_fs10.default.mkdirSync(dir, { recursive: true });
|
|
2626
|
+
const tmpPath = `${loopStateFile()}.${import_os9.default.hostname()}.${process.pid}.tmp`;
|
|
2627
|
+
import_fs10.default.writeFileSync(tmpPath, JSON.stringify(records));
|
|
2628
|
+
import_fs10.default.renameSync(tmpPath, loopStateFile());
|
|
2629
|
+
}
|
|
2630
|
+
function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
2631
|
+
try {
|
|
2632
|
+
const hash = computeArgsHash(args);
|
|
2633
|
+
const now = Date.now();
|
|
2634
|
+
const cutoff = now - windowMs;
|
|
2635
|
+
const records = readState().filter((r) => r.ts >= cutoff);
|
|
2636
|
+
records.push({ t: tool, h: hash, ts: now });
|
|
2637
|
+
const count = records.filter((r) => r.t === tool && r.h === hash).length;
|
|
2638
|
+
writeState(records.slice(-MAX_RECORDS));
|
|
2639
|
+
return { looping: count >= threshold, count };
|
|
2640
|
+
} catch {
|
|
2641
|
+
return { looping: false, count: 0 };
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2577
2645
|
// src/auth/orchestrator.ts
|
|
2578
2646
|
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
2579
2647
|
"write",
|
|
@@ -2625,7 +2693,7 @@ function notifyActivity(data) {
|
|
|
2625
2693
|
}
|
|
2626
2694
|
async function authorizeHeadless(toolName, args, meta, options) {
|
|
2627
2695
|
if (!options?.calledFromDaemon) {
|
|
2628
|
-
const actId = (0,
|
|
2696
|
+
const actId = (0, import_crypto3.randomUUID)();
|
|
2629
2697
|
const actTs = Date.now();
|
|
2630
2698
|
await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
|
|
2631
2699
|
const result = await _authorizeHeadlessCore(toolName, args, meta, {
|
|
@@ -2672,6 +2740,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2672
2740
|
let explainableLabel = "Local Config";
|
|
2673
2741
|
let policyMatchedField;
|
|
2674
2742
|
let policyMatchedWord;
|
|
2743
|
+
let policyRuleDescription;
|
|
2675
2744
|
let riskMetadata;
|
|
2676
2745
|
let statefulRecoveryCommand;
|
|
2677
2746
|
let localSmartRuleMatched = false;
|
|
@@ -2765,6 +2834,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2765
2834
|
return { approved: true, checkedBy: "audit" };
|
|
2766
2835
|
}
|
|
2767
2836
|
if (!taintWarning && !isIgnoredTool(toolName)) {
|
|
2837
|
+
const ld = config.policy.loopDetection;
|
|
2838
|
+
if (ld.enabled) {
|
|
2839
|
+
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
2840
|
+
if (loopResult.looping) {
|
|
2841
|
+
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?`;
|
|
2842
|
+
if (!isManual)
|
|
2843
|
+
appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
|
|
2844
|
+
return {
|
|
2845
|
+
approved: false,
|
|
2846
|
+
reason,
|
|
2847
|
+
blockedBy: "loop-detection",
|
|
2848
|
+
blockedByLabel: "\u{1F504} Loop Detected"
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2768
2852
|
if (getActiveTrustSession(toolName)) {
|
|
2769
2853
|
if (approvers.cloud && creds?.apiKey)
|
|
2770
2854
|
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
@@ -2820,6 +2904,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2820
2904
|
policyMatchedField = policyResult.matchedField;
|
|
2821
2905
|
policyMatchedWord = policyResult.matchedWord;
|
|
2822
2906
|
if (policyResult.ruleName) localSmartRuleMatched = true;
|
|
2907
|
+
if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
|
|
2908
|
+
else if (policyResult.reason) policyRuleDescription = policyResult.reason;
|
|
2823
2909
|
riskMetadata = computeRiskMetadata(
|
|
2824
2910
|
args,
|
|
2825
2911
|
policyResult.tier ?? 6,
|
|
@@ -3083,7 +3169,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
|
|
|
3083
3169
|
hashAuditArgs
|
|
3084
3170
|
);
|
|
3085
3171
|
}
|
|
3086
|
-
|
|
3172
|
+
const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
|
|
3173
|
+
return enrichedResult;
|
|
3087
3174
|
}
|
|
3088
3175
|
async function authorizeAction(toolName, args) {
|
|
3089
3176
|
const result = await authorizeHeadless(toolName, args);
|
package/dist/index.mjs
CHANGED
|
@@ -56,6 +56,11 @@ __export(audit_exports, {
|
|
|
56
56
|
import fs from "fs";
|
|
57
57
|
import path from "path";
|
|
58
58
|
import os from "os";
|
|
59
|
+
function isTestCall(toolName, args) {
|
|
60
|
+
if (toolName !== "Bash" && toolName !== "bash") return false;
|
|
61
|
+
const cmd = args?.command;
|
|
62
|
+
return typeof cmd === "string" && TEST_COMMAND_RE.test(cmd);
|
|
63
|
+
}
|
|
59
64
|
function redactSecrets(text) {
|
|
60
65
|
if (!text) return text;
|
|
61
66
|
let redacted = text;
|
|
@@ -91,12 +96,14 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
|
|
|
91
96
|
}
|
|
92
97
|
function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
|
|
93
98
|
const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
|
|
99
|
+
const testRun = isTestCall(toolName, args) ? { testRun: true } : {};
|
|
94
100
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
95
101
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
96
102
|
tool: toolName,
|
|
97
103
|
...argsField,
|
|
98
104
|
decision,
|
|
99
105
|
checkedBy,
|
|
106
|
+
...testRun,
|
|
100
107
|
agent: meta?.agent,
|
|
101
108
|
mcpServer: meta?.mcpServer,
|
|
102
109
|
hostname: os.hostname()
|
|
@@ -109,13 +116,14 @@ function appendConfigAudit(entry) {
|
|
|
109
116
|
hostname: os.hostname()
|
|
110
117
|
});
|
|
111
118
|
}
|
|
112
|
-
var LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG;
|
|
119
|
+
var LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG, TEST_COMMAND_RE;
|
|
113
120
|
var init_audit = __esm({
|
|
114
121
|
"src/audit/index.ts"() {
|
|
115
122
|
"use strict";
|
|
116
123
|
init_hasher();
|
|
117
124
|
LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
|
|
118
125
|
HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
|
|
126
|
+
TEST_COMMAND_RE = /(?:^|\s)(npm\s+(?:run\s+)?test|npx\s+(?:vitest|jest|mocha)|yarn\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|vitest|jest|mocha|pytest|py\.test|cargo\s+test|go\s+test|bundle\s+exec\s+rspec|rspec|phpunit|dotnet\s+test)\b/i;
|
|
119
127
|
}
|
|
120
128
|
});
|
|
121
129
|
|
|
@@ -216,6 +224,11 @@ var ConfigFileSchema = z.object({
|
|
|
216
224
|
dlp: z.object({
|
|
217
225
|
enabled: z.boolean().optional(),
|
|
218
226
|
scanIgnoredTools: z.boolean().optional()
|
|
227
|
+
}).optional(),
|
|
228
|
+
loopDetection: z.object({
|
|
229
|
+
enabled: z.boolean().optional(),
|
|
230
|
+
threshold: z.number().min(2).optional(),
|
|
231
|
+
windowSeconds: z.number().min(10).optional()
|
|
219
232
|
}).optional()
|
|
220
233
|
}).optional(),
|
|
221
234
|
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
@@ -237,8 +250,8 @@ function sanitizeConfig(raw) {
|
|
|
237
250
|
}
|
|
238
251
|
}
|
|
239
252
|
const lines = result.error.issues.map((issue) => {
|
|
240
|
-
const
|
|
241
|
-
return ` \u2022 ${
|
|
253
|
+
const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
254
|
+
return ` \u2022 ${path15}: ${issue.message}`;
|
|
242
255
|
});
|
|
243
256
|
return {
|
|
244
257
|
sanitized,
|
|
@@ -571,7 +584,8 @@ var DEFAULT_CONFIG = {
|
|
|
571
584
|
description: "The AI wants to download a script from the internet and run it immediately, without you seeing what it contains. This is one of the most common ways malware gets installed."
|
|
572
585
|
}
|
|
573
586
|
],
|
|
574
|
-
dlp: { enabled: true, scanIgnoredTools: true }
|
|
587
|
+
dlp: { enabled: true, scanIgnoredTools: true },
|
|
588
|
+
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
|
|
575
589
|
},
|
|
576
590
|
environments: {}
|
|
577
591
|
};
|
|
@@ -693,7 +707,8 @@ function getConfig(cwd) {
|
|
|
693
707
|
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
694
708
|
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
695
709
|
},
|
|
696
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
710
|
+
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
711
|
+
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
|
|
697
712
|
};
|
|
698
713
|
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
699
714
|
const applyLayer = (source) => {
|
|
@@ -732,6 +747,13 @@ function getConfig(cwd) {
|
|
|
732
747
|
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
733
748
|
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
734
749
|
}
|
|
750
|
+
if (p.loopDetection) {
|
|
751
|
+
const ld = p.loopDetection;
|
|
752
|
+
if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
|
|
753
|
+
if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
|
|
754
|
+
if (ld.windowSeconds !== void 0)
|
|
755
|
+
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
756
|
+
}
|
|
735
757
|
const envs = source.environments || {};
|
|
736
758
|
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
737
759
|
if (envConfig && typeof envConfig === "object") {
|
|
@@ -1506,9 +1528,9 @@ function matchesPattern(text, patterns) {
|
|
|
1506
1528
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1507
1529
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1508
1530
|
}
|
|
1509
|
-
function getNestedValue(obj,
|
|
1531
|
+
function getNestedValue(obj, path15) {
|
|
1510
1532
|
if (!obj || typeof obj !== "object") return null;
|
|
1511
|
-
return
|
|
1533
|
+
return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1512
1534
|
}
|
|
1513
1535
|
function evaluateSmartConditions(args, rule) {
|
|
1514
1536
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -2544,6 +2566,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
2544
2566
|
}
|
|
2545
2567
|
}
|
|
2546
2568
|
|
|
2569
|
+
// src/loop-detector.ts
|
|
2570
|
+
import fs10 from "fs";
|
|
2571
|
+
import path14 from "path";
|
|
2572
|
+
import os9 from "os";
|
|
2573
|
+
import crypto from "crypto";
|
|
2574
|
+
function loopStateFile() {
|
|
2575
|
+
return path14.join(os9.homedir(), ".node9", "loop-state.json");
|
|
2576
|
+
}
|
|
2577
|
+
var MAX_RECORDS = 500;
|
|
2578
|
+
function computeArgsHash(args) {
|
|
2579
|
+
const str = JSON.stringify(args ?? "");
|
|
2580
|
+
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
2581
|
+
}
|
|
2582
|
+
function readState() {
|
|
2583
|
+
try {
|
|
2584
|
+
if (!fs10.existsSync(loopStateFile())) return [];
|
|
2585
|
+
const raw = fs10.readFileSync(loopStateFile(), "utf-8");
|
|
2586
|
+
const parsed = JSON.parse(raw);
|
|
2587
|
+
if (!Array.isArray(parsed)) return [];
|
|
2588
|
+
return parsed;
|
|
2589
|
+
} catch {
|
|
2590
|
+
return [];
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
function writeState(records) {
|
|
2594
|
+
const dir = path14.dirname(loopStateFile());
|
|
2595
|
+
if (!fs10.existsSync(dir)) fs10.mkdirSync(dir, { recursive: true });
|
|
2596
|
+
const tmpPath = `${loopStateFile()}.${os9.hostname()}.${process.pid}.tmp`;
|
|
2597
|
+
fs10.writeFileSync(tmpPath, JSON.stringify(records));
|
|
2598
|
+
fs10.renameSync(tmpPath, loopStateFile());
|
|
2599
|
+
}
|
|
2600
|
+
function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
2601
|
+
try {
|
|
2602
|
+
const hash = computeArgsHash(args);
|
|
2603
|
+
const now = Date.now();
|
|
2604
|
+
const cutoff = now - windowMs;
|
|
2605
|
+
const records = readState().filter((r) => r.ts >= cutoff);
|
|
2606
|
+
records.push({ t: tool, h: hash, ts: now });
|
|
2607
|
+
const count = records.filter((r) => r.t === tool && r.h === hash).length;
|
|
2608
|
+
writeState(records.slice(-MAX_RECORDS));
|
|
2609
|
+
return { looping: count >= threshold, count };
|
|
2610
|
+
} catch {
|
|
2611
|
+
return { looping: false, count: 0 };
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2547
2615
|
// src/auth/orchestrator.ts
|
|
2548
2616
|
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
2549
2617
|
"write",
|
|
@@ -2642,6 +2710,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2642
2710
|
let explainableLabel = "Local Config";
|
|
2643
2711
|
let policyMatchedField;
|
|
2644
2712
|
let policyMatchedWord;
|
|
2713
|
+
let policyRuleDescription;
|
|
2645
2714
|
let riskMetadata;
|
|
2646
2715
|
let statefulRecoveryCommand;
|
|
2647
2716
|
let localSmartRuleMatched = false;
|
|
@@ -2735,6 +2804,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2735
2804
|
return { approved: true, checkedBy: "audit" };
|
|
2736
2805
|
}
|
|
2737
2806
|
if (!taintWarning && !isIgnoredTool(toolName)) {
|
|
2807
|
+
const ld = config.policy.loopDetection;
|
|
2808
|
+
if (ld.enabled) {
|
|
2809
|
+
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
2810
|
+
if (loopResult.looping) {
|
|
2811
|
+
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?`;
|
|
2812
|
+
if (!isManual)
|
|
2813
|
+
appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
|
|
2814
|
+
return {
|
|
2815
|
+
approved: false,
|
|
2816
|
+
reason,
|
|
2817
|
+
blockedBy: "loop-detection",
|
|
2818
|
+
blockedByLabel: "\u{1F504} Loop Detected"
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2738
2822
|
if (getActiveTrustSession(toolName)) {
|
|
2739
2823
|
if (approvers.cloud && creds?.apiKey)
|
|
2740
2824
|
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
@@ -2790,6 +2874,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
2790
2874
|
policyMatchedField = policyResult.matchedField;
|
|
2791
2875
|
policyMatchedWord = policyResult.matchedWord;
|
|
2792
2876
|
if (policyResult.ruleName) localSmartRuleMatched = true;
|
|
2877
|
+
if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
|
|
2878
|
+
else if (policyResult.reason) policyRuleDescription = policyResult.reason;
|
|
2793
2879
|
riskMetadata = computeRiskMetadata(
|
|
2794
2880
|
args,
|
|
2795
2881
|
policyResult.tier ?? 6,
|
|
@@ -3053,7 +3139,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
|
|
|
3053
3139
|
hashAuditArgs
|
|
3054
3140
|
);
|
|
3055
3141
|
}
|
|
3056
|
-
|
|
3142
|
+
const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
|
|
3143
|
+
return enrichedResult;
|
|
3057
3144
|
}
|
|
3058
3145
|
async function authorizeAction(toolName, args) {
|
|
3059
3146
|
const result = await authorizeHeadless(toolName, args);
|