@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/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 path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
271
- return ` \u2022 ${path14}: ${issue.message}`;
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, path14) {
1561
+ function getNestedValue(obj, path15) {
1540
1562
  if (!obj || typeof obj !== "object") return null;
1541
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
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 import_crypto2 = require("crypto");
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, import_crypto2.randomUUID)();
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
- return finalResult;
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 path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
241
- return ` \u2022 ${path14}: ${issue.message}`;
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, path14) {
1531
+ function getNestedValue(obj, path15) {
1510
1532
  if (!obj || typeof obj !== "object") return null;
1511
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
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
- return finalResult;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.9.3",
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",