@node9/proxy 1.9.3 → 1.10.1

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
@@ -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
 
@@ -200,7 +208,8 @@ var ConfigFileSchema = z.object({
200
208
  slackEnabled: z.boolean().optional(),
201
209
  enableTrustSessions: z.boolean().optional(),
202
210
  allowGlobalPause: z.boolean().optional(),
203
- auditHashArgs: z.boolean().optional()
211
+ auditHashArgs: z.boolean().optional(),
212
+ agentPolicy: z.enum(["require_approval", "block_on_rules"]).optional()
204
213
  }).optional(),
205
214
  policy: z.object({
206
215
  sandboxPaths: z.array(z.string()).optional(),
@@ -216,6 +225,11 @@ var ConfigFileSchema = z.object({
216
225
  dlp: z.object({
217
226
  enabled: z.boolean().optional(),
218
227
  scanIgnoredTools: z.boolean().optional()
228
+ }).optional(),
229
+ loopDetection: z.object({
230
+ enabled: z.boolean().optional(),
231
+ threshold: z.number().min(2).optional(),
232
+ windowSeconds: z.number().min(10).optional()
219
233
  }).optional()
220
234
  }).optional(),
221
235
  environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
@@ -237,8 +251,8 @@ function sanitizeConfig(raw) {
237
251
  }
238
252
  }
239
253
  const lines = result.error.issues.map((issue) => {
240
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
241
- return ` \u2022 ${path14}: ${issue.message}`;
254
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
255
+ return ` \u2022 ${path15}: ${issue.message}`;
242
256
  });
243
257
  return {
244
258
  sanitized,
@@ -571,7 +585,8 @@ var DEFAULT_CONFIG = {
571
585
  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
586
  }
573
587
  ],
574
- dlp: { enabled: true, scanIgnoredTools: true }
588
+ dlp: { enabled: true, scanIgnoredTools: true },
589
+ loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
575
590
  },
576
591
  environments: {}
577
592
  };
@@ -693,7 +708,8 @@ function getConfig(cwd) {
693
708
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
694
709
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
695
710
  },
696
- dlp: { ...DEFAULT_CONFIG.policy.dlp }
711
+ dlp: { ...DEFAULT_CONFIG.policy.dlp },
712
+ loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
697
713
  };
698
714
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
699
715
  const applyLayer = (source) => {
@@ -732,6 +748,13 @@ function getConfig(cwd) {
732
748
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
733
749
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
734
750
  }
751
+ if (p.loopDetection) {
752
+ const ld = p.loopDetection;
753
+ if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
754
+ if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
755
+ if (ld.windowSeconds !== void 0)
756
+ mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
757
+ }
735
758
  const envs = source.environments || {};
736
759
  for (const [envName, envConfig] of Object.entries(envs)) {
737
760
  if (envConfig && typeof envConfig === "object") {
@@ -1506,9 +1529,9 @@ function matchesPattern(text, patterns) {
1506
1529
  const withoutDotSlash = text.replace(/^\.\//, "");
1507
1530
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1508
1531
  }
1509
- function getNestedValue(obj, path14) {
1532
+ function getNestedValue(obj, path15) {
1510
1533
  if (!obj || typeof obj !== "object") return null;
1511
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1534
+ return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
1512
1535
  }
1513
1536
  function evaluateSmartConditions(args, rule) {
1514
1537
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -2269,11 +2292,12 @@ ${smartTruncate(str, 500)}`
2269
2292
  function escapePango(text) {
2270
2293
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2271
2294
  }
2272
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2295
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
2273
2296
  const lines = [];
2274
2297
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
2275
2298
  lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
2276
2299
  lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
2300
+ if (ruleDescription) lines.push(`\u2139 ${ruleDescription}`);
2277
2301
  lines.push("");
2278
2302
  lines.push(formattedArgs);
2279
2303
  if (allowCount >= 3) {
@@ -2286,7 +2310,7 @@ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, loc
2286
2310
  }
2287
2311
  return lines.join("\n");
2288
2312
  }
2289
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2313
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
2290
2314
  const lines = [];
2291
2315
  if (locked) {
2292
2316
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -2296,6 +2320,7 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2296
2320
  `<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
2297
2321
  );
2298
2322
  lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
2323
+ if (ruleDescription) lines.push(`<i>\u2139 ${escapePango(ruleDescription)}</i>`);
2299
2324
  lines.push("");
2300
2325
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
2301
2326
  if (allowCount >= 3) {
@@ -2312,7 +2337,7 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2312
2337
  }
2313
2338
  return lines.join("\n");
2314
2339
  }
2315
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1) {
2340
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1, ruleDescription) {
2316
2341
  if (isTestEnv()) return "deny";
2317
2342
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
2318
2343
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
@@ -2323,7 +2348,8 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
2323
2348
  agent,
2324
2349
  explainableLabel,
2325
2350
  locked,
2326
- allowCount
2351
+ allowCount,
2352
+ ruleDescription
2327
2353
  );
2328
2354
  return new Promise((resolve) => {
2329
2355
  let childProcess = null;
@@ -2357,7 +2383,8 @@ end run`;
2357
2383
  agent,
2358
2384
  explainableLabel,
2359
2385
  locked,
2360
- allowCount
2386
+ allowCount,
2387
+ ruleDescription
2361
2388
  );
2362
2389
  const argsList = [
2363
2390
  locked ? "--info" : "--question",
@@ -2425,7 +2452,7 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2425
2452
  }).catch(() => {
2426
2453
  });
2427
2454
  }
2428
- async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2455
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
2429
2456
  const controller = new AbortController();
2430
2457
  const timeout = setTimeout(() => controller.abort(), 1e4);
2431
2458
  if (!creds.apiKey) throw new Error("Node9 API Key is missing");
@@ -2470,7 +2497,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2470
2497
  platform: os8.platform()
2471
2498
  },
2472
2499
  ...riskMetadata && { riskMetadata },
2473
- ...ciContext && { ciContext }
2500
+ ...ciContext && { ciContext },
2501
+ ...agentPolicy && { policy: agentPolicy },
2502
+ ...forceReview && { forceReview: true }
2474
2503
  }),
2475
2504
  signal: controller.signal
2476
2505
  });
@@ -2544,6 +2573,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
2544
2573
  }
2545
2574
  }
2546
2575
 
2576
+ // src/loop-detector.ts
2577
+ import fs10 from "fs";
2578
+ import path14 from "path";
2579
+ import os9 from "os";
2580
+ import crypto from "crypto";
2581
+ function loopStateFile() {
2582
+ return path14.join(os9.homedir(), ".node9", "loop-state.json");
2583
+ }
2584
+ var MAX_RECORDS = 500;
2585
+ function computeArgsHash(args) {
2586
+ const str = JSON.stringify(args ?? "");
2587
+ return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
2588
+ }
2589
+ function readState() {
2590
+ try {
2591
+ if (!fs10.existsSync(loopStateFile())) return [];
2592
+ const raw = fs10.readFileSync(loopStateFile(), "utf-8");
2593
+ const parsed = JSON.parse(raw);
2594
+ if (!Array.isArray(parsed)) return [];
2595
+ return parsed;
2596
+ } catch {
2597
+ return [];
2598
+ }
2599
+ }
2600
+ function writeState(records) {
2601
+ const dir = path14.dirname(loopStateFile());
2602
+ if (!fs10.existsSync(dir)) fs10.mkdirSync(dir, { recursive: true });
2603
+ const tmpPath = `${loopStateFile()}.${os9.hostname()}.${process.pid}.tmp`;
2604
+ fs10.writeFileSync(tmpPath, JSON.stringify(records));
2605
+ fs10.renameSync(tmpPath, loopStateFile());
2606
+ }
2607
+ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
2608
+ try {
2609
+ const hash = computeArgsHash(args);
2610
+ const now = Date.now();
2611
+ const cutoff = now - windowMs;
2612
+ const records = readState().filter((r) => r.ts >= cutoff);
2613
+ records.push({ t: tool, h: hash, ts: now });
2614
+ const count = records.filter((r) => r.t === tool && r.h === hash).length;
2615
+ writeState(records.slice(-MAX_RECORDS));
2616
+ return { looping: count >= threshold, count };
2617
+ } catch {
2618
+ return { looping: false, count: 0 };
2619
+ }
2620
+ }
2621
+
2547
2622
  // src/auth/orchestrator.ts
2548
2623
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2549
2624
  "write",
@@ -2642,6 +2717,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2642
2717
  let explainableLabel = "Local Config";
2643
2718
  let policyMatchedField;
2644
2719
  let policyMatchedWord;
2720
+ let policyRuleDescription;
2645
2721
  let riskMetadata;
2646
2722
  let statefulRecoveryCommand;
2647
2723
  let localSmartRuleMatched = false;
@@ -2735,6 +2811,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2735
2811
  return { approved: true, checkedBy: "audit" };
2736
2812
  }
2737
2813
  if (!taintWarning && !isIgnoredTool(toolName)) {
2814
+ const ld = config.policy.loopDetection;
2815
+ if (ld.enabled) {
2816
+ const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
2817
+ if (loopResult.looping) {
2818
+ 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?`;
2819
+ if (!isManual)
2820
+ appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
2821
+ return {
2822
+ approved: false,
2823
+ reason,
2824
+ blockedBy: "loop-detection",
2825
+ blockedByLabel: "\u{1F504} Loop Detected"
2826
+ };
2827
+ }
2828
+ }
2738
2829
  if (getActiveTrustSession(toolName)) {
2739
2830
  if (approvers.cloud && creds?.apiKey)
2740
2831
  await auditLocalAllow(toolName, args, "trust", creds, meta);
@@ -2790,6 +2881,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2790
2881
  policyMatchedField = policyResult.matchedField;
2791
2882
  policyMatchedWord = policyResult.matchedWord;
2792
2883
  if (policyResult.ruleName) localSmartRuleMatched = true;
2884
+ if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
2885
+ else if (policyResult.reason) policyRuleDescription = policyResult.reason;
2793
2886
  riskMetadata = computeRiskMetadata(
2794
2887
  args,
2795
2888
  policyResult.tier ?? 6,
@@ -2798,6 +2891,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2798
2891
  policyMatchedWord,
2799
2892
  policyResult.ruleName
2800
2893
  );
2894
+ if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
2801
2895
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
2802
2896
  if (persistent === "allow") {
2803
2897
  if (approvers.cloud && creds?.apiKey)
@@ -2832,9 +2926,18 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2832
2926
  }
2833
2927
  let cloudRequestId = null;
2834
2928
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
2835
- if (cloudEnforced && !localSmartRuleMatched && !options?.localSmartRuleMatched) {
2929
+ const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || void 0;
2930
+ if (cloudEnforced) {
2836
2931
  try {
2837
- const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
2932
+ const initResult = await initNode9SaaS(
2933
+ toolName,
2934
+ args,
2935
+ creds,
2936
+ meta,
2937
+ riskMetadata,
2938
+ config.settings.agentPolicy,
2939
+ forceReview
2940
+ );
2838
2941
  if (!initResult.pending) {
2839
2942
  if (initResult.shadowMode) {
2840
2943
  return { approved: true, checkedBy: "cloud" };
@@ -2849,9 +2952,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2849
2952
  };
2850
2953
  }
2851
2954
  }
2852
- if (!localSmartRuleMatched && !options?.localSmartRuleMatched) {
2853
- cloudRequestId = initResult.requestId || null;
2854
- }
2955
+ if (initResult.pending) cloudRequestId = initResult.requestId || null;
2855
2956
  if (!taintWarning) explainableLabel = "Organization Policy (SaaS)";
2856
2957
  } catch {
2857
2958
  }
@@ -2908,7 +3009,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2908
3009
  }
2909
3010
  }
2910
3011
  }
2911
- if (cloudEnforced && cloudRequestId && !localSmartRuleMatched && !options?.localSmartRuleMatched) {
3012
+ if (cloudEnforced && cloudRequestId) {
2912
3013
  racePromises.push(
2913
3014
  (async () => {
2914
3015
  try {
@@ -2940,7 +3041,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2940
3041
  signal,
2941
3042
  policyMatchedField,
2942
3043
  policyMatchedWord,
2943
- daemonAllowCount
3044
+ daemonAllowCount,
3045
+ riskMetadata?.ruleDescription
2944
3046
  );
2945
3047
  if (decision === "always_allow") {
2946
3048
  writeTrustSession(toolName, 36e5);
@@ -3053,7 +3155,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3053
3155
  hashAuditArgs
3054
3156
  );
3055
3157
  }
3056
- return finalResult;
3158
+ const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
3159
+ return enrichedResult;
3057
3160
  }
3058
3161
  async function authorizeAction(toolName, args) {
3059
3162
  const result = await authorizeHeadless(toolName, args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.9.3",
3
+ "version": "1.10.1",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",