@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.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
 
@@ -230,7 +238,8 @@ var ConfigFileSchema = import_zod.z.object({
230
238
  slackEnabled: import_zod.z.boolean().optional(),
231
239
  enableTrustSessions: import_zod.z.boolean().optional(),
232
240
  allowGlobalPause: import_zod.z.boolean().optional(),
233
- auditHashArgs: import_zod.z.boolean().optional()
241
+ auditHashArgs: import_zod.z.boolean().optional(),
242
+ agentPolicy: import_zod.z.enum(["require_approval", "block_on_rules"]).optional()
234
243
  }).optional(),
235
244
  policy: import_zod.z.object({
236
245
  sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
@@ -246,6 +255,11 @@ var ConfigFileSchema = import_zod.z.object({
246
255
  dlp: import_zod.z.object({
247
256
  enabled: import_zod.z.boolean().optional(),
248
257
  scanIgnoredTools: import_zod.z.boolean().optional()
258
+ }).optional(),
259
+ loopDetection: import_zod.z.object({
260
+ enabled: import_zod.z.boolean().optional(),
261
+ threshold: import_zod.z.number().min(2).optional(),
262
+ windowSeconds: import_zod.z.number().min(10).optional()
249
263
  }).optional()
250
264
  }).optional(),
251
265
  environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
@@ -267,8 +281,8 @@ function sanitizeConfig(raw) {
267
281
  }
268
282
  }
269
283
  const lines = result.error.issues.map((issue) => {
270
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
271
- return ` \u2022 ${path14}: ${issue.message}`;
284
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
285
+ return ` \u2022 ${path15}: ${issue.message}`;
272
286
  });
273
287
  return {
274
288
  sanitized,
@@ -601,7 +615,8 @@ var DEFAULT_CONFIG = {
601
615
  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
616
  }
603
617
  ],
604
- dlp: { enabled: true, scanIgnoredTools: true }
618
+ dlp: { enabled: true, scanIgnoredTools: true },
619
+ loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
605
620
  },
606
621
  environments: {}
607
622
  };
@@ -723,7 +738,8 @@ function getConfig(cwd) {
723
738
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
724
739
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
725
740
  },
726
- dlp: { ...DEFAULT_CONFIG.policy.dlp }
741
+ dlp: { ...DEFAULT_CONFIG.policy.dlp },
742
+ loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
727
743
  };
728
744
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
729
745
  const applyLayer = (source) => {
@@ -762,6 +778,13 @@ function getConfig(cwd) {
762
778
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
763
779
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
764
780
  }
781
+ if (p.loopDetection) {
782
+ const ld = p.loopDetection;
783
+ if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
784
+ if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
785
+ if (ld.windowSeconds !== void 0)
786
+ mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
787
+ }
765
788
  const envs = source.environments || {};
766
789
  for (const [envName, envConfig] of Object.entries(envs)) {
767
790
  if (envConfig && typeof envConfig === "object") {
@@ -1536,9 +1559,9 @@ function matchesPattern(text, patterns) {
1536
1559
  const withoutDotSlash = text.replace(/^\.\//, "");
1537
1560
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1538
1561
  }
1539
- function getNestedValue(obj, path14) {
1562
+ function getNestedValue(obj, path15) {
1540
1563
  if (!obj || typeof obj !== "object") return null;
1541
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1564
+ return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
1542
1565
  }
1543
1566
  function evaluateSmartConditions(args, rule) {
1544
1567
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -2106,7 +2129,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2106
2129
  }
2107
2130
 
2108
2131
  // src/auth/orchestrator.ts
2109
- var import_crypto2 = require("crypto");
2132
+ var import_crypto3 = require("crypto");
2110
2133
 
2111
2134
  // src/ui/native.ts
2112
2135
  var import_child_process2 = require("child_process");
@@ -2299,11 +2322,12 @@ ${smartTruncate(str, 500)}`
2299
2322
  function escapePango(text) {
2300
2323
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2301
2324
  }
2302
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2325
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
2303
2326
  const lines = [];
2304
2327
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
2305
2328
  lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
2306
2329
  lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
2330
+ if (ruleDescription) lines.push(`\u2139 ${ruleDescription}`);
2307
2331
  lines.push("");
2308
2332
  lines.push(formattedArgs);
2309
2333
  if (allowCount >= 3) {
@@ -2316,7 +2340,7 @@ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, loc
2316
2340
  }
2317
2341
  return lines.join("\n");
2318
2342
  }
2319
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2343
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1, ruleDescription) {
2320
2344
  const lines = [];
2321
2345
  if (locked) {
2322
2346
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -2326,6 +2350,7 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2326
2350
  `<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
2327
2351
  );
2328
2352
  lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
2353
+ if (ruleDescription) lines.push(`<i>\u2139 ${escapePango(ruleDescription)}</i>`);
2329
2354
  lines.push("");
2330
2355
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
2331
2356
  if (allowCount >= 3) {
@@ -2342,7 +2367,7 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2342
2367
  }
2343
2368
  return lines.join("\n");
2344
2369
  }
2345
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1) {
2370
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1, ruleDescription) {
2346
2371
  if (isTestEnv()) return "deny";
2347
2372
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
2348
2373
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
@@ -2353,7 +2378,8 @@ async function askNativePopup(toolName, args, agent, explainableLabel, locked =
2353
2378
  agent,
2354
2379
  explainableLabel,
2355
2380
  locked,
2356
- allowCount
2381
+ allowCount,
2382
+ ruleDescription
2357
2383
  );
2358
2384
  return new Promise((resolve) => {
2359
2385
  let childProcess = null;
@@ -2387,7 +2413,8 @@ end run`;
2387
2413
  agent,
2388
2414
  explainableLabel,
2389
2415
  locked,
2390
- allowCount
2416
+ allowCount,
2417
+ ruleDescription
2391
2418
  );
2392
2419
  const argsList = [
2393
2420
  locked ? "--info" : "--question",
@@ -2455,7 +2482,7 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2455
2482
  }).catch(() => {
2456
2483
  });
2457
2484
  }
2458
- async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2485
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
2459
2486
  const controller = new AbortController();
2460
2487
  const timeout = setTimeout(() => controller.abort(), 1e4);
2461
2488
  if (!creds.apiKey) throw new Error("Node9 API Key is missing");
@@ -2500,7 +2527,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2500
2527
  platform: import_os8.default.platform()
2501
2528
  },
2502
2529
  ...riskMetadata && { riskMetadata },
2503
- ...ciContext && { ciContext }
2530
+ ...ciContext && { ciContext },
2531
+ ...agentPolicy && { policy: agentPolicy },
2532
+ ...forceReview && { forceReview: true }
2504
2533
  }),
2505
2534
  signal: controller.signal
2506
2535
  });
@@ -2574,6 +2603,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
2574
2603
  }
2575
2604
  }
2576
2605
 
2606
+ // src/loop-detector.ts
2607
+ var import_fs10 = __toESM(require("fs"));
2608
+ var import_path14 = __toESM(require("path"));
2609
+ var import_os9 = __toESM(require("os"));
2610
+ var import_crypto2 = __toESM(require("crypto"));
2611
+ function loopStateFile() {
2612
+ return import_path14.default.join(import_os9.default.homedir(), ".node9", "loop-state.json");
2613
+ }
2614
+ var MAX_RECORDS = 500;
2615
+ function computeArgsHash(args) {
2616
+ const str = JSON.stringify(args ?? "");
2617
+ return import_crypto2.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
2618
+ }
2619
+ function readState() {
2620
+ try {
2621
+ if (!import_fs10.default.existsSync(loopStateFile())) return [];
2622
+ const raw = import_fs10.default.readFileSync(loopStateFile(), "utf-8");
2623
+ const parsed = JSON.parse(raw);
2624
+ if (!Array.isArray(parsed)) return [];
2625
+ return parsed;
2626
+ } catch {
2627
+ return [];
2628
+ }
2629
+ }
2630
+ function writeState(records) {
2631
+ const dir = import_path14.default.dirname(loopStateFile());
2632
+ if (!import_fs10.default.existsSync(dir)) import_fs10.default.mkdirSync(dir, { recursive: true });
2633
+ const tmpPath = `${loopStateFile()}.${import_os9.default.hostname()}.${process.pid}.tmp`;
2634
+ import_fs10.default.writeFileSync(tmpPath, JSON.stringify(records));
2635
+ import_fs10.default.renameSync(tmpPath, loopStateFile());
2636
+ }
2637
+ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
2638
+ try {
2639
+ const hash = computeArgsHash(args);
2640
+ const now = Date.now();
2641
+ const cutoff = now - windowMs;
2642
+ const records = readState().filter((r) => r.ts >= cutoff);
2643
+ records.push({ t: tool, h: hash, ts: now });
2644
+ const count = records.filter((r) => r.t === tool && r.h === hash).length;
2645
+ writeState(records.slice(-MAX_RECORDS));
2646
+ return { looping: count >= threshold, count };
2647
+ } catch {
2648
+ return { looping: false, count: 0 };
2649
+ }
2650
+ }
2651
+
2577
2652
  // src/auth/orchestrator.ts
2578
2653
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2579
2654
  "write",
@@ -2625,7 +2700,7 @@ function notifyActivity(data) {
2625
2700
  }
2626
2701
  async function authorizeHeadless(toolName, args, meta, options) {
2627
2702
  if (!options?.calledFromDaemon) {
2628
- const actId = (0, import_crypto2.randomUUID)();
2703
+ const actId = (0, import_crypto3.randomUUID)();
2629
2704
  const actTs = Date.now();
2630
2705
  await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
2631
2706
  const result = await _authorizeHeadlessCore(toolName, args, meta, {
@@ -2672,6 +2747,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2672
2747
  let explainableLabel = "Local Config";
2673
2748
  let policyMatchedField;
2674
2749
  let policyMatchedWord;
2750
+ let policyRuleDescription;
2675
2751
  let riskMetadata;
2676
2752
  let statefulRecoveryCommand;
2677
2753
  let localSmartRuleMatched = false;
@@ -2765,6 +2841,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2765
2841
  return { approved: true, checkedBy: "audit" };
2766
2842
  }
2767
2843
  if (!taintWarning && !isIgnoredTool(toolName)) {
2844
+ const ld = config.policy.loopDetection;
2845
+ if (ld.enabled) {
2846
+ const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
2847
+ if (loopResult.looping) {
2848
+ 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?`;
2849
+ if (!isManual)
2850
+ appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
2851
+ return {
2852
+ approved: false,
2853
+ reason,
2854
+ blockedBy: "loop-detection",
2855
+ blockedByLabel: "\u{1F504} Loop Detected"
2856
+ };
2857
+ }
2858
+ }
2768
2859
  if (getActiveTrustSession(toolName)) {
2769
2860
  if (approvers.cloud && creds?.apiKey)
2770
2861
  await auditLocalAllow(toolName, args, "trust", creds, meta);
@@ -2820,6 +2911,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2820
2911
  policyMatchedField = policyResult.matchedField;
2821
2912
  policyMatchedWord = policyResult.matchedWord;
2822
2913
  if (policyResult.ruleName) localSmartRuleMatched = true;
2914
+ if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
2915
+ else if (policyResult.reason) policyRuleDescription = policyResult.reason;
2823
2916
  riskMetadata = computeRiskMetadata(
2824
2917
  args,
2825
2918
  policyResult.tier ?? 6,
@@ -2828,6 +2921,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2828
2921
  policyMatchedWord,
2829
2922
  policyResult.ruleName
2830
2923
  );
2924
+ if (policyRuleDescription) riskMetadata.ruleDescription = policyRuleDescription.slice(0, 200);
2831
2925
  const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
2832
2926
  if (persistent === "allow") {
2833
2927
  if (approvers.cloud && creds?.apiKey)
@@ -2862,9 +2956,18 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2862
2956
  }
2863
2957
  let cloudRequestId = null;
2864
2958
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
2865
- if (cloudEnforced && !localSmartRuleMatched && !options?.localSmartRuleMatched) {
2959
+ const forceReview = localSmartRuleMatched === true || options?.localSmartRuleMatched === true || void 0;
2960
+ if (cloudEnforced) {
2866
2961
  try {
2867
- const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
2962
+ const initResult = await initNode9SaaS(
2963
+ toolName,
2964
+ args,
2965
+ creds,
2966
+ meta,
2967
+ riskMetadata,
2968
+ config.settings.agentPolicy,
2969
+ forceReview
2970
+ );
2868
2971
  if (!initResult.pending) {
2869
2972
  if (initResult.shadowMode) {
2870
2973
  return { approved: true, checkedBy: "cloud" };
@@ -2879,9 +2982,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2879
2982
  };
2880
2983
  }
2881
2984
  }
2882
- if (!localSmartRuleMatched && !options?.localSmartRuleMatched) {
2883
- cloudRequestId = initResult.requestId || null;
2884
- }
2985
+ if (initResult.pending) cloudRequestId = initResult.requestId || null;
2885
2986
  if (!taintWarning) explainableLabel = "Organization Policy (SaaS)";
2886
2987
  } catch {
2887
2988
  }
@@ -2938,7 +3039,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2938
3039
  }
2939
3040
  }
2940
3041
  }
2941
- if (cloudEnforced && cloudRequestId && !localSmartRuleMatched && !options?.localSmartRuleMatched) {
3042
+ if (cloudEnforced && cloudRequestId) {
2942
3043
  racePromises.push(
2943
3044
  (async () => {
2944
3045
  try {
@@ -2970,7 +3071,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2970
3071
  signal,
2971
3072
  policyMatchedField,
2972
3073
  policyMatchedWord,
2973
- daemonAllowCount
3074
+ daemonAllowCount,
3075
+ riskMetadata?.ruleDescription
2974
3076
  );
2975
3077
  if (decision === "always_allow") {
2976
3078
  writeTrustSession(toolName, 36e5);
@@ -3083,7 +3185,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3083
3185
  hashAuditArgs
3084
3186
  );
3085
3187
  }
3086
- return finalResult;
3188
+ const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
3189
+ return enrichedResult;
3087
3190
  }
3088
3191
  async function authorizeAction(toolName, args) {
3089
3192
  const result = await authorizeHeadless(toolName, args);