@node9/proxy 1.0.0 → 1.0.2

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
@@ -1,5 +1,5 @@
1
1
  // src/core.ts
2
- import chalk from "chalk";
2
+ import chalk2 from "chalk";
3
3
  import { confirm } from "@inquirer/prompts";
4
4
  import fs from "fs";
5
5
  import path from "path";
@@ -9,19 +9,69 @@ import { parse } from "sh-syntax";
9
9
 
10
10
  // src/ui/native.ts
11
11
  import { spawn } from "child_process";
12
+ import chalk from "chalk";
12
13
  var isTestEnv = () => {
13
14
  return process.env.NODE_ENV === "test" || process.env.VITEST === "true" || !!process.env.VITEST || process.env.CI === "true" || !!process.env.CI || process.env.NODE9_TESTING === "1";
14
15
  };
16
+ function smartTruncate(str, maxLen = 500) {
17
+ if (str.length <= maxLen) return str;
18
+ const edge = Math.floor(maxLen / 2) - 3;
19
+ return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
20
+ }
21
+ function formatArgs(args) {
22
+ if (args === null || args === void 0) return "(none)";
23
+ let parsed = args;
24
+ if (typeof args === "string") {
25
+ const trimmed = args.trim();
26
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
27
+ try {
28
+ parsed = JSON.parse(trimmed);
29
+ } catch {
30
+ parsed = args;
31
+ }
32
+ } else {
33
+ return smartTruncate(args, 600);
34
+ }
35
+ }
36
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
37
+ const obj = parsed;
38
+ const codeKeys = [
39
+ "command",
40
+ "cmd",
41
+ "shell_command",
42
+ "bash_command",
43
+ "script",
44
+ "code",
45
+ "input",
46
+ "sql",
47
+ "query",
48
+ "arguments",
49
+ "args",
50
+ "param",
51
+ "params",
52
+ "text"
53
+ ];
54
+ const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
55
+ if (foundKey) {
56
+ const val = obj[foundKey];
57
+ const str = typeof val === "string" ? val : JSON.stringify(val);
58
+ return `[${foundKey.toUpperCase()}]:
59
+ ${smartTruncate(str, 500)}`;
60
+ }
61
+ return Object.entries(obj).slice(0, 5).map(
62
+ ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
63
+ ).join("\n");
64
+ }
65
+ return smartTruncate(JSON.stringify(parsed), 200);
66
+ }
15
67
  function sendDesktopNotification(title, body) {
16
68
  if (isTestEnv()) return;
17
69
  try {
18
- const safeTitle = title.replace(/"/g, '\\"');
19
- const safeBody = body.replace(/"/g, '\\"');
20
70
  if (process.platform === "darwin") {
21
- const script = `display notification "${safeBody}" with title "${safeTitle}"`;
71
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
22
72
  spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
23
73
  } else if (process.platform === "linux") {
24
- spawn("notify-send", [safeTitle, safeBody, "--icon=dialog-warning"], {
74
+ spawn("notify-send", [title, body, "--icon=dialog-warning"], {
25
75
  detached: true,
26
76
  stdio: "ignore"
27
77
  }).unref();
@@ -29,69 +79,54 @@ function sendDesktopNotification(title, body) {
29
79
  } catch {
30
80
  }
31
81
  }
32
- function formatArgs(args) {
33
- if (args === null || args === void 0) return "(none)";
34
- if (typeof args !== "object" || Array.isArray(args)) {
35
- const str = typeof args === "string" ? args : JSON.stringify(args);
36
- return str.length > 200 ? str.slice(0, 200) + "\u2026" : str;
37
- }
38
- const entries = Object.entries(args).filter(
39
- ([, v]) => v !== null && v !== void 0 && v !== ""
40
- );
41
- if (entries.length === 0) return "(none)";
42
- const MAX_FIELDS = 5;
43
- const MAX_VALUE_LEN = 120;
44
- const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
45
- const str = typeof val === "string" ? val : JSON.stringify(val);
46
- const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
47
- return ` ${key}: ${truncated}`;
48
- });
49
- if (entries.length > MAX_FIELDS) {
50
- lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
82
+ function escapePango(text) {
83
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
84
+ }
85
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
86
+ const lines = [];
87
+ if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
88
+ lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
89
+ lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
90
+ lines.push("");
91
+ lines.push(formattedArgs);
92
+ if (!locked) {
93
+ lines.push("");
94
+ lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
51
95
  }
52
96
  return lines.join("\n");
53
97
  }
54
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
55
- if (isTestEnv()) return "deny";
56
- if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
57
- console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
58
- console.log(`[DEBUG Native] isTestEnv check:`, {
59
- VITEST: process.env.VITEST,
60
- NODE_ENV: process.env.NODE_ENV,
61
- CI: process.env.CI,
62
- isTest: isTestEnv()
63
- });
64
- }
65
- const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
66
- let message = "";
98
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
99
+ const lines = [];
67
100
  if (locked) {
68
- message += `\u26A1 Awaiting remote approval via Slack. Local override is disabled.
69
- `;
70
- message += `\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
71
- `;
72
- }
73
- message += `Tool: ${toolName}
74
- `;
75
- message += `Agent: ${agent || "AI Agent"}
76
- `;
77
- if (explainableLabel) {
78
- message += `Reason: ${explainableLabel}
79
- `;
101
+ lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
102
+ lines.push("");
80
103
  }
81
- message += `
82
- Arguments:
83
- ${formatArgs(args)}`;
104
+ lines.push(
105
+ `<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
106
+ );
107
+ lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
108
+ lines.push("");
109
+ lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
84
110
  if (!locked) {
85
- message += `
86
-
87
- Enter = Allow | Click "Block" to deny`;
111
+ lines.push("");
112
+ lines.push(
113
+ '<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
114
+ );
88
115
  }
89
- const safeMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "'");
90
- const safeTitle = title.replace(/"/g, '\\"');
116
+ return lines.join("\n");
117
+ }
118
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
119
+ if (isTestEnv()) return "deny";
120
+ const formattedArgs = formatArgs(args);
121
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
122
+ const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
123
+ process.stderr.write(chalk.yellow(`
124
+ \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
125
+ `));
91
126
  return new Promise((resolve) => {
92
127
  let childProcess = null;
93
128
  const onAbort = () => {
94
- if (childProcess) {
129
+ if (childProcess && childProcess.pid) {
95
130
  try {
96
131
  process.kill(childProcess.pid, "SIGKILL");
97
132
  } catch {
@@ -103,83 +138,58 @@ Enter = Allow | Click "Block" to deny`;
103
138
  if (signal.aborted) return resolve("deny");
104
139
  signal.addEventListener("abort", onAbort);
105
140
  }
106
- const cleanup = () => {
107
- if (signal) signal.removeEventListener("abort", onAbort);
108
- };
109
141
  try {
110
142
  if (process.platform === "darwin") {
111
- const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
112
- const script = `
113
- tell application "System Events"
114
- activate
115
- display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
116
- end tell`;
117
- childProcess = spawn("osascript", ["-e", script]);
118
- let output = "";
119
- childProcess.stdout?.on("data", (d) => output += d.toString());
120
- childProcess.on("close", (code) => {
121
- cleanup();
122
- if (locked) return resolve("deny");
123
- if (code === 0) {
124
- if (output.includes("Always Allow")) return resolve("always_allow");
125
- if (output.includes("Allow")) return resolve("allow");
126
- }
127
- resolve("deny");
128
- });
143
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "Always Allow", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
144
+ const script = `on run argv
145
+ tell application "System Events"
146
+ activate
147
+ display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
148
+ end tell
149
+ end run`;
150
+ childProcess = spawn("osascript", ["-e", script, "--", message, title]);
129
151
  } else if (process.platform === "linux") {
130
- const argsList = locked ? [
131
- "--info",
132
- "--title",
133
- title,
134
- "--text",
135
- safeMessage,
136
- "--ok-label",
137
- "Waiting for Slack\u2026",
138
- "--timeout",
139
- "300"
140
- ] : [
141
- "--question",
152
+ const pangoMessage = buildPangoMessage(
153
+ toolName,
154
+ formattedArgs,
155
+ agent,
156
+ explainableLabel,
157
+ locked
158
+ );
159
+ const argsList = [
160
+ locked ? "--info" : "--question",
161
+ "--modal",
162
+ "--width=480",
142
163
  "--title",
143
164
  title,
144
165
  "--text",
145
- safeMessage,
166
+ pangoMessage,
146
167
  "--ok-label",
147
- "Allow",
148
- "--cancel-label",
149
- "Block",
150
- "--extra-button",
151
- "Always Allow",
168
+ locked ? "Waiting..." : "Allow \u21B5",
152
169
  "--timeout",
153
170
  "300"
154
171
  ];
172
+ if (!locked) {
173
+ argsList.push("--cancel-label", "Block \u238B");
174
+ argsList.push("--extra-button", "Always Allow");
175
+ }
155
176
  childProcess = spawn("zenity", argsList);
156
- let output = "";
157
- childProcess.stdout?.on("data", (d) => output += d.toString());
158
- childProcess.on("close", (code) => {
159
- cleanup();
160
- if (locked) return resolve("deny");
161
- if (output.trim() === "Always Allow") return resolve("always_allow");
162
- if (code === 0) return resolve("allow");
163
- resolve("deny");
164
- });
165
177
  } else if (process.platform === "win32") {
166
- const buttonType = locked ? "OK" : "YesNo";
167
- const ps = `
168
- Add-Type -AssemblyName PresentationFramework;
169
- $res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
170
- if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
178
+ const b64Msg = Buffer.from(message).toString("base64");
179
+ const b64Title = Buffer.from(title).toString("base64");
180
+ const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
171
181
  childProcess = spawn("powershell", ["-Command", ps]);
172
- childProcess.on("close", (code) => {
173
- cleanup();
174
- if (locked) return resolve("deny");
175
- resolve(code === 0 ? "allow" : "deny");
176
- });
177
- } else {
178
- cleanup();
179
- resolve("deny");
180
182
  }
183
+ let output = "";
184
+ childProcess?.stdout?.on("data", (d) => output += d.toString());
185
+ childProcess?.on("close", (code) => {
186
+ if (signal) signal.removeEventListener("abort", onAbort);
187
+ if (locked) return resolve("deny");
188
+ if (output.includes("Always Allow")) return resolve("always_allow");
189
+ if (code === 0) return resolve("allow");
190
+ resolve("deny");
191
+ });
181
192
  } catch {
182
- cleanup();
183
193
  resolve("deny");
184
194
  }
185
195
  });
@@ -188,6 +198,8 @@ Enter = Allow | Click "Block" to deny`;
188
198
  // src/core.ts
189
199
  var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
190
200
  var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
201
+ var LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
202
+ var HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
191
203
  function checkPause() {
192
204
  try {
193
205
  if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
@@ -244,36 +256,39 @@ function writeTrustSession(toolName, durationMs) {
244
256
  }
245
257
  }
246
258
  }
247
- function appendAuditModeEntry(toolName, args) {
259
+ function appendToLog(logPath, entry) {
248
260
  try {
249
- const entry = JSON.stringify({
250
- ts: (/* @__PURE__ */ new Date()).toISOString(),
251
- tool: toolName,
252
- args,
253
- decision: "would-have-blocked",
254
- source: "audit-mode"
255
- });
256
- const logPath = path.join(os.homedir(), ".node9", "audit.log");
257
261
  const dir = path.dirname(logPath);
258
262
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
259
- fs.appendFileSync(logPath, entry + "\n");
263
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
260
264
  } catch {
261
265
  }
262
266
  }
263
- var DANGEROUS_WORDS = [
264
- "delete",
265
- "drop",
266
- "remove",
267
- "terminate",
268
- "refund",
269
- "write",
270
- "update",
271
- "destroy",
272
- "rm",
273
- "rmdir",
274
- "purge",
275
- "format"
276
- ];
267
+ function appendHookDebug(toolName, args, meta) {
268
+ const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
269
+ appendToLog(HOOK_DEBUG_LOG, {
270
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
271
+ tool: toolName,
272
+ args: safeArgs,
273
+ agent: meta?.agent,
274
+ mcpServer: meta?.mcpServer,
275
+ hostname: os.hostname(),
276
+ cwd: process.cwd()
277
+ });
278
+ }
279
+ function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
280
+ const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
281
+ appendToLog(LOCAL_AUDIT_LOG, {
282
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
283
+ tool: toolName,
284
+ args: safeArgs,
285
+ decision,
286
+ checkedBy,
287
+ agent: meta?.agent,
288
+ mcpServer: meta?.mcpServer,
289
+ hostname: os.hostname()
290
+ });
291
+ }
277
292
  function tokenize(toolName) {
278
293
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
279
294
  }
@@ -367,16 +382,41 @@ async function analyzeShellCommand(command) {
367
382
  }
368
383
  return { actions, paths, allTokens };
369
384
  }
385
+ function redactSecrets(text) {
386
+ if (!text) return text;
387
+ let redacted = text;
388
+ redacted = redacted.replace(
389
+ /(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
390
+ "$1********"
391
+ );
392
+ redacted = redacted.replace(
393
+ /(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
394
+ "$1$2********"
395
+ );
396
+ return redacted;
397
+ }
398
+ var DANGEROUS_WORDS = [
399
+ "drop",
400
+ "truncate",
401
+ "purge",
402
+ "format",
403
+ "destroy",
404
+ "terminate",
405
+ "revoke",
406
+ "docker",
407
+ "psql"
408
+ ];
370
409
  var DEFAULT_CONFIG = {
371
410
  settings: {
372
411
  mode: "standard",
373
412
  autoStartDaemon: true,
374
- enableUndo: false,
413
+ enableUndo: true,
414
+ // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
375
415
  enableHookLogDebug: false,
376
416
  approvers: { native: true, browser: true, cloud: true, terminal: true }
377
417
  },
378
418
  policy: {
379
- sandboxPaths: [],
419
+ sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
380
420
  dangerousWords: DANGEROUS_WORDS,
381
421
  ignoredTools: [
382
422
  "list_*",
@@ -384,12 +424,44 @@ var DEFAULT_CONFIG = {
384
424
  "read_*",
385
425
  "describe_*",
386
426
  "read",
427
+ "glob",
387
428
  "grep",
388
429
  "ls",
389
- "askuserquestion"
430
+ "notebookread",
431
+ "notebookedit",
432
+ "webfetch",
433
+ "websearch",
434
+ "exitplanmode",
435
+ "askuserquestion",
436
+ "agent",
437
+ "task*",
438
+ "toolsearch",
439
+ "mcp__ide__*",
440
+ "getDiagnostics"
390
441
  ],
391
- toolInspection: { bash: "command", shell: "command" },
392
- rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
442
+ toolInspection: {
443
+ bash: "command",
444
+ shell: "command",
445
+ run_shell_command: "command",
446
+ "terminal.execute": "command",
447
+ "postgres:query": "sql"
448
+ },
449
+ rules: [
450
+ {
451
+ action: "rm",
452
+ allowPaths: [
453
+ "**/node_modules/**",
454
+ "dist/**",
455
+ "build/**",
456
+ ".next/**",
457
+ "coverage/**",
458
+ ".cache/**",
459
+ "tmp/**",
460
+ "temp/**",
461
+ ".DS_Store"
462
+ ]
463
+ }
464
+ ]
393
465
  },
394
466
  environments: {}
395
467
  };
@@ -427,20 +499,15 @@ async function evaluatePolicy(toolName, args, agent) {
427
499
  }
428
500
  const isManual = agent === "Terminal";
429
501
  if (isManual) {
430
- const NUCLEAR_COMMANDS = [
431
- "drop",
432
- "destroy",
433
- "purge",
434
- "rmdir",
435
- "format",
436
- "truncate",
437
- "alter",
438
- "grant",
439
- "revoke",
440
- "docker"
441
- ];
442
- const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
443
- if (!hasNuclear) return { decision: "allow" };
502
+ const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
503
+ const hasSystemDisaster = allTokens.some(
504
+ (t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
505
+ );
506
+ const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
507
+ if (hasSystemDisaster || isRootWipe) {
508
+ return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
509
+ }
510
+ return { decision: "allow" };
444
511
  }
445
512
  if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
446
513
  const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
@@ -454,27 +521,39 @@ async function evaluatePolicy(toolName, args, agent) {
454
521
  if (pathTokens.length > 0) {
455
522
  const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
456
523
  if (anyBlocked)
457
- return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
524
+ return {
525
+ decision: "review",
526
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
527
+ };
458
528
  const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
459
529
  if (allAllowed) return { decision: "allow" };
460
530
  }
461
- return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
531
+ return {
532
+ decision: "review",
533
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
534
+ };
462
535
  }
463
536
  }
537
+ let matchedDangerousWord;
464
538
  const isDangerous = allTokens.some(
465
539
  (token) => config.policy.dangerousWords.some((word) => {
466
540
  const w = word.toLowerCase();
467
- if (token === w) return true;
468
- try {
469
- return new RegExp(`\\b${w}\\b`, "i").test(token);
470
- } catch {
471
- return false;
472
- }
541
+ const hit = token === w || (() => {
542
+ try {
543
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
544
+ } catch {
545
+ return false;
546
+ }
547
+ })();
548
+ if (hit && !matchedDangerousWord) matchedDangerousWord = word;
549
+ return hit;
473
550
  })
474
551
  );
475
552
  if (isDangerous) {
476
- const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
477
- return { decision: "review", blockedByLabel: label };
553
+ return {
554
+ decision: "review",
555
+ blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`
556
+ };
478
557
  }
479
558
  if (config.settings.mode === "strict") {
480
559
  const envConfig = getActiveEnvironment(config);
@@ -589,13 +668,16 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
589
668
  approvers.browser = false;
590
669
  approvers.terminal = false;
591
670
  }
671
+ if (config.settings.enableHookLogDebug && !isTestEnv2) {
672
+ appendHookDebug(toolName, args, meta);
673
+ }
592
674
  const isManual = meta?.agent === "Terminal";
593
675
  let explainableLabel = "Local Config";
594
676
  if (config.settings.mode === "audit") {
595
677
  if (!isIgnoredTool(toolName)) {
596
678
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
597
679
  if (policyResult.decision === "review") {
598
- appendAuditModeEntry(toolName, args);
680
+ appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
599
681
  sendDesktopNotification(
600
682
  "Node9 Audit Mode",
601
683
  `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
@@ -607,20 +689,24 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
607
689
  if (!isIgnoredTool(toolName)) {
608
690
  if (getActiveTrustSession(toolName)) {
609
691
  if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
692
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
610
693
  return { approved: true, checkedBy: "trust" };
611
694
  }
612
695
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
613
696
  if (policyResult.decision === "allow") {
614
697
  if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
698
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
615
699
  return { approved: true, checkedBy: "local-policy" };
616
700
  }
617
701
  explainableLabel = policyResult.blockedByLabel || "Local Config";
618
702
  const persistent = getPersistentDecision(toolName);
619
703
  if (persistent === "allow") {
620
704
  if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
705
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
621
706
  return { approved: true, checkedBy: "persistent" };
622
707
  }
623
708
  if (persistent === "deny") {
709
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
624
710
  return {
625
711
  approved: false,
626
712
  reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
@@ -630,6 +716,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
630
716
  }
631
717
  } else {
632
718
  if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
719
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
633
720
  return { approved: true };
634
721
  }
635
722
  let cloudRequestId = null;
@@ -657,8 +744,8 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
657
744
  const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
658
745
  const reason = isAuthError ? "Invalid or missing API key. Run `node9 login` to generate a key (must start with n9_live_)." : isNetworkError ? "Could not reach the Node9 cloud. Check your network or API URL." : error.message;
659
746
  console.error(
660
- chalk.yellow(`
661
- \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk.dim(`
747
+ chalk2.yellow(`
748
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk2.dim(`
662
749
  Falling back to local rules...
663
750
  `)
664
751
  );
@@ -666,13 +753,13 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
666
753
  }
667
754
  if (cloudEnforced && cloudRequestId) {
668
755
  console.error(
669
- chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
756
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
670
757
  );
671
- console.error(chalk.cyan(" Dashboard \u2192 ") + chalk.bold("Mission Control > Activity Feed\n"));
758
+ console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
672
759
  } else if (!cloudEnforced) {
673
760
  const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
674
761
  console.error(
675
- chalk.dim(`
762
+ chalk2.dim(`
676
763
  \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
677
764
  `)
678
765
  );
@@ -737,9 +824,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
737
824
  try {
738
825
  if (!approvers.native && !cloudEnforced) {
739
826
  console.error(
740
- chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
827
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
741
828
  );
742
- console.error(chalk.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
829
+ console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
743
830
  `));
744
831
  }
745
832
  const daemonDecision = await askDaemon(toolName, args, meta, signal);
@@ -762,11 +849,11 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
762
849
  racePromises.push(
763
850
  (async () => {
764
851
  try {
765
- console.log(chalk.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
766
- console.log(`${chalk.bold("Action:")} ${chalk.red(toolName)}`);
767
- console.log(`${chalk.bold("Flagged By:")} ${chalk.yellow(explainableLabel)}`);
852
+ console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
853
+ console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
854
+ console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
768
855
  if (isRemoteLocked) {
769
- console.log(chalk.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
856
+ console.log(chalk2.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
770
857
  `));
771
858
  await new Promise((_, reject) => {
772
859
  signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
@@ -854,6 +941,15 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
854
941
  if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
855
942
  await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
856
943
  }
944
+ if (!isManual) {
945
+ appendLocalAudit(
946
+ toolName,
947
+ args,
948
+ finalResult.approved ? "allow" : "deny",
949
+ finalResult.checkedBy || finalResult.blockedBy || "unknown",
950
+ meta
951
+ );
952
+ }
857
953
  return finalResult;
858
954
  }
859
955
  function getConfig() {
@@ -884,8 +980,8 @@ function getConfig() {
884
980
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
885
981
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
886
982
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
887
- if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
888
983
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
984
+ if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
889
985
  if (p.toolInspection)
890
986
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
891
987
  if (p.rules) mergedPolicy.rules.push(...p.rules);
@@ -1017,11 +1113,11 @@ async function pollNode9SaaS(requestId, creds, signal) {
1017
1113
  if (!statusRes.ok) continue;
1018
1114
  const { status, reason } = await statusRes.json();
1019
1115
  if (status === "APPROVED") {
1020
- console.error(chalk.green("\u2705 Approved via Cloud.\n"));
1116
+ console.error(chalk2.green("\u2705 Approved via Cloud.\n"));
1021
1117
  return { approved: true, reason };
1022
1118
  }
1023
1119
  if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
1024
- console.error(chalk.red("\u274C Denied via Cloud.\n"));
1120
+ console.error(chalk2.red("\u274C Denied via Cloud.\n"));
1025
1121
  return { approved: false, reason };
1026
1122
  }
1027
1123
  } catch {