@node9/proxy 1.9.2 → 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/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
 
@@ -199,6 +207,7 @@ var SmartRuleSchema = import_zod.z.object({
199
207
  errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
200
208
  }),
201
209
  reason: import_zod.z.string().optional(),
210
+ description: import_zod.z.string().optional(),
202
211
  // Unknown predicate names are filtered out rather than failing the whole rule.
203
212
  // Failing the whole z.array() would cause sanitizeConfig to drop the entire
204
213
  // `policy` top-level key, silently disabling ALL smart rules in the config.
@@ -245,6 +254,11 @@ var ConfigFileSchema = import_zod.z.object({
245
254
  dlp: import_zod.z.object({
246
255
  enabled: import_zod.z.boolean().optional(),
247
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()
248
262
  }).optional()
249
263
  }).optional(),
250
264
  environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
@@ -266,8 +280,8 @@ function sanitizeConfig(raw) {
266
280
  }
267
281
  }
268
282
  const lines = result.error.issues.map((issue) => {
269
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
270
- return ` \u2022 ${path14}: ${issue.message}`;
283
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
284
+ return ` \u2022 ${path15}: ${issue.message}`;
271
285
  });
272
286
  return {
273
287
  sanitized,
@@ -492,7 +506,8 @@ var DEFAULT_CONFIG = {
492
506
  }
493
507
  ],
494
508
  verdict: "block",
495
- reason: "Recursive delete of home directory is irreversible"
509
+ reason: "Recursive delete of home directory is irreversible",
510
+ description: "The AI wants to recursively delete your home directory. This will permanently destroy all your personal files and cannot be undone."
496
511
  },
497
512
  // ── SQL safety ────────────────────────────────────────────────────────
498
513
  {
@@ -504,7 +519,8 @@ var DEFAULT_CONFIG = {
504
519
  ],
505
520
  conditionMode: "all",
506
521
  verdict: "review",
507
- reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
522
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table",
523
+ description: "The AI is running a SQL statement that will modify every row in the table \u2014 no WHERE filter was found. This could wipe or corrupt all your data."
508
524
  },
509
525
  {
510
526
  name: "review-drop-truncate-shell",
@@ -519,7 +535,8 @@ var DEFAULT_CONFIG = {
519
535
  ],
520
536
  conditionMode: "all",
521
537
  verdict: "review",
522
- reason: "SQL DDL destructive statement inside a shell command"
538
+ reason: "SQL DDL destructive statement inside a shell command",
539
+ description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
523
540
  },
524
541
  // ── Git safety ────────────────────────────────────────────────────────
525
542
  {
@@ -535,7 +552,8 @@ var DEFAULT_CONFIG = {
535
552
  ],
536
553
  conditionMode: "all",
537
554
  verdict: "block",
538
- reason: "Force push overwrites remote history and cannot be undone"
555
+ reason: "Force push overwrites remote history and cannot be undone",
556
+ description: "The AI wants to force push to a remote git branch. This rewrites shared history and can permanently destroy commits that teammates have already pulled."
539
557
  },
540
558
  {
541
559
  name: "review-git-push",
@@ -550,7 +568,8 @@ var DEFAULT_CONFIG = {
550
568
  ],
551
569
  conditionMode: "all",
552
570
  verdict: "review",
553
- reason: "git push sends changes to a shared remote"
571
+ reason: "git push sends changes to a shared remote",
572
+ description: "The AI wants to push commits to a remote repository. Once pushed, those changes are visible to everyone with access."
554
573
  },
555
574
  {
556
575
  name: "review-git-destructive",
@@ -565,7 +584,8 @@ var DEFAULT_CONFIG = {
565
584
  ],
566
585
  conditionMode: "all",
567
586
  verdict: "review",
568
- reason: "Destructive git operation \u2014 discards history or working-tree changes"
587
+ reason: "Destructive git operation \u2014 discards history or working-tree changes",
588
+ description: "The AI wants to run a destructive git operation (reset, rebase, clean, or branch delete) that can permanently discard commits or uncommitted work."
569
589
  },
570
590
  // ── Shell safety ──────────────────────────────────────────────────────
571
591
  {
@@ -574,7 +594,8 @@ var DEFAULT_CONFIG = {
574
594
  conditions: [{ field: "command", op: "matches", value: "\\bsudo\\s", flags: "i" }],
575
595
  conditionMode: "all",
576
596
  verdict: "review",
577
- reason: "Command requires elevated privileges"
597
+ reason: "Command requires elevated privileges",
598
+ description: "The AI wants to run a command as root (sudo). Commands with root access can modify system files, install software, or change security settings."
578
599
  },
579
600
  {
580
601
  name: "review-curl-pipe-shell",
@@ -589,10 +610,12 @@ var DEFAULT_CONFIG = {
589
610
  ],
590
611
  conditionMode: "all",
591
612
  verdict: "block",
592
- reason: "Piping remote script into a shell is a supply-chain attack vector"
613
+ reason: "Piping remote script into a shell is a supply-chain attack vector",
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."
593
615
  }
594
616
  ],
595
- dlp: { enabled: true, scanIgnoredTools: true }
617
+ dlp: { enabled: true, scanIgnoredTools: true },
618
+ loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
596
619
  },
597
620
  environments: {}
598
621
  };
@@ -622,7 +645,8 @@ var ADVISORY_SMART_RULES = [
622
645
  tool: "*",
623
646
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
624
647
  verdict: "review",
625
- reason: "rm can permanently delete files \u2014 confirm the target path"
648
+ reason: "rm can permanently delete files \u2014 confirm the target path",
649
+ description: "The AI wants to delete files. Unlike moving to trash, rm is permanent \u2014 the files cannot be recovered without a backup."
626
650
  },
627
651
  // ── SQL safety (Safe by Default) ──────────────────────────────────────────
628
652
  // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
@@ -634,14 +658,16 @@ var ADVISORY_SMART_RULES = [
634
658
  tool: "*",
635
659
  conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
636
660
  verdict: "review",
637
- reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
661
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead",
662
+ description: "The AI wants to drop a database table. This permanently deletes the table and all its data \u2014 there is no undo."
638
663
  },
639
664
  {
640
665
  name: "review-truncate-sql",
641
666
  tool: "*",
642
667
  conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
643
668
  verdict: "review",
644
- reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
669
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead",
670
+ description: "The AI wants to truncate a database table, which instantly deletes every row. The table structure remains but all data is gone."
645
671
  },
646
672
  {
647
673
  name: "review-drop-column-sql",
@@ -650,7 +676,8 @@ var ADVISORY_SMART_RULES = [
650
676
  { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
651
677
  ],
652
678
  verdict: "review",
653
- reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
679
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead",
680
+ description: "The AI wants to drop a column from a database table. This permanently removes the column and all its data from every row."
654
681
  }
655
682
  ];
656
683
  var cachedConfig = null;
@@ -710,7 +737,8 @@ function getConfig(cwd) {
710
737
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
711
738
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
712
739
  },
713
- dlp: { ...DEFAULT_CONFIG.policy.dlp }
740
+ dlp: { ...DEFAULT_CONFIG.policy.dlp },
741
+ loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
714
742
  };
715
743
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
716
744
  const applyLayer = (source) => {
@@ -749,6 +777,13 @@ function getConfig(cwd) {
749
777
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
750
778
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
751
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
+ }
752
787
  const envs = source.environments || {};
753
788
  for (const [envName, envConfig] of Object.entries(envs)) {
754
789
  if (envConfig && typeof envConfig === "object") {
@@ -1523,9 +1558,9 @@ function matchesPattern(text, patterns) {
1523
1558
  const withoutDotSlash = text.replace(/^\.\//, "");
1524
1559
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1525
1560
  }
1526
- function getNestedValue(obj, path14) {
1561
+ function getNestedValue(obj, path15) {
1527
1562
  if (!obj || typeof obj !== "object") return null;
1528
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1563
+ return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
1529
1564
  }
1530
1565
  function evaluateSmartConditions(args, rule) {
1531
1566
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1665,6 +1700,9 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1665
1700
  reason: matchedRule.reason,
1666
1701
  tier: 2,
1667
1702
  ruleName: matchedRule.name ?? matchedRule.tool,
1703
+ ...(matchedRule.description ?? matchedRule.reason) && {
1704
+ ruleDescription: matchedRule.description ?? matchedRule.reason
1705
+ },
1668
1706
  ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1669
1707
  dependsOnStatePredicates: matchedRule.dependsOnState
1670
1708
  },
@@ -2090,7 +2128,7 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2090
2128
  }
2091
2129
 
2092
2130
  // src/auth/orchestrator.ts
2093
- var import_crypto2 = require("crypto");
2131
+ var import_crypto3 = require("crypto");
2094
2132
 
2095
2133
  // src/ui/native.ts
2096
2134
  var import_child_process2 = require("child_process");
@@ -2558,6 +2596,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
2558
2596
  }
2559
2597
  }
2560
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
+
2561
2645
  // src/auth/orchestrator.ts
2562
2646
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2563
2647
  "write",
@@ -2609,7 +2693,7 @@ function notifyActivity(data) {
2609
2693
  }
2610
2694
  async function authorizeHeadless(toolName, args, meta, options) {
2611
2695
  if (!options?.calledFromDaemon) {
2612
- const actId = (0, import_crypto2.randomUUID)();
2696
+ const actId = (0, import_crypto3.randomUUID)();
2613
2697
  const actTs = Date.now();
2614
2698
  await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
2615
2699
  const result = await _authorizeHeadlessCore(toolName, args, meta, {
@@ -2656,6 +2740,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2656
2740
  let explainableLabel = "Local Config";
2657
2741
  let policyMatchedField;
2658
2742
  let policyMatchedWord;
2743
+ let policyRuleDescription;
2659
2744
  let riskMetadata;
2660
2745
  let statefulRecoveryCommand;
2661
2746
  let localSmartRuleMatched = false;
@@ -2749,6 +2834,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2749
2834
  return { approved: true, checkedBy: "audit" };
2750
2835
  }
2751
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
+ }
2752
2852
  if (getActiveTrustSession(toolName)) {
2753
2853
  if (approvers.cloud && creds?.apiKey)
2754
2854
  await auditLocalAllow(toolName, args, "trust", creds, meta);
@@ -2795,7 +2895,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2795
2895
  blockedBy: "local-config",
2796
2896
  blockedByLabel: policyResult.blockedByLabel,
2797
2897
  ruleHit: policyResult.ruleName,
2798
- ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2898
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand },
2899
+ ...policyResult.ruleDescription && { ruleDescription: policyResult.ruleDescription }
2799
2900
  };
2800
2901
  }
2801
2902
  }
@@ -2803,6 +2904,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2803
2904
  policyMatchedField = policyResult.matchedField;
2804
2905
  policyMatchedWord = policyResult.matchedWord;
2805
2906
  if (policyResult.ruleName) localSmartRuleMatched = true;
2907
+ if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
2908
+ else if (policyResult.reason) policyRuleDescription = policyResult.reason;
2806
2909
  riskMetadata = computeRiskMetadata(
2807
2910
  args,
2808
2911
  policyResult.tier ?? 6,
@@ -3066,7 +3169,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3066
3169
  hashAuditArgs
3067
3170
  );
3068
3171
  }
3069
- return finalResult;
3172
+ const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
3173
+ return enrichedResult;
3070
3174
  }
3071
3175
  async function authorizeAction(toolName, args) {
3072
3176
  const result = await authorizeHeadless(toolName, args);