@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.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
 
@@ -169,6 +177,7 @@ var SmartRuleSchema = z.object({
169
177
  errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
170
178
  }),
171
179
  reason: z.string().optional(),
180
+ description: z.string().optional(),
172
181
  // Unknown predicate names are filtered out rather than failing the whole rule.
173
182
  // Failing the whole z.array() would cause sanitizeConfig to drop the entire
174
183
  // `policy` top-level key, silently disabling ALL smart rules in the config.
@@ -215,6 +224,11 @@ var ConfigFileSchema = z.object({
215
224
  dlp: z.object({
216
225
  enabled: z.boolean().optional(),
217
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()
218
232
  }).optional()
219
233
  }).optional(),
220
234
  environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
@@ -236,8 +250,8 @@ function sanitizeConfig(raw) {
236
250
  }
237
251
  }
238
252
  const lines = result.error.issues.map((issue) => {
239
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
240
- return ` \u2022 ${path14}: ${issue.message}`;
253
+ const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
254
+ return ` \u2022 ${path15}: ${issue.message}`;
241
255
  });
242
256
  return {
243
257
  sanitized,
@@ -462,7 +476,8 @@ var DEFAULT_CONFIG = {
462
476
  }
463
477
  ],
464
478
  verdict: "block",
465
- reason: "Recursive delete of home directory is irreversible"
479
+ reason: "Recursive delete of home directory is irreversible",
480
+ description: "The AI wants to recursively delete your home directory. This will permanently destroy all your personal files and cannot be undone."
466
481
  },
467
482
  // ── SQL safety ────────────────────────────────────────────────────────
468
483
  {
@@ -474,7 +489,8 @@ var DEFAULT_CONFIG = {
474
489
  ],
475
490
  conditionMode: "all",
476
491
  verdict: "review",
477
- reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
492
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table",
493
+ 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."
478
494
  },
479
495
  {
480
496
  name: "review-drop-truncate-shell",
@@ -489,7 +505,8 @@ var DEFAULT_CONFIG = {
489
505
  ],
490
506
  conditionMode: "all",
491
507
  verdict: "review",
492
- reason: "SQL DDL destructive statement inside a shell command"
508
+ reason: "SQL DDL destructive statement inside a shell command",
509
+ description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
493
510
  },
494
511
  // ── Git safety ────────────────────────────────────────────────────────
495
512
  {
@@ -505,7 +522,8 @@ var DEFAULT_CONFIG = {
505
522
  ],
506
523
  conditionMode: "all",
507
524
  verdict: "block",
508
- reason: "Force push overwrites remote history and cannot be undone"
525
+ reason: "Force push overwrites remote history and cannot be undone",
526
+ 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."
509
527
  },
510
528
  {
511
529
  name: "review-git-push",
@@ -520,7 +538,8 @@ var DEFAULT_CONFIG = {
520
538
  ],
521
539
  conditionMode: "all",
522
540
  verdict: "review",
523
- reason: "git push sends changes to a shared remote"
541
+ reason: "git push sends changes to a shared remote",
542
+ description: "The AI wants to push commits to a remote repository. Once pushed, those changes are visible to everyone with access."
524
543
  },
525
544
  {
526
545
  name: "review-git-destructive",
@@ -535,7 +554,8 @@ var DEFAULT_CONFIG = {
535
554
  ],
536
555
  conditionMode: "all",
537
556
  verdict: "review",
538
- reason: "Destructive git operation \u2014 discards history or working-tree changes"
557
+ reason: "Destructive git operation \u2014 discards history or working-tree changes",
558
+ description: "The AI wants to run a destructive git operation (reset, rebase, clean, or branch delete) that can permanently discard commits or uncommitted work."
539
559
  },
540
560
  // ── Shell safety ──────────────────────────────────────────────────────
541
561
  {
@@ -544,7 +564,8 @@ var DEFAULT_CONFIG = {
544
564
  conditions: [{ field: "command", op: "matches", value: "\\bsudo\\s", flags: "i" }],
545
565
  conditionMode: "all",
546
566
  verdict: "review",
547
- reason: "Command requires elevated privileges"
567
+ reason: "Command requires elevated privileges",
568
+ 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."
548
569
  },
549
570
  {
550
571
  name: "review-curl-pipe-shell",
@@ -559,10 +580,12 @@ var DEFAULT_CONFIG = {
559
580
  ],
560
581
  conditionMode: "all",
561
582
  verdict: "block",
562
- reason: "Piping remote script into a shell is a supply-chain attack vector"
583
+ reason: "Piping remote script into a shell is a supply-chain attack vector",
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."
563
585
  }
564
586
  ],
565
- dlp: { enabled: true, scanIgnoredTools: true }
587
+ dlp: { enabled: true, scanIgnoredTools: true },
588
+ loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 }
566
589
  },
567
590
  environments: {}
568
591
  };
@@ -592,7 +615,8 @@ var ADVISORY_SMART_RULES = [
592
615
  tool: "*",
593
616
  conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
594
617
  verdict: "review",
595
- reason: "rm can permanently delete files \u2014 confirm the target path"
618
+ reason: "rm can permanently delete files \u2014 confirm the target path",
619
+ description: "The AI wants to delete files. Unlike moving to trash, rm is permanent \u2014 the files cannot be recovered without a backup."
596
620
  },
597
621
  // ── SQL safety (Safe by Default) ──────────────────────────────────────────
598
622
  // These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
@@ -604,14 +628,16 @@ var ADVISORY_SMART_RULES = [
604
628
  tool: "*",
605
629
  conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
606
630
  verdict: "review",
607
- reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
631
+ reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead",
632
+ description: "The AI wants to drop a database table. This permanently deletes the table and all its data \u2014 there is no undo."
608
633
  },
609
634
  {
610
635
  name: "review-truncate-sql",
611
636
  tool: "*",
612
637
  conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
613
638
  verdict: "review",
614
- reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
639
+ reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead",
640
+ description: "The AI wants to truncate a database table, which instantly deletes every row. The table structure remains but all data is gone."
615
641
  },
616
642
  {
617
643
  name: "review-drop-column-sql",
@@ -620,7 +646,8 @@ var ADVISORY_SMART_RULES = [
620
646
  { field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
621
647
  ],
622
648
  verdict: "review",
623
- reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
649
+ reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead",
650
+ description: "The AI wants to drop a column from a database table. This permanently removes the column and all its data from every row."
624
651
  }
625
652
  ];
626
653
  var cachedConfig = null;
@@ -680,7 +707,8 @@ function getConfig(cwd) {
680
707
  onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
681
708
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
682
709
  },
683
- dlp: { ...DEFAULT_CONFIG.policy.dlp }
710
+ dlp: { ...DEFAULT_CONFIG.policy.dlp },
711
+ loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection }
684
712
  };
685
713
  const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
686
714
  const applyLayer = (source) => {
@@ -719,6 +747,13 @@ function getConfig(cwd) {
719
747
  if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
720
748
  if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
721
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
+ }
722
757
  const envs = source.environments || {};
723
758
  for (const [envName, envConfig] of Object.entries(envs)) {
724
759
  if (envConfig && typeof envConfig === "object") {
@@ -1493,9 +1528,9 @@ function matchesPattern(text, patterns) {
1493
1528
  const withoutDotSlash = text.replace(/^\.\//, "");
1494
1529
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1495
1530
  }
1496
- function getNestedValue(obj, path14) {
1531
+ function getNestedValue(obj, path15) {
1497
1532
  if (!obj || typeof obj !== "object") return null;
1498
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1533
+ return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
1499
1534
  }
1500
1535
  function evaluateSmartConditions(args, rule) {
1501
1536
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1635,6 +1670,9 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1635
1670
  reason: matchedRule.reason,
1636
1671
  tier: 2,
1637
1672
  ruleName: matchedRule.name ?? matchedRule.tool,
1673
+ ...(matchedRule.description ?? matchedRule.reason) && {
1674
+ ruleDescription: matchedRule.description ?? matchedRule.reason
1675
+ },
1638
1676
  ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1639
1677
  dependsOnStatePredicates: matchedRule.dependsOnState
1640
1678
  },
@@ -2528,6 +2566,52 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
2528
2566
  }
2529
2567
  }
2530
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
+
2531
2615
  // src/auth/orchestrator.ts
2532
2616
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2533
2617
  "write",
@@ -2626,6 +2710,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2626
2710
  let explainableLabel = "Local Config";
2627
2711
  let policyMatchedField;
2628
2712
  let policyMatchedWord;
2713
+ let policyRuleDescription;
2629
2714
  let riskMetadata;
2630
2715
  let statefulRecoveryCommand;
2631
2716
  let localSmartRuleMatched = false;
@@ -2719,6 +2804,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2719
2804
  return { approved: true, checkedBy: "audit" };
2720
2805
  }
2721
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
+ }
2722
2822
  if (getActiveTrustSession(toolName)) {
2723
2823
  if (approvers.cloud && creds?.apiKey)
2724
2824
  await auditLocalAllow(toolName, args, "trust", creds, meta);
@@ -2765,7 +2865,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2765
2865
  blockedBy: "local-config",
2766
2866
  blockedByLabel: policyResult.blockedByLabel,
2767
2867
  ruleHit: policyResult.ruleName,
2768
- ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2868
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand },
2869
+ ...policyResult.ruleDescription && { ruleDescription: policyResult.ruleDescription }
2769
2870
  };
2770
2871
  }
2771
2872
  }
@@ -2773,6 +2874,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2773
2874
  policyMatchedField = policyResult.matchedField;
2774
2875
  policyMatchedWord = policyResult.matchedWord;
2775
2876
  if (policyResult.ruleName) localSmartRuleMatched = true;
2877
+ if (policyResult.ruleDescription) policyRuleDescription = policyResult.ruleDescription;
2878
+ else if (policyResult.reason) policyRuleDescription = policyResult.reason;
2776
2879
  riskMetadata = computeRiskMetadata(
2777
2880
  args,
2778
2881
  policyResult.tier ?? 6,
@@ -3036,7 +3139,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3036
3139
  hashAuditArgs
3037
3140
  );
3038
3141
  }
3039
- return finalResult;
3142
+ const enrichedResult = !finalResult.approved && policyRuleDescription && !finalResult.ruleDescription ? { ...finalResult, ruleDescription: policyRuleDescription } : finalResult;
3143
+ return enrichedResult;
3040
3144
  }
3041
3145
  async function authorizeAction(toolName, args) {
3042
3146
  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.2",
3
+ "version": "1.10.0",
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",