@node9/proxy 0.2.1 → 1.0.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.
Files changed (6) hide show
  1. package/README.md +66 -319
  2. package/dist/cli.js +1417 -608
  3. package/dist/cli.mjs +1417 -608
  4. package/dist/index.js +722 -261
  5. package/dist/index.mjs +722 -261
  6. package/package.json +44 -8
package/dist/cli.mjs CHANGED
@@ -4,13 +4,254 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/core.ts
7
- import chalk from "chalk";
7
+ import chalk2 from "chalk";
8
8
  import { confirm } from "@inquirer/prompts";
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import os from "os";
12
12
  import pm from "picomatch";
13
13
  import { parse } from "sh-syntax";
14
+
15
+ // src/ui/native.ts
16
+ import { spawn } from "child_process";
17
+ import chalk from "chalk";
18
+ var isTestEnv = () => {
19
+ 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";
20
+ };
21
+ function smartTruncate(str, maxLen = 500) {
22
+ if (str.length <= maxLen) return str;
23
+ const edge = Math.floor(maxLen / 2) - 3;
24
+ return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
25
+ }
26
+ function formatArgs(args) {
27
+ if (args === null || args === void 0) return "(none)";
28
+ let parsed = args;
29
+ if (typeof args === "string") {
30
+ const trimmed = args.trim();
31
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
32
+ try {
33
+ parsed = JSON.parse(trimmed);
34
+ } catch {
35
+ parsed = args;
36
+ }
37
+ } else {
38
+ return smartTruncate(args, 600);
39
+ }
40
+ }
41
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
42
+ const obj = parsed;
43
+ const codeKeys = [
44
+ "command",
45
+ "cmd",
46
+ "shell_command",
47
+ "bash_command",
48
+ "script",
49
+ "code",
50
+ "input",
51
+ "sql",
52
+ "query",
53
+ "arguments",
54
+ "args",
55
+ "param",
56
+ "params",
57
+ "text"
58
+ ];
59
+ const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
60
+ if (foundKey) {
61
+ const val = obj[foundKey];
62
+ const str = typeof val === "string" ? val : JSON.stringify(val);
63
+ return `[${foundKey.toUpperCase()}]:
64
+ ${smartTruncate(str, 500)}`;
65
+ }
66
+ return Object.entries(obj).slice(0, 5).map(
67
+ ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
68
+ ).join("\n");
69
+ }
70
+ return smartTruncate(JSON.stringify(parsed), 200);
71
+ }
72
+ function sendDesktopNotification(title, body) {
73
+ if (isTestEnv()) return;
74
+ try {
75
+ if (process.platform === "darwin") {
76
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
77
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
78
+ } else if (process.platform === "linux") {
79
+ spawn("notify-send", [title, body, "--icon=dialog-warning"], {
80
+ detached: true,
81
+ stdio: "ignore"
82
+ }).unref();
83
+ }
84
+ } catch {
85
+ }
86
+ }
87
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
88
+ if (isTestEnv()) return "deny";
89
+ const formattedArgs = formatArgs(args);
90
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
91
+ let message = "";
92
+ if (locked) message += `\u26A0\uFE0F LOCKED BY ADMIN POLICY
93
+ `;
94
+ message += `Tool: ${toolName}
95
+ `;
96
+ message += `Agent: ${agent || "AI Agent"}
97
+ `;
98
+ message += `Rule: ${explainableLabel || "Security Policy"}
99
+
100
+ `;
101
+ message += `${formattedArgs}`;
102
+ process.stderr.write(chalk.yellow(`
103
+ \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
104
+ `));
105
+ return new Promise((resolve) => {
106
+ let childProcess = null;
107
+ const onAbort = () => {
108
+ if (childProcess && childProcess.pid) {
109
+ try {
110
+ process.kill(childProcess.pid, "SIGKILL");
111
+ } catch {
112
+ }
113
+ }
114
+ resolve("deny");
115
+ };
116
+ if (signal) {
117
+ if (signal.aborted) return resolve("deny");
118
+ signal.addEventListener("abort", onAbort);
119
+ }
120
+ try {
121
+ if (process.platform === "darwin") {
122
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
123
+ const script = `on run argv
124
+ tell application "System Events"
125
+ activate
126
+ display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
127
+ end tell
128
+ end run`;
129
+ childProcess = spawn("osascript", ["-e", script, "--", message, title]);
130
+ } else if (process.platform === "linux") {
131
+ const argsList = [
132
+ locked ? "--info" : "--question",
133
+ "--modal",
134
+ "--width=450",
135
+ "--title",
136
+ title,
137
+ "--text",
138
+ message,
139
+ "--ok-label",
140
+ locked ? "Waiting..." : "Allow",
141
+ "--timeout",
142
+ "300"
143
+ ];
144
+ if (!locked) {
145
+ argsList.push("--cancel-label", "Block");
146
+ argsList.push("--extra-button", "Always Allow");
147
+ }
148
+ childProcess = spawn("zenity", argsList);
149
+ } else if (process.platform === "win32") {
150
+ const b64Msg = Buffer.from(message).toString("base64");
151
+ const b64Title = Buffer.from(title).toString("base64");
152
+ 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 }`;
153
+ childProcess = spawn("powershell", ["-Command", ps]);
154
+ }
155
+ let output = "";
156
+ childProcess?.stdout?.on("data", (d) => output += d.toString());
157
+ childProcess?.on("close", (code) => {
158
+ if (signal) signal.removeEventListener("abort", onAbort);
159
+ if (locked) return resolve("deny");
160
+ if (output.includes("Always Allow")) return resolve("always_allow");
161
+ if (code === 0) return resolve("allow");
162
+ resolve("deny");
163
+ });
164
+ } catch {
165
+ resolve("deny");
166
+ }
167
+ });
168
+ }
169
+
170
+ // src/core.ts
171
+ var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
172
+ var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
173
+ function checkPause() {
174
+ try {
175
+ if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
176
+ const state = JSON.parse(fs.readFileSync(PAUSED_FILE, "utf-8"));
177
+ if (state.expiry > 0 && Date.now() >= state.expiry) {
178
+ try {
179
+ fs.unlinkSync(PAUSED_FILE);
180
+ } catch {
181
+ }
182
+ return { paused: false };
183
+ }
184
+ return { paused: true, expiresAt: state.expiry, duration: state.duration };
185
+ } catch {
186
+ return { paused: false };
187
+ }
188
+ }
189
+ function atomicWriteSync(filePath, data, options) {
190
+ const dir = path.dirname(filePath);
191
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
192
+ const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
193
+ fs.writeFileSync(tmpPath, data, options);
194
+ fs.renameSync(tmpPath, filePath);
195
+ }
196
+ function pauseNode9(durationMs, durationStr) {
197
+ const state = { expiry: Date.now() + durationMs, duration: durationStr };
198
+ atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
199
+ }
200
+ function resumeNode9() {
201
+ try {
202
+ if (fs.existsSync(PAUSED_FILE)) fs.unlinkSync(PAUSED_FILE);
203
+ } catch {
204
+ }
205
+ }
206
+ function getActiveTrustSession(toolName) {
207
+ try {
208
+ if (!fs.existsSync(TRUST_FILE)) return false;
209
+ const trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
210
+ const now = Date.now();
211
+ const active = trust.entries.filter((e) => e.expiry > now);
212
+ if (active.length !== trust.entries.length) {
213
+ fs.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
214
+ }
215
+ return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+ function writeTrustSession(toolName, durationMs) {
221
+ try {
222
+ let trust = { entries: [] };
223
+ try {
224
+ if (fs.existsSync(TRUST_FILE)) {
225
+ trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
226
+ }
227
+ } catch {
228
+ }
229
+ const now = Date.now();
230
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
231
+ trust.entries.push({ tool: toolName, expiry: now + durationMs });
232
+ atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
233
+ } catch (err) {
234
+ if (process.env.NODE9_DEBUG === "1") {
235
+ console.error("[Node9 Trust Error]:", err);
236
+ }
237
+ }
238
+ }
239
+ function appendAuditModeEntry(toolName, args) {
240
+ try {
241
+ const entry = JSON.stringify({
242
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
243
+ tool: toolName,
244
+ args,
245
+ decision: "would-have-blocked",
246
+ source: "audit-mode"
247
+ });
248
+ const logPath = path.join(os.homedir(), ".node9", "audit.log");
249
+ const dir = path.dirname(logPath);
250
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
251
+ fs.appendFileSync(logPath, entry + "\n");
252
+ } catch {
253
+ }
254
+ }
14
255
  var DANGEROUS_WORDS = [
15
256
  "delete",
16
257
  "drop",
@@ -28,10 +269,6 @@ var DANGEROUS_WORDS = [
28
269
  function tokenize(toolName) {
29
270
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
30
271
  }
31
- function containsDangerousWord(toolName, dangerousWords) {
32
- const tokens = tokenize(toolName);
33
- return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
34
- }
35
272
  function matchesPattern(text, patterns) {
36
273
  const p = Array.isArray(patterns) ? patterns : [patterns];
37
274
  if (p.length === 0) return false;
@@ -42,9 +279,9 @@ function matchesPattern(text, patterns) {
42
279
  const withoutDotSlash = text.replace(/^\.\//, "");
43
280
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
44
281
  }
45
- function getNestedValue(obj, path5) {
282
+ function getNestedValue(obj, path6) {
46
283
  if (!obj || typeof obj !== "object") return null;
47
- return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
284
+ return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
48
285
  }
49
286
  function extractShellCommand(toolName, args, toolInspection) {
50
287
  const patterns = Object.keys(toolInspection);
@@ -136,8 +373,15 @@ function redactSecrets(text) {
136
373
  return redacted;
137
374
  }
138
375
  var DEFAULT_CONFIG = {
139
- settings: { mode: "standard" },
376
+ settings: {
377
+ mode: "standard",
378
+ autoStartDaemon: true,
379
+ enableUndo: false,
380
+ enableHookLogDebug: false,
381
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
382
+ },
140
383
  policy: {
384
+ sandboxPaths: [],
141
385
  dangerousWords: DANGEROUS_WORDS,
142
386
  ignoredTools: [
143
387
  "list_*",
@@ -145,34 +389,19 @@ var DEFAULT_CONFIG = {
145
389
  "read_*",
146
390
  "describe_*",
147
391
  "read",
148
- "write",
149
- "edit",
150
- "multiedit",
151
- "glob",
152
392
  "grep",
153
393
  "ls",
154
- "notebookread",
155
- "notebookedit",
156
- "todoread",
157
- "todowrite",
158
- "webfetch",
159
- "websearch",
160
- "exitplanmode",
161
394
  "askuserquestion"
162
395
  ],
163
- toolInspection: {
164
- bash: "command",
165
- run_shell_command: "command",
166
- shell: "command",
167
- "terminal.execute": "command"
168
- },
169
- rules: [
170
- { action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
171
- ]
396
+ toolInspection: { bash: "command", shell: "command" },
397
+ rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
172
398
  },
173
399
  environments: {}
174
400
  };
175
401
  var cachedConfig = null;
402
+ function _resetConfigCache() {
403
+ cachedConfig = null;
404
+ }
176
405
  function getGlobalSettings() {
177
406
  try {
178
407
  const globalConfigPath = path.join(os.homedir(), ".node9", "config.json");
@@ -183,18 +412,19 @@ function getGlobalSettings() {
183
412
  mode: settings.mode || "standard",
184
413
  autoStartDaemon: settings.autoStartDaemon !== false,
185
414
  slackEnabled: settings.slackEnabled !== false,
186
- // agentMode defaults to false — user must explicitly opt in via `node9 login`
187
- agentMode: settings.agentMode === true
415
+ enableTrustSessions: settings.enableTrustSessions === true,
416
+ allowGlobalPause: settings.allowGlobalPause !== false
188
417
  };
189
418
  }
190
419
  } catch {
191
420
  }
192
- return { mode: "standard", autoStartDaemon: true, slackEnabled: true, agentMode: false };
193
- }
194
- function hasSlack() {
195
- const creds = getCredentials();
196
- if (!creds?.apiKey) return false;
197
- return getGlobalSettings().slackEnabled;
421
+ return {
422
+ mode: "standard",
423
+ autoStartDaemon: true,
424
+ slackEnabled: true,
425
+ enableTrustSessions: false,
426
+ allowGlobalPause: true
427
+ };
198
428
  }
199
429
  function getInternalToken() {
200
430
  try {
@@ -207,51 +437,83 @@ function getInternalToken() {
207
437
  return null;
208
438
  }
209
439
  }
210
- async function evaluatePolicy(toolName, args) {
440
+ async function evaluatePolicy(toolName, args, agent) {
211
441
  const config = getConfig();
212
- if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
442
+ if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
443
+ let allTokens = [];
444
+ let actionTokens = [];
445
+ let pathTokens = [];
213
446
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
214
447
  if (shellCommand) {
215
- const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
448
+ const analyzed = await analyzeShellCommand(shellCommand);
449
+ allTokens = analyzed.allTokens;
450
+ actionTokens = analyzed.actions;
451
+ pathTokens = analyzed.paths;
216
452
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
217
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) return "review";
218
- for (const action of actions) {
219
- const basename = action.includes("/") ? action.split("/").pop() : action;
220
- const rule = config.policy.rules.find(
221
- (r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
222
- );
223
- if (rule) {
224
- if (paths.length > 0) {
225
- const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
226
- if (anyBlocked) return "review";
227
- const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
228
- if (allAllowed) return "allow";
229
- }
230
- return "review";
231
- }
453
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
454
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
232
455
  }
233
- const isDangerous2 = allTokens.some(
234
- (token) => config.policy.dangerousWords.some((word) => {
235
- const w = word.toLowerCase();
236
- if (token === w) return true;
237
- try {
238
- return new RegExp(`\\b${w}\\b`, "i").test(token);
239
- } catch {
240
- return false;
241
- }
242
- })
456
+ } else {
457
+ allTokens = tokenize(toolName);
458
+ actionTokens = [toolName];
459
+ }
460
+ const isManual = agent === "Terminal";
461
+ if (isManual) {
462
+ const NUCLEAR_COMMANDS = [
463
+ "drop",
464
+ "destroy",
465
+ "purge",
466
+ "rmdir",
467
+ "format",
468
+ "truncate",
469
+ "alter",
470
+ "grant",
471
+ "revoke",
472
+ "docker"
473
+ ];
474
+ const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
475
+ if (!hasNuclear) return { decision: "allow" };
476
+ }
477
+ if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
478
+ const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
479
+ if (allInSandbox) return { decision: "allow" };
480
+ }
481
+ for (const action of actionTokens) {
482
+ const rule = config.policy.rules.find(
483
+ (r) => r.action === action || matchesPattern(action, r.action)
243
484
  );
244
- if (isDangerous2) return "review";
245
- if (config.settings.mode === "strict") return "review";
246
- return "allow";
485
+ if (rule) {
486
+ if (pathTokens.length > 0) {
487
+ const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
488
+ if (anyBlocked)
489
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
490
+ const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
491
+ if (allAllowed) return { decision: "allow" };
492
+ }
493
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
494
+ }
495
+ }
496
+ const isDangerous = allTokens.some(
497
+ (token) => config.policy.dangerousWords.some((word) => {
498
+ const w = word.toLowerCase();
499
+ if (token === w) return true;
500
+ try {
501
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
502
+ } catch {
503
+ return false;
504
+ }
505
+ })
506
+ );
507
+ if (isDangerous) {
508
+ const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
509
+ return { decision: "review", blockedByLabel: label };
247
510
  }
248
- const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
249
- if (isDangerous || config.settings.mode === "strict") {
511
+ if (config.settings.mode === "strict") {
250
512
  const envConfig = getActiveEnvironment(config);
251
- if (envConfig?.requireApproval === false) return "allow";
252
- return "review";
513
+ if (envConfig?.requireApproval === false) return { decision: "allow" };
514
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
253
515
  }
254
- return "allow";
516
+ return { decision: "allow" };
255
517
  }
256
518
  function isIgnoredTool(toolName) {
257
519
  const config = getConfig();
@@ -282,22 +544,40 @@ function getPersistentDecision(toolName) {
282
544
  }
283
545
  return null;
284
546
  }
285
- async function askDaemon(toolName, args, meta) {
547
+ async function askDaemon(toolName, args, meta, signal) {
286
548
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
287
- const checkRes = await fetch(`${base}/check`, {
288
- method: "POST",
289
- headers: { "Content-Type": "application/json" },
290
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
291
- signal: AbortSignal.timeout(5e3)
292
- });
293
- if (!checkRes.ok) throw new Error("Daemon fail");
294
- const { id } = await checkRes.json();
295
- const waitRes = await fetch(`${base}/wait/${id}`, { signal: AbortSignal.timeout(12e4) });
296
- if (!waitRes.ok) return "deny";
297
- const { decision } = await waitRes.json();
298
- if (decision === "allow") return "allow";
299
- if (decision === "abandoned") return "abandoned";
300
- return "deny";
549
+ const checkCtrl = new AbortController();
550
+ const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
551
+ const onAbort = () => checkCtrl.abort();
552
+ if (signal) signal.addEventListener("abort", onAbort);
553
+ try {
554
+ const checkRes = await fetch(`${base}/check`, {
555
+ method: "POST",
556
+ headers: { "Content-Type": "application/json" },
557
+ body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
558
+ signal: checkCtrl.signal
559
+ });
560
+ if (!checkRes.ok) throw new Error("Daemon fail");
561
+ const { id } = await checkRes.json();
562
+ const waitCtrl = new AbortController();
563
+ const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
564
+ const onWaitAbort = () => waitCtrl.abort();
565
+ if (signal) signal.addEventListener("abort", onWaitAbort);
566
+ try {
567
+ const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
568
+ if (!waitRes.ok) return "deny";
569
+ const { decision } = await waitRes.json();
570
+ if (decision === "allow") return "allow";
571
+ if (decision === "abandoned") return "abandoned";
572
+ return "deny";
573
+ } finally {
574
+ clearTimeout(waitTimer);
575
+ if (signal) signal.removeEventListener("abort", onWaitAbort);
576
+ }
577
+ } finally {
578
+ clearTimeout(checkTimer);
579
+ if (signal) signal.removeEventListener("abort", onAbort);
580
+ }
301
581
  }
302
582
  async function notifyDaemonViewer(toolName, args, meta) {
303
583
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
@@ -327,176 +607,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
327
607
  });
328
608
  }
329
609
  async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
330
- const { agentMode } = getGlobalSettings();
331
- const cloudEnforced = agentMode && hasSlack();
332
- if (!cloudEnforced) {
333
- if (isIgnoredTool(toolName)) return { approved: true };
334
- const policyDecision = await evaluatePolicy(toolName, args);
335
- if (policyDecision === "allow") return { approved: true, checkedBy: "local-policy" };
610
+ if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
611
+ const pauseState = checkPause();
612
+ if (pauseState.paused) return { approved: true, checkedBy: "paused" };
613
+ const creds = getCredentials();
614
+ const config = getConfig();
615
+ const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
616
+ const approvers = {
617
+ ...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
618
+ };
619
+ if (isTestEnv2) {
620
+ approvers.native = false;
621
+ approvers.browser = false;
622
+ approvers.terminal = false;
623
+ }
624
+ const isManual = meta?.agent === "Terminal";
625
+ let explainableLabel = "Local Config";
626
+ if (config.settings.mode === "audit") {
627
+ if (!isIgnoredTool(toolName)) {
628
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
629
+ if (policyResult.decision === "review") {
630
+ appendAuditModeEntry(toolName, args);
631
+ sendDesktopNotification(
632
+ "Node9 Audit Mode",
633
+ `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
634
+ );
635
+ }
636
+ }
637
+ return { approved: true, checkedBy: "audit" };
638
+ }
639
+ if (!isIgnoredTool(toolName)) {
640
+ if (getActiveTrustSession(toolName)) {
641
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
642
+ return { approved: true, checkedBy: "trust" };
643
+ }
644
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
645
+ if (policyResult.decision === "allow") {
646
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
647
+ return { approved: true, checkedBy: "local-policy" };
648
+ }
649
+ explainableLabel = policyResult.blockedByLabel || "Local Config";
336
650
  const persistent = getPersistentDecision(toolName);
337
- if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
338
- if (persistent === "deny")
651
+ if (persistent === "allow") {
652
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
653
+ return { approved: true, checkedBy: "persistent" };
654
+ }
655
+ if (persistent === "deny") {
339
656
  return {
340
657
  approved: false,
341
- reason: `Node9: "${toolName}" is set to always deny.`,
658
+ reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
342
659
  blockedBy: "persistent-deny",
343
- changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
660
+ blockedByLabel: "Persistent User Rule"
344
661
  };
345
- }
346
- if (cloudEnforced) {
347
- const creds = getCredentials();
348
- const envConfig = getActiveEnvironment(getConfig());
349
- let viewerId = null;
350
- const internalToken = getInternalToken();
351
- if (isDaemonRunning() && internalToken) {
352
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
353
- }
354
- const approved = await callNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
355
- if (viewerId && internalToken) {
356
- resolveViaDaemon(viewerId, approved ? "allow" : "deny", internalToken).catch(() => null);
357
662
  }
358
- return {
359
- approved,
360
- checkedBy: approved ? "cloud" : void 0,
361
- blockedBy: approved ? void 0 : "team-policy",
362
- changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
363
- };
663
+ } else {
664
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
665
+ return { approved: true };
364
666
  }
365
- if (isDaemonRunning()) {
366
- console.error(chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
367
- console.error(chalk.cyan(` Browser UI \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
368
- `));
667
+ let cloudRequestId = null;
668
+ let isRemoteLocked = false;
669
+ const cloudEnforced = approvers.cloud && !!creds?.apiKey;
670
+ if (cloudEnforced) {
369
671
  try {
370
- const daemonDecision = await askDaemon(toolName, args, meta);
371
- if (daemonDecision === "abandoned") {
372
- console.error(chalk.yellow("\n\u26A0\uFE0F Browser closed without a decision. Falling back..."));
373
- } else {
672
+ const envConfig = getActiveEnvironment(getConfig());
673
+ const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
674
+ if (!initResult.pending) {
374
675
  return {
375
- approved: daemonDecision === "allow",
376
- reason: daemonDecision === "deny" ? `Node9 blocked "${toolName}" \u2014 denied in browser.` : void 0,
377
- checkedBy: daemonDecision === "allow" ? "daemon" : void 0,
378
- blockedBy: daemonDecision === "deny" ? "local-decision" : void 0,
379
- changeHint: daemonDecision === "deny" ? `Open the daemon UI to change: node9 daemon --openui` : void 0
676
+ approved: !!initResult.approved,
677
+ reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
678
+ checkedBy: initResult.approved ? "cloud" : void 0,
679
+ blockedBy: initResult.approved ? void 0 : "team-policy",
680
+ blockedByLabel: "Organization Policy (SaaS)"
380
681
  };
381
682
  }
382
- } catch {
683
+ cloudRequestId = initResult.requestId || null;
684
+ isRemoteLocked = !!initResult.remoteApprovalOnly;
685
+ explainableLabel = "Organization Policy (SaaS)";
686
+ } catch (err) {
687
+ const error = err;
688
+ const isAuthError = error.message.includes("401") || error.message.includes("403");
689
+ const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
690
+ 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;
691
+ console.error(
692
+ chalk2.yellow(`
693
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk2.dim(`
694
+ Falling back to local rules...
695
+ `)
696
+ );
383
697
  }
384
698
  }
385
- if (allowTerminalFallback && process.stdout.isTTY) {
386
- console.log(chalk.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
387
- console.log(`${chalk.bold("Action:")} ${chalk.red(toolName)}`);
388
- const argsPreview = JSON.stringify(args, null, 2);
389
- console.log(
390
- `${chalk.bold("Args:")}
391
- ${chalk.gray(argsPreview.length > 500 ? argsPreview.slice(0, 500) + "..." : argsPreview)}`
699
+ if (cloudEnforced && cloudRequestId) {
700
+ console.error(
701
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
702
+ );
703
+ console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
704
+ } else if (!cloudEnforced) {
705
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
706
+ console.error(
707
+ chalk2.dim(`
708
+ \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
709
+ `)
392
710
  );
393
- const controller = new AbortController();
394
- const TIMEOUT_MS = 3e4;
395
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
396
- try {
397
- const approved = await confirm(
398
- { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
399
- { signal: controller.signal }
400
- );
401
- clearTimeout(timer);
402
- return { approved };
403
- } catch {
404
- clearTimeout(timer);
405
- console.error(chalk.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
406
- return { approved: false };
407
- }
408
711
  }
409
- return {
410
- approved: false,
411
- noApprovalMechanism: true,
412
- reason: `Node9 blocked "${toolName}". No approval mechanism is active.`,
413
- blockedBy: "no-approval-mechanism",
414
- changeHint: `Start the approval daemon: node9 daemon --background
415
- Or connect to your team: node9 login <apiKey>`
416
- };
417
- }
418
- function listCredentialProfiles() {
419
- try {
420
- const credPath = path.join(os.homedir(), ".node9", "credentials.json");
421
- if (!fs.existsSync(credPath)) return [];
422
- const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
423
- if (!creds.apiKey) return Object.keys(creds).filter((k) => typeof creds[k] === "object");
424
- } catch {
712
+ const abortController = new AbortController();
713
+ const { signal } = abortController;
714
+ const racePromises = [];
715
+ let viewerId = null;
716
+ const internalToken = getInternalToken();
717
+ if (cloudEnforced && cloudRequestId) {
718
+ racePromises.push(
719
+ (async () => {
720
+ try {
721
+ if (isDaemonRunning() && internalToken) {
722
+ viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
723
+ }
724
+ const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
725
+ return {
726
+ approved: cloudResult.approved,
727
+ reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
728
+ checkedBy: cloudResult.approved ? "cloud" : void 0,
729
+ blockedBy: cloudResult.approved ? void 0 : "team-policy",
730
+ blockedByLabel: "Organization Policy (SaaS)"
731
+ };
732
+ } catch (err) {
733
+ const error = err;
734
+ if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
735
+ throw err;
736
+ }
737
+ })()
738
+ );
739
+ }
740
+ if (approvers.native && !isManual) {
741
+ racePromises.push(
742
+ (async () => {
743
+ const decision = await askNativePopup(
744
+ toolName,
745
+ args,
746
+ meta?.agent,
747
+ explainableLabel,
748
+ isRemoteLocked,
749
+ signal
750
+ );
751
+ if (decision === "always_allow") {
752
+ writeTrustSession(toolName, 36e5);
753
+ return { approved: true, checkedBy: "trust" };
754
+ }
755
+ const isApproved = decision === "allow";
756
+ return {
757
+ approved: isApproved,
758
+ reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
759
+ checkedBy: isApproved ? "daemon" : void 0,
760
+ blockedBy: isApproved ? void 0 : "local-decision",
761
+ blockedByLabel: "User Decision (Native)"
762
+ };
763
+ })()
764
+ );
765
+ }
766
+ if (approvers.browser && isDaemonRunning()) {
767
+ racePromises.push(
768
+ (async () => {
769
+ try {
770
+ if (!approvers.native && !cloudEnforced) {
771
+ console.error(
772
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
773
+ );
774
+ console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
775
+ `));
776
+ }
777
+ const daemonDecision = await askDaemon(toolName, args, meta, signal);
778
+ if (daemonDecision === "abandoned") throw new Error("Abandoned");
779
+ const isApproved = daemonDecision === "allow";
780
+ return {
781
+ approved: isApproved,
782
+ reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
783
+ checkedBy: isApproved ? "daemon" : void 0,
784
+ blockedBy: isApproved ? void 0 : "local-decision",
785
+ blockedByLabel: "User Decision (Browser)"
786
+ };
787
+ } catch (err) {
788
+ throw err;
789
+ }
790
+ })()
791
+ );
425
792
  }
426
- return [];
793
+ if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
794
+ racePromises.push(
795
+ (async () => {
796
+ try {
797
+ console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
798
+ console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
799
+ console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
800
+ if (isRemoteLocked) {
801
+ console.log(chalk2.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
802
+ `));
803
+ await new Promise((_, reject) => {
804
+ signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
805
+ });
806
+ }
807
+ const TIMEOUT_MS = 6e4;
808
+ let timer;
809
+ const result = await new Promise((resolve, reject) => {
810
+ timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
811
+ confirm(
812
+ { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
813
+ { signal }
814
+ ).then(resolve).catch(reject);
815
+ });
816
+ clearTimeout(timer);
817
+ return {
818
+ approved: result,
819
+ reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
820
+ checkedBy: result ? "terminal" : void 0,
821
+ blockedBy: result ? void 0 : "local-decision",
822
+ blockedByLabel: "User Decision (Terminal)"
823
+ };
824
+ } catch (err) {
825
+ const error = err;
826
+ if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
827
+ throw err;
828
+ if (error.message === "Terminal Timeout") {
829
+ return {
830
+ approved: false,
831
+ reason: "The terminal prompt timed out without a human response.",
832
+ blockedBy: "local-decision"
833
+ };
834
+ }
835
+ throw err;
836
+ }
837
+ })()
838
+ );
839
+ }
840
+ if (racePromises.length === 0) {
841
+ return {
842
+ approved: false,
843
+ noApprovalMechanism: true,
844
+ reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
845
+ REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
846
+ blockedBy: "no-approval-mechanism",
847
+ blockedByLabel: explainableLabel
848
+ };
849
+ }
850
+ const finalResult = await new Promise((resolve) => {
851
+ let resolved = false;
852
+ let failures = 0;
853
+ const total = racePromises.length;
854
+ const finish = (res) => {
855
+ if (!resolved) {
856
+ resolved = true;
857
+ abortController.abort();
858
+ if (viewerId && internalToken) {
859
+ resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
860
+ () => null
861
+ );
862
+ }
863
+ resolve(res);
864
+ }
865
+ };
866
+ for (const p of racePromises) {
867
+ p.then(finish).catch((err) => {
868
+ if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
869
+ return;
870
+ if (err.message === "Abandoned") {
871
+ finish({
872
+ approved: false,
873
+ reason: "Browser dashboard closed without making a decision.",
874
+ blockedBy: "local-decision",
875
+ blockedByLabel: "Browser Dashboard (Abandoned)"
876
+ });
877
+ return;
878
+ }
879
+ failures++;
880
+ if (failures === total && !resolved) {
881
+ finish({ approved: false, reason: "All approval channels failed or disconnected." });
882
+ }
883
+ });
884
+ }
885
+ });
886
+ if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
887
+ await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
888
+ }
889
+ return finalResult;
427
890
  }
428
891
  function getConfig() {
429
892
  if (cachedConfig) return cachedConfig;
430
- const projectConfig = tryLoadConfig(path.join(process.cwd(), "node9.config.json"));
431
- if (projectConfig) {
432
- cachedConfig = buildConfig(projectConfig);
433
- return cachedConfig;
434
- }
435
- const globalConfig = tryLoadConfig(path.join(os.homedir(), ".node9", "config.json"));
436
- if (globalConfig) {
437
- cachedConfig = buildConfig(globalConfig);
438
- return cachedConfig;
439
- }
440
- cachedConfig = DEFAULT_CONFIG;
893
+ const globalPath = path.join(os.homedir(), ".node9", "config.json");
894
+ const projectPath = path.join(process.cwd(), "node9.config.json");
895
+ const globalConfig = tryLoadConfig(globalPath);
896
+ const projectConfig = tryLoadConfig(projectPath);
897
+ const mergedSettings = {
898
+ ...DEFAULT_CONFIG.settings,
899
+ approvers: { ...DEFAULT_CONFIG.settings.approvers }
900
+ };
901
+ const mergedPolicy = {
902
+ sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
903
+ dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
904
+ ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
905
+ toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
906
+ rules: [...DEFAULT_CONFIG.policy.rules]
907
+ };
908
+ const applyLayer = (source) => {
909
+ if (!source) return;
910
+ const s = source.settings || {};
911
+ const p = source.policy || {};
912
+ if (s.mode !== void 0) mergedSettings.mode = s.mode;
913
+ if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
914
+ if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
915
+ if (s.enableHookLogDebug !== void 0)
916
+ mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
917
+ if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
918
+ if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
919
+ if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
920
+ if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
921
+ if (p.toolInspection)
922
+ mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
923
+ if (p.rules) mergedPolicy.rules.push(...p.rules);
924
+ };
925
+ applyLayer(globalConfig);
926
+ applyLayer(projectConfig);
927
+ if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
928
+ mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
929
+ mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
930
+ mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
931
+ cachedConfig = {
932
+ settings: mergedSettings,
933
+ policy: mergedPolicy,
934
+ environments: {}
935
+ };
441
936
  return cachedConfig;
442
937
  }
443
938
  function tryLoadConfig(filePath) {
444
939
  if (!fs.existsSync(filePath)) return null;
445
940
  try {
446
- const config = JSON.parse(fs.readFileSync(filePath, "utf-8"));
447
- validateConfig(config, filePath);
448
- return config;
941
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
449
942
  } catch {
450
943
  return null;
451
944
  }
452
945
  }
453
- function validateConfig(config, path5) {
454
- const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
455
- Object.keys(config).forEach((key) => {
456
- if (!allowedTopLevel.includes(key))
457
- console.warn(chalk.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path5}`));
458
- });
459
- }
460
- function buildConfig(parsed) {
461
- const p = parsed.policy || {};
462
- const s = parsed.settings || {};
463
- return {
464
- settings: {
465
- mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
466
- autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
467
- },
468
- policy: {
469
- dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
470
- ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
471
- toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
472
- rules: p.rules ?? DEFAULT_CONFIG.policy.rules
473
- },
474
- environments: parsed.environments || {}
475
- };
476
- }
477
946
  function getActiveEnvironment(config) {
478
947
  const env = process.env.NODE_ENV || "development";
479
948
  return config.environments[env] ?? null;
480
949
  }
481
950
  function getCredentials() {
482
951
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
483
- if (process.env.NODE9_API_KEY)
952
+ if (process.env.NODE9_API_KEY) {
484
953
  return {
485
954
  apiKey: process.env.NODE9_API_KEY,
486
955
  apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
487
956
  };
488
- try {
489
- const projectConfigPath = path.join(process.cwd(), "node9.config.json");
490
- if (fs.existsSync(projectConfigPath)) {
491
- const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8"));
492
- if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
493
- return {
494
- apiKey: projectConfig.apiKey,
495
- apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
496
- };
497
- }
498
- }
499
- } catch {
500
957
  }
501
958
  try {
502
959
  const credPath = path.join(os.homedir(), ".node9", "credentials.json");
@@ -521,14 +978,32 @@ function getCredentials() {
521
978
  }
522
979
  return null;
523
980
  }
524
- async function authorizeAction(toolName, args) {
525
- const result = await authorizeHeadless(toolName, args, true);
526
- return result.approved;
981
+ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
982
+ const controller = new AbortController();
983
+ setTimeout(() => controller.abort(), 5e3);
984
+ fetch(`${creds.apiUrl}/audit`, {
985
+ method: "POST",
986
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
987
+ body: JSON.stringify({
988
+ toolName,
989
+ args,
990
+ checkedBy,
991
+ context: {
992
+ agent: meta?.agent,
993
+ mcpServer: meta?.mcpServer,
994
+ hostname: os.hostname(),
995
+ cwd: process.cwd(),
996
+ platform: os.platform()
997
+ }
998
+ }),
999
+ signal: controller.signal
1000
+ }).catch(() => {
1001
+ });
527
1002
  }
528
- async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
1003
+ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
1004
+ const controller = new AbortController();
1005
+ const timeout = setTimeout(() => controller.abort(), 1e4);
529
1006
  try {
530
- const controller = new AbortController();
531
- const timeout = setTimeout(() => controller.abort(), 35e3);
532
1007
  const response = await fetch(creds.apiUrl, {
533
1008
  method: "POST",
534
1009
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
@@ -546,46 +1021,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
546
1021
  }),
547
1022
  signal: controller.signal
548
1023
  });
1024
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1025
+ return await response.json();
1026
+ } finally {
549
1027
  clearTimeout(timeout);
550
- if (!response.ok) throw new Error("API fail");
551
- const data = await response.json();
552
- if (!data.pending) return data.approved;
553
- if (!data.requestId) return false;
554
- const statusUrl = `${creds.apiUrl}/status/${data.requestId}`;
555
- console.error(chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
556
- if (isDaemonRunning()) {
557
- console.error(
558
- chalk.cyan(" Browser UI \u2192 ") + chalk.bold(`http://${DAEMON_HOST}:${DAEMON_PORT}/`)
559
- );
560
- }
561
- console.error(chalk.cyan(" Dashboard \u2192 ") + chalk.bold("Mission Control > Flows"));
562
- console.error(chalk.gray(" Agent is paused. Approve or deny to continue.\n"));
563
- const POLL_INTERVAL_MS = 3e3;
564
- const POLL_DEADLINE = Date.now() + 5 * 60 * 1e3;
565
- while (Date.now() < POLL_DEADLINE) {
566
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
567
- try {
568
- const statusRes = await fetch(statusUrl, {
569
- headers: { Authorization: `Bearer ${creds.apiKey}` },
570
- signal: AbortSignal.timeout(5e3)
571
- });
572
- if (!statusRes.ok) continue;
573
- const { status } = await statusRes.json();
574
- if (status === "APPROVED") {
575
- console.error(chalk.green("\u2705 Approved \u2014 continuing.\n"));
576
- return true;
577
- }
578
- if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
579
- console.error(chalk.red("\u274C Denied \u2014 action blocked.\n"));
580
- return false;
581
- }
582
- } catch {
1028
+ }
1029
+ }
1030
+ async function pollNode9SaaS(requestId, creds, signal) {
1031
+ const statusUrl = `${creds.apiUrl}/status/${requestId}`;
1032
+ const POLL_INTERVAL_MS = 1e3;
1033
+ const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
1034
+ while (Date.now() < POLL_DEADLINE) {
1035
+ if (signal.aborted) throw new Error("Aborted");
1036
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1037
+ try {
1038
+ const pollCtrl = new AbortController();
1039
+ const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
1040
+ const statusRes = await fetch(statusUrl, {
1041
+ headers: { Authorization: `Bearer ${creds.apiKey}` },
1042
+ signal: pollCtrl.signal
1043
+ });
1044
+ clearTimeout(pollTimer);
1045
+ if (!statusRes.ok) continue;
1046
+ const { status, reason } = await statusRes.json();
1047
+ if (status === "APPROVED") {
1048
+ console.error(chalk2.green("\u2705 Approved via Cloud.\n"));
1049
+ return { approved: true, reason };
1050
+ }
1051
+ if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
1052
+ console.error(chalk2.red("\u274C Denied via Cloud.\n"));
1053
+ return { approved: false, reason };
583
1054
  }
1055
+ } catch {
584
1056
  }
585
- console.error(chalk.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
586
- return false;
1057
+ }
1058
+ return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
1059
+ }
1060
+ async function resolveNode9SaaS(requestId, creds, approved) {
1061
+ try {
1062
+ const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
1063
+ const ctrl = new AbortController();
1064
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
1065
+ await fetch(resolveUrl, {
1066
+ method: "PATCH",
1067
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1068
+ body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
1069
+ signal: ctrl.signal
1070
+ });
1071
+ clearTimeout(timer);
587
1072
  } catch {
588
- return false;
589
1073
  }
590
1074
  }
591
1075
 
@@ -593,11 +1077,11 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
593
1077
  import fs2 from "fs";
594
1078
  import path2 from "path";
595
1079
  import os2 from "os";
596
- import chalk2 from "chalk";
1080
+ import chalk3 from "chalk";
597
1081
  import { confirm as confirm2 } from "@inquirer/prompts";
598
1082
  function printDaemonTip() {
599
1083
  console.log(
600
- chalk2.cyan("\n \u{1F4A1} Enable browser approvals (no API key needed):") + chalk2.green(" node9 daemon --background")
1084
+ chalk3.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + chalk3.white("\n To view your history or manage persistent rules, run:") + chalk3.green("\n node9 daemon --openui")
601
1085
  );
602
1086
  }
603
1087
  function fullPathCommand(subcommand) {
@@ -638,7 +1122,7 @@ async function setupClaude() {
638
1122
  matcher: ".*",
639
1123
  hooks: [{ type: "command", command: fullPathCommand("check"), timeout: 60 }]
640
1124
  });
641
- console.log(chalk2.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
1125
+ console.log(chalk3.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
642
1126
  anythingChanged = true;
643
1127
  }
644
1128
  const hasPostHook = settings.hooks.PostToolUse?.some(
@@ -648,9 +1132,9 @@ async function setupClaude() {
648
1132
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
649
1133
  settings.hooks.PostToolUse.push({
650
1134
  matcher: ".*",
651
- hooks: [{ type: "command", command: fullPathCommand("log") }]
1135
+ hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
652
1136
  });
653
- console.log(chalk2.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
1137
+ console.log(chalk3.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
654
1138
  anythingChanged = true;
655
1139
  }
656
1140
  if (anythingChanged) {
@@ -664,10 +1148,10 @@ async function setupClaude() {
664
1148
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
665
1149
  }
666
1150
  if (serversToWrap.length > 0) {
667
- console.log(chalk2.bold("The following existing entries will be modified:\n"));
668
- console.log(chalk2.white(` ${mcpPath}`));
1151
+ console.log(chalk3.bold("The following existing entries will be modified:\n"));
1152
+ console.log(chalk3.white(` ${mcpPath}`));
669
1153
  for (const { name, originalCmd } of serversToWrap) {
670
- console.log(chalk2.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1154
+ console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
671
1155
  }
672
1156
  console.log("");
673
1157
  const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
@@ -677,22 +1161,22 @@ async function setupClaude() {
677
1161
  }
678
1162
  claudeConfig.mcpServers = servers;
679
1163
  writeJson(mcpPath, claudeConfig);
680
- console.log(chalk2.green(`
1164
+ console.log(chalk3.green(`
681
1165
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
682
1166
  anythingChanged = true;
683
1167
  } else {
684
- console.log(chalk2.yellow(" Skipped MCP server wrapping."));
1168
+ console.log(chalk3.yellow(" Skipped MCP server wrapping."));
685
1169
  }
686
1170
  console.log("");
687
1171
  }
688
1172
  if (!anythingChanged && serversToWrap.length === 0) {
689
- console.log(chalk2.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
1173
+ console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
690
1174
  printDaemonTip();
691
1175
  return;
692
1176
  }
693
1177
  if (anythingChanged) {
694
- console.log(chalk2.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
695
- console.log(chalk2.gray(" Restart Claude Code for changes to take effect."));
1178
+ console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
1179
+ console.log(chalk3.gray(" Restart Claude Code for changes to take effect."));
696
1180
  printDaemonTip();
697
1181
  }
698
1182
  }
@@ -712,10 +1196,15 @@ async function setupGemini() {
712
1196
  settings.hooks.BeforeTool.push({
713
1197
  matcher: ".*",
714
1198
  hooks: [
715
- { name: "node9-check", type: "command", command: fullPathCommand("check"), timeout: 6e4 }
1199
+ {
1200
+ name: "node9-check",
1201
+ type: "command",
1202
+ command: fullPathCommand("check"),
1203
+ timeout: 6e5
1204
+ }
716
1205
  ]
717
1206
  });
718
- console.log(chalk2.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
1207
+ console.log(chalk3.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
719
1208
  anythingChanged = true;
720
1209
  }
721
1210
  const hasAfterHook = Array.isArray(settings.hooks.AfterTool) && settings.hooks.AfterTool.some(
@@ -728,7 +1217,7 @@ async function setupGemini() {
728
1217
  matcher: ".*",
729
1218
  hooks: [{ name: "node9-log", type: "command", command: fullPathCommand("log") }]
730
1219
  });
731
- console.log(chalk2.green(" \u2705 AfterTool hook added \u2192 node9 log"));
1220
+ console.log(chalk3.green(" \u2705 AfterTool hook added \u2192 node9 log"));
732
1221
  anythingChanged = true;
733
1222
  }
734
1223
  if (anythingChanged) {
@@ -742,10 +1231,10 @@ async function setupGemini() {
742
1231
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
743
1232
  }
744
1233
  if (serversToWrap.length > 0) {
745
- console.log(chalk2.bold("The following existing entries will be modified:\n"));
746
- console.log(chalk2.white(` ${settingsPath} (mcpServers)`));
1234
+ console.log(chalk3.bold("The following existing entries will be modified:\n"));
1235
+ console.log(chalk3.white(` ${settingsPath} (mcpServers)`));
747
1236
  for (const { name, originalCmd } of serversToWrap) {
748
- console.log(chalk2.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1237
+ console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
749
1238
  }
750
1239
  console.log("");
751
1240
  const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
@@ -755,22 +1244,22 @@ async function setupGemini() {
755
1244
  }
756
1245
  settings.mcpServers = servers;
757
1246
  writeJson(settingsPath, settings);
758
- console.log(chalk2.green(`
1247
+ console.log(chalk3.green(`
759
1248
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
760
1249
  anythingChanged = true;
761
1250
  } else {
762
- console.log(chalk2.yellow(" Skipped MCP server wrapping."));
1251
+ console.log(chalk3.yellow(" Skipped MCP server wrapping."));
763
1252
  }
764
1253
  console.log("");
765
1254
  }
766
1255
  if (!anythingChanged && serversToWrap.length === 0) {
767
- console.log(chalk2.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
1256
+ console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
768
1257
  printDaemonTip();
769
1258
  return;
770
1259
  }
771
1260
  if (anythingChanged) {
772
- console.log(chalk2.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
773
- console.log(chalk2.gray(" Restart Gemini CLI for changes to take effect."));
1261
+ console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
1262
+ console.log(chalk3.gray(" Restart Gemini CLI for changes to take effect."));
774
1263
  printDaemonTip();
775
1264
  }
776
1265
  }
@@ -789,7 +1278,7 @@ async function setupCursor() {
789
1278
  if (!hasPreHook) {
790
1279
  if (!hooksFile.hooks.preToolUse) hooksFile.hooks.preToolUse = [];
791
1280
  hooksFile.hooks.preToolUse.push({ command: fullPathCommand("check") });
792
- console.log(chalk2.green(" \u2705 preToolUse hook added \u2192 node9 check"));
1281
+ console.log(chalk3.green(" \u2705 preToolUse hook added \u2192 node9 check"));
793
1282
  anythingChanged = true;
794
1283
  }
795
1284
  const hasPostHook = hooksFile.hooks.postToolUse?.some(
@@ -798,7 +1287,7 @@ async function setupCursor() {
798
1287
  if (!hasPostHook) {
799
1288
  if (!hooksFile.hooks.postToolUse) hooksFile.hooks.postToolUse = [];
800
1289
  hooksFile.hooks.postToolUse.push({ command: fullPathCommand("log") });
801
- console.log(chalk2.green(" \u2705 postToolUse hook added \u2192 node9 log"));
1290
+ console.log(chalk3.green(" \u2705 postToolUse hook added \u2192 node9 log"));
802
1291
  anythingChanged = true;
803
1292
  }
804
1293
  if (anythingChanged) {
@@ -812,10 +1301,10 @@ async function setupCursor() {
812
1301
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
813
1302
  }
814
1303
  if (serversToWrap.length > 0) {
815
- console.log(chalk2.bold("The following existing entries will be modified:\n"));
816
- console.log(chalk2.white(` ${mcpPath}`));
1304
+ console.log(chalk3.bold("The following existing entries will be modified:\n"));
1305
+ console.log(chalk3.white(` ${mcpPath}`));
817
1306
  for (const { name, originalCmd } of serversToWrap) {
818
- console.log(chalk2.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1307
+ console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
819
1308
  }
820
1309
  console.log("");
821
1310
  const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
@@ -825,22 +1314,22 @@ async function setupCursor() {
825
1314
  }
826
1315
  mcpConfig.mcpServers = servers;
827
1316
  writeJson(mcpPath, mcpConfig);
828
- console.log(chalk2.green(`
1317
+ console.log(chalk3.green(`
829
1318
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
830
1319
  anythingChanged = true;
831
1320
  } else {
832
- console.log(chalk2.yellow(" Skipped MCP server wrapping."));
1321
+ console.log(chalk3.yellow(" Skipped MCP server wrapping."));
833
1322
  }
834
1323
  console.log("");
835
1324
  }
836
1325
  if (!anythingChanged && serversToWrap.length === 0) {
837
- console.log(chalk2.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
1326
+ console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
838
1327
  printDaemonTip();
839
1328
  return;
840
1329
  }
841
1330
  if (anythingChanged) {
842
- console.log(chalk2.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
843
- console.log(chalk2.gray(" Restart Cursor for changes to take effect."));
1331
+ console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
1332
+ console.log(chalk3.gray(" Restart Cursor for changes to take effect."));
844
1333
  printDaemonTip();
845
1334
  }
846
1335
  }
@@ -1109,6 +1598,27 @@ var ui_default = `<!doctype html>
1109
1598
  font-size: 12px;
1110
1599
  font-weight: 500;
1111
1600
  }
1601
+ .btn-trust {
1602
+ background: rgba(240, 136, 62, 0.1);
1603
+ border: 1px solid rgba(240, 136, 62, 0.35);
1604
+ color: var(--primary);
1605
+ font-size: 12px;
1606
+ font-weight: 600;
1607
+ }
1608
+ .btn-trust:hover:not(:disabled) {
1609
+ background: rgba(240, 136, 62, 0.2);
1610
+ filter: none;
1611
+ transform: translateY(-1px);
1612
+ }
1613
+ .trust-row {
1614
+ display: none;
1615
+ grid-column: span 2;
1616
+ grid-template-columns: 1fr 1fr;
1617
+ gap: 8px;
1618
+ }
1619
+ .trust-row.show {
1620
+ display: grid;
1621
+ }
1112
1622
  button:hover:not(:disabled) {
1113
1623
  filter: brightness(1.15);
1114
1624
  transform: translateY(-1px);
@@ -1412,15 +1922,31 @@ var ui_default = `<!doctype html>
1412
1922
  <span class="slider"></span>
1413
1923
  </label>
1414
1924
  </div>
1925
+ <div class="setting-row">
1926
+ <div class="setting-text">
1927
+ <div class="setting-label">Trust Sessions</div>
1928
+ <div class="setting-desc">
1929
+ Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
1930
+ </div>
1931
+ </div>
1932
+ <label class="toggle">
1933
+ <input
1934
+ type="checkbox"
1935
+ id="trustSessionsToggle"
1936
+ onchange="onTrustToggle(this.checked)"
1937
+ />
1938
+ <span class="slider"></span>
1939
+ </label>
1940
+ </div>
1415
1941
  </div>
1416
1942
 
1417
1943
  <div class="panel">
1418
- <div class="panel-title">\u{1F4AC} Slack Approvals</div>
1944
+ <div class="panel-title">\u{1F4AC} Cloud Approvals</div>
1419
1945
  <div class="setting-row">
1420
1946
  <div class="setting-text">
1421
- <div class="setting-label">Enable Slack</div>
1947
+ <div class="setting-label">Enable Cloud</div>
1422
1948
  <div class="setting-desc">
1423
- Use Slack as the approval authority when a key is saved.
1949
+ Use Cloud/Slack as the approval authority when a key is saved.
1424
1950
  </div>
1425
1951
  </div>
1426
1952
  <label class="toggle">
@@ -1474,6 +2000,7 @@ var ui_default = `<!doctype html>
1474
2000
  const requests = new Set();
1475
2001
  let orgName = null;
1476
2002
  let autoDenyMs = 120000;
2003
+ let trustEnabled = false;
1477
2004
 
1478
2005
  function highlightSyntax(code) {
1479
2006
  if (typeof code !== 'string') return esc(code);
@@ -1526,6 +2053,21 @@ var ui_default = `<!doctype html>
1526
2053
  }, 200);
1527
2054
  }
1528
2055
 
2056
+ function sendTrust(id, duration) {
2057
+ const card = document.getElementById('c-' + id);
2058
+ if (card) card.style.opacity = '0.5';
2059
+ fetch('/decision/' + id, {
2060
+ method: 'POST',
2061
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
2062
+ body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
2063
+ });
2064
+ setTimeout(() => {
2065
+ card?.remove();
2066
+ requests.delete(id);
2067
+ refresh();
2068
+ }, 200);
2069
+ }
2070
+
1529
2071
  function addCard(req) {
1530
2072
  if (requests.has(req.id)) return;
1531
2073
  requests.add(req.id);
@@ -1545,6 +2087,7 @@ var ui_default = `<!doctype html>
1545
2087
  card.id = 'c-' + req.id;
1546
2088
  const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
1547
2089
  const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
2090
+ const dis = isSlack ? 'disabled' : '';
1548
2091
  card.innerHTML = \`
1549
2092
  <div class="source-row">
1550
2093
  <span class="agent-badge">\${agentLabel}</span>
@@ -1554,11 +2097,15 @@ var ui_default = `<!doctype html>
1554
2097
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
1555
2098
  <span class="label">Input Payload</span>
1556
2099
  <pre>\${cmd}</pre>
1557
- <div class="actions">
1558
- <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${isSlack ? 'disabled' : ''}>Approve Execution</button>
1559
- <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${isSlack ? 'disabled' : ''}>Block Action</button>
1560
- <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${isSlack ? 'disabled' : ''}>Always Allow</button>
1561
- <button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${isSlack ? 'disabled' : ''}>Always Deny</button>
2100
+ <div class="actions" id="act-\${req.id}">
2101
+ <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
2102
+ <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
2103
+ <div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
2104
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
2105
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
2106
+ </div>
2107
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
2108
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
1562
2109
  </div>
1563
2110
  \`;
1564
2111
  list.appendChild(card);
@@ -1590,8 +2137,10 @@ var ui_default = `<!doctype html>
1590
2137
  autoDenyMs = data.autoDenyMs;
1591
2138
  if (orgName) {
1592
2139
  const b = document.getElementById('cloudBadge');
1593
- b.innerText = orgName;
1594
- b.classList.add('online');
2140
+ if (b) {
2141
+ b.innerText = orgName;
2142
+ b.classList.add('online');
2143
+ }
1595
2144
  }
1596
2145
  data.requests.forEach(addCard);
1597
2146
  });
@@ -1621,6 +2170,14 @@ var ui_default = `<!doctype html>
1621
2170
  }).catch(() => {});
1622
2171
  }
1623
2172
 
2173
+ function onTrustToggle(checked) {
2174
+ trustEnabled = checked;
2175
+ saveSetting('enableTrustSessions', checked);
2176
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2177
+ el.classList.toggle('show', checked);
2178
+ });
2179
+ }
2180
+
1624
2181
  fetch('/settings')
1625
2182
  .then((r) => r.json())
1626
2183
  .then((s) => {
@@ -1633,6 +2190,13 @@ var ui_default = `<!doctype html>
1633
2190
  if (!s.autoStartDaemon && !s.autoStarted) {
1634
2191
  document.getElementById('warnBanner').classList.add('show');
1635
2192
  }
2193
+ trustEnabled = !!s.enableTrustSessions;
2194
+ const trustTog = document.getElementById('trustSessionsToggle');
2195
+ if (trustTog) trustTog.checked = trustEnabled;
2196
+ // Show/hide trust rows on any cards already rendered
2197
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2198
+ el.classList.toggle('show', trustEnabled);
2199
+ });
1636
2200
  })
1637
2201
  .catch(() => {});
1638
2202
 
@@ -1742,9 +2306,9 @@ import http from "http";
1742
2306
  import fs3 from "fs";
1743
2307
  import path3 from "path";
1744
2308
  import os3 from "os";
1745
- import { execSync } from "child_process";
2309
+ import { spawn as spawn2 } from "child_process";
1746
2310
  import { randomUUID } from "crypto";
1747
- import chalk3 from "chalk";
2311
+ import chalk4 from "chalk";
1748
2312
  var DAEMON_PORT2 = 7391;
1749
2313
  var DAEMON_HOST2 = "127.0.0.1";
1750
2314
  var homeDir = os3.homedir();
@@ -1753,6 +2317,33 @@ var DECISIONS_FILE = path3.join(homeDir, ".node9", "decisions.json");
1753
2317
  var GLOBAL_CONFIG_FILE = path3.join(homeDir, ".node9", "config.json");
1754
2318
  var CREDENTIALS_FILE = path3.join(homeDir, ".node9", "credentials.json");
1755
2319
  var AUDIT_LOG_FILE = path3.join(homeDir, ".node9", "audit.log");
2320
+ var TRUST_FILE2 = path3.join(homeDir, ".node9", "trust.json");
2321
+ function atomicWriteSync2(filePath, data, options) {
2322
+ const dir = path3.dirname(filePath);
2323
+ if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
2324
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
2325
+ fs3.writeFileSync(tmpPath, data, options);
2326
+ fs3.renameSync(tmpPath, filePath);
2327
+ }
2328
+ function writeTrustEntry(toolName, durationMs) {
2329
+ try {
2330
+ let trust = { entries: [] };
2331
+ try {
2332
+ if (fs3.existsSync(TRUST_FILE2))
2333
+ trust = JSON.parse(fs3.readFileSync(TRUST_FILE2, "utf-8"));
2334
+ } catch {
2335
+ }
2336
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
2337
+ trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
2338
+ atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
2339
+ } catch {
2340
+ }
2341
+ }
2342
+ var TRUST_DURATIONS = {
2343
+ "30m": 30 * 6e4,
2344
+ "1h": 60 * 6e4,
2345
+ "2h": 2 * 60 * 6e4
2346
+ };
1756
2347
  var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
1757
2348
  function redactArgs(value) {
1758
2349
  if (!value || typeof value !== "object") return value;
@@ -1765,10 +2356,16 @@ function redactArgs(value) {
1765
2356
  }
1766
2357
  function appendAuditLog(data) {
1767
2358
  try {
1768
- const entry = JSON.stringify({ ...data, args: redactArgs(data.args) }) + "\n";
2359
+ const entry = {
2360
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2361
+ tool: data.toolName,
2362
+ args: redactArgs(data.args),
2363
+ decision: data.decision,
2364
+ source: "daemon"
2365
+ };
1769
2366
  const dir = path3.dirname(AUDIT_LOG_FILE);
1770
2367
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
1771
- fs3.appendFileSync(AUDIT_LOG_FILE, entry);
2368
+ fs3.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
1772
2369
  } catch {
1773
2370
  }
1774
2371
  }
@@ -1793,21 +2390,6 @@ function getOrgName() {
1793
2390
  return null;
1794
2391
  }
1795
2392
  var autoStarted = process.env.NODE9_AUTO_STARTED === "1";
1796
- function readGlobalSettings() {
1797
- try {
1798
- if (fs3.existsSync(GLOBAL_CONFIG_FILE)) {
1799
- const config = JSON.parse(fs3.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
1800
- const s = config?.settings ?? {};
1801
- return {
1802
- autoStartDaemon: s.autoStartDaemon !== false,
1803
- slackEnabled: s.slackEnabled !== false,
1804
- agentMode: s.agentMode === true
1805
- };
1806
- }
1807
- } catch {
1808
- }
1809
- return { autoStartDaemon: true, slackEnabled: true, agentMode: false };
1810
- }
1811
2393
  function hasStoredSlackKey() {
1812
2394
  return fs3.existsSync(CREDENTIALS_FILE);
1813
2395
  }
@@ -1821,14 +2403,13 @@ function writeGlobalSetting(key, value) {
1821
2403
  }
1822
2404
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
1823
2405
  config.settings[key] = value;
1824
- const dir = path3.dirname(GLOBAL_CONFIG_FILE);
1825
- if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
1826
- fs3.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
2406
+ atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
1827
2407
  }
1828
2408
  var pending = /* @__PURE__ */ new Map();
1829
2409
  var sseClients = /* @__PURE__ */ new Set();
1830
2410
  var abandonTimer = null;
1831
2411
  var daemonServer = null;
2412
+ var hadBrowserClient = false;
1832
2413
  function abandonPending() {
1833
2414
  abandonTimer = null;
1834
2415
  pending.forEach((entry, id) => {
@@ -1864,10 +2445,8 @@ data: ${JSON.stringify(data)}
1864
2445
  }
1865
2446
  function openBrowser(url) {
1866
2447
  try {
1867
- const opts = { stdio: "ignore" };
1868
- if (process.platform === "darwin") execSync(`open "${url}"`, opts);
1869
- else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
1870
- else execSync(`xdg-open "${url}"`, opts);
2448
+ const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
2449
+ spawn2(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
1871
2450
  } catch {
1872
2451
  }
1873
2452
  }
@@ -1889,11 +2468,9 @@ function readPersistentDecisions() {
1889
2468
  }
1890
2469
  function writePersistentDecision(toolName, decision) {
1891
2470
  try {
1892
- const dir = path3.dirname(DECISIONS_FILE);
1893
- if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
1894
2471
  const decisions = readPersistentDecisions();
1895
2472
  decisions[toolName] = decision;
1896
- fs3.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2473
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
1897
2474
  broadcast("decisions", decisions);
1898
2475
  } catch {
1899
2476
  }
@@ -1903,6 +2480,22 @@ function startDaemon() {
1903
2480
  const internalToken = randomUUID();
1904
2481
  const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
1905
2482
  const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
2483
+ const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
2484
+ let idleTimer;
2485
+ function resetIdleTimer() {
2486
+ if (idleTimer) clearTimeout(idleTimer);
2487
+ idleTimer = setTimeout(() => {
2488
+ if (autoStarted) {
2489
+ try {
2490
+ fs3.unlinkSync(DAEMON_PID_FILE);
2491
+ } catch {
2492
+ }
2493
+ }
2494
+ process.exit(0);
2495
+ }, IDLE_TIMEOUT_MS);
2496
+ idleTimer.unref();
2497
+ }
2498
+ resetIdleTimer();
1906
2499
  const server = http.createServer(async (req, res) => {
1907
2500
  const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
1908
2501
  if (req.method === "GET" && pathname === "/") {
@@ -1919,6 +2512,7 @@ function startDaemon() {
1919
2512
  clearTimeout(abandonTimer);
1920
2513
  abandonTimer = null;
1921
2514
  }
2515
+ hadBrowserClient = true;
1922
2516
  sseClients.add(res);
1923
2517
  res.write(
1924
2518
  `event: init
@@ -1945,12 +2539,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
1945
2539
  return req.on("close", () => {
1946
2540
  sseClients.delete(res);
1947
2541
  if (sseClients.size === 0 && pending.size > 0) {
1948
- abandonTimer = setTimeout(abandonPending, 2e3);
2542
+ abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
1949
2543
  }
1950
2544
  });
1951
2545
  }
1952
2546
  if (req.method === "POST" && pathname === "/check") {
1953
2547
  try {
2548
+ resetIdleTimer();
1954
2549
  const body = await readBody(req);
1955
2550
  if (body.length > 65536) return res.writeHead(413).end();
1956
2551
  const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
@@ -1971,8 +2566,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1971
2566
  appendAuditLog({
1972
2567
  toolName: e.toolName,
1973
2568
  args: e.args,
1974
- decision: "auto-deny",
1975
- timestamp: Date.now()
2569
+ decision: "auto-deny"
1976
2570
  });
1977
2571
  if (e.waiter) e.waiter("deny");
1978
2572
  else e.earlyDecision = "deny";
@@ -1990,7 +2584,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1990
2584
  agent: entry.agent,
1991
2585
  mcpServer: entry.mcpServer
1992
2586
  });
1993
- if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
2587
+ if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
1994
2588
  res.writeHead(200, { "Content-Type": "application/json" });
1995
2589
  return res.end(JSON.stringify({ id }));
1996
2590
  } catch {
@@ -2017,17 +2611,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
2017
2611
  const id = pathname.split("/").pop();
2018
2612
  const entry = pending.get(id);
2019
2613
  if (!entry) return res.writeHead(404).end();
2020
- const { decision, persist } = JSON.parse(await readBody(req));
2021
- if (persist) writePersistentDecision(entry.toolName, decision);
2614
+ const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
2615
+ if (decision === "trust" && trustDuration) {
2616
+ const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
2617
+ writeTrustEntry(entry.toolName, ms);
2618
+ appendAuditLog({
2619
+ toolName: entry.toolName,
2620
+ args: entry.args,
2621
+ decision: `trust:${trustDuration}`
2622
+ });
2623
+ clearTimeout(entry.timer);
2624
+ if (entry.waiter) entry.waiter("allow");
2625
+ else entry.earlyDecision = "allow";
2626
+ pending.delete(id);
2627
+ broadcast("remove", { id });
2628
+ res.writeHead(200);
2629
+ return res.end(JSON.stringify({ ok: true }));
2630
+ }
2631
+ const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
2632
+ if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
2022
2633
  appendAuditLog({
2023
2634
  toolName: entry.toolName,
2024
2635
  args: entry.args,
2025
- decision,
2026
- timestamp: Date.now()
2636
+ decision: resolvedDecision
2027
2637
  });
2028
2638
  clearTimeout(entry.timer);
2029
- if (entry.waiter) entry.waiter(decision);
2030
- else entry.earlyDecision = decision;
2639
+ if (entry.waiter) entry.waiter(resolvedDecision);
2640
+ else entry.earlyDecision = resolvedDecision;
2031
2641
  pending.delete(id);
2032
2642
  broadcast("remove", { id });
2033
2643
  res.writeHead(200);
@@ -2037,7 +2647,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2037
2647
  }
2038
2648
  }
2039
2649
  if (req.method === "GET" && pathname === "/settings") {
2040
- const s = readGlobalSettings();
2650
+ const s = getGlobalSettings();
2041
2651
  res.writeHead(200, { "Content-Type": "application/json" });
2042
2652
  return res.end(JSON.stringify({ ...s, autoStarted }));
2043
2653
  }
@@ -2049,7 +2659,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2049
2659
  if (data.autoStartDaemon !== void 0)
2050
2660
  writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
2051
2661
  if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
2052
- if (data.agentMode !== void 0) writeGlobalSetting("agentMode", data.agentMode);
2662
+ if (data.enableTrustSessions !== void 0)
2663
+ writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
2664
+ if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
2665
+ if (data.enableHookLogDebug !== void 0)
2666
+ writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
2667
+ if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
2053
2668
  res.writeHead(200);
2054
2669
  return res.end(JSON.stringify({ ok: true }));
2055
2670
  } catch {
@@ -2057,7 +2672,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2057
2672
  }
2058
2673
  }
2059
2674
  if (req.method === "GET" && pathname === "/slack-status") {
2060
- const s = readGlobalSettings();
2675
+ const s = getGlobalSettings();
2061
2676
  res.writeHead(200, { "Content-Type": "application/json" });
2062
2677
  return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
2063
2678
  }
@@ -2065,14 +2680,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2065
2680
  if (!validToken(req)) return res.writeHead(403).end();
2066
2681
  try {
2067
2682
  const { apiKey } = JSON.parse(await readBody(req));
2068
- if (!fs3.existsSync(path3.dirname(CREDENTIALS_FILE)))
2069
- fs3.mkdirSync(path3.dirname(CREDENTIALS_FILE), { recursive: true });
2070
- fs3.writeFileSync(
2683
+ atomicWriteSync2(
2071
2684
  CREDENTIALS_FILE,
2072
2685
  JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
2073
2686
  { mode: 384 }
2074
2687
  );
2075
- broadcast("slack-status", { hasKey: true, enabled: readGlobalSettings().slackEnabled });
2688
+ broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
2076
2689
  res.writeHead(200);
2077
2690
  return res.end(JSON.stringify({ ok: true }));
2078
2691
  } catch {
@@ -2085,7 +2698,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2085
2698
  const toolName = decodeURIComponent(pathname.split("/").pop());
2086
2699
  const decisions = readPersistentDecisions();
2087
2700
  delete decisions[toolName];
2088
- fs3.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2701
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2089
2702
  broadcast("decisions", decisions);
2090
2703
  res.writeHead(200);
2091
2704
  return res.end(JSON.stringify({ ok: true }));
@@ -2104,8 +2717,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2104
2717
  appendAuditLog({
2105
2718
  toolName: entry.toolName,
2106
2719
  args: entry.args,
2107
- decision,
2108
- timestamp: Date.now()
2720
+ decision
2109
2721
  });
2110
2722
  clearTimeout(entry.timer);
2111
2723
  if (entry.waiter) entry.waiter(decision);
@@ -2125,25 +2737,43 @@ data: ${JSON.stringify(readPersistentDecisions())}
2125
2737
  res.writeHead(404).end();
2126
2738
  });
2127
2739
  daemonServer = server;
2740
+ server.on("error", (e) => {
2741
+ if (e.code === "EADDRINUSE") {
2742
+ try {
2743
+ if (fs3.existsSync(DAEMON_PID_FILE)) {
2744
+ const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
2745
+ process.kill(pid, 0);
2746
+ return process.exit(0);
2747
+ }
2748
+ } catch {
2749
+ try {
2750
+ fs3.unlinkSync(DAEMON_PID_FILE);
2751
+ } catch {
2752
+ }
2753
+ server.listen(DAEMON_PORT2, DAEMON_HOST2);
2754
+ return;
2755
+ }
2756
+ }
2757
+ console.error(chalk4.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
2758
+ process.exit(1);
2759
+ });
2128
2760
  server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
2129
- if (!fs3.existsSync(path3.dirname(DAEMON_PID_FILE)))
2130
- fs3.mkdirSync(path3.dirname(DAEMON_PID_FILE), { recursive: true });
2131
- fs3.writeFileSync(
2761
+ atomicWriteSync2(
2132
2762
  DAEMON_PID_FILE,
2133
2763
  JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
2134
2764
  { mode: 384 }
2135
2765
  );
2136
- console.log(chalk3.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
2766
+ console.log(chalk4.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
2137
2767
  });
2138
2768
  }
2139
2769
  function stopDaemon() {
2140
- if (!fs3.existsSync(DAEMON_PID_FILE)) return console.log(chalk3.yellow("Not running."));
2770
+ if (!fs3.existsSync(DAEMON_PID_FILE)) return console.log(chalk4.yellow("Not running."));
2141
2771
  try {
2142
2772
  const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
2143
2773
  process.kill(pid, "SIGTERM");
2144
- console.log(chalk3.green("\u2705 Stopped."));
2774
+ console.log(chalk4.green("\u2705 Stopped."));
2145
2775
  } catch {
2146
- console.log(chalk3.gray("Cleaned up stale PID file."));
2776
+ console.log(chalk4.gray("Cleaned up stale PID file."));
2147
2777
  } finally {
2148
2778
  try {
2149
2779
  fs3.unlinkSync(DAEMON_PID_FILE);
@@ -2153,28 +2783,108 @@ function stopDaemon() {
2153
2783
  }
2154
2784
  function daemonStatus() {
2155
2785
  if (!fs3.existsSync(DAEMON_PID_FILE))
2156
- return console.log(chalk3.yellow("Node9 daemon: not running"));
2786
+ return console.log(chalk4.yellow("Node9 daemon: not running"));
2157
2787
  try {
2158
2788
  const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
2159
2789
  process.kill(pid, 0);
2160
- console.log(chalk3.green("Node9 daemon: running"));
2790
+ console.log(chalk4.green("Node9 daemon: running"));
2161
2791
  } catch {
2162
- console.log(chalk3.yellow("Node9 daemon: not running (stale PID)"));
2792
+ console.log(chalk4.yellow("Node9 daemon: not running (stale PID)"));
2163
2793
  }
2164
2794
  }
2165
2795
 
2166
2796
  // src/cli.ts
2167
- import { spawn, execSync as execSync2 } from "child_process";
2797
+ import { spawn as spawn3, execSync } from "child_process";
2168
2798
  import { parseCommandString } from "execa";
2169
2799
  import { execa } from "execa";
2170
- import chalk4 from "chalk";
2800
+ import chalk5 from "chalk";
2171
2801
  import readline from "readline";
2802
+ import fs5 from "fs";
2803
+ import path5 from "path";
2804
+ import os5 from "os";
2805
+
2806
+ // src/undo.ts
2807
+ import { spawnSync } from "child_process";
2172
2808
  import fs4 from "fs";
2173
2809
  import path4 from "path";
2174
2810
  import os4 from "os";
2811
+ var UNDO_LATEST_PATH = path4.join(os4.homedir(), ".node9", "undo_latest.txt");
2812
+ async function createShadowSnapshot() {
2813
+ try {
2814
+ const cwd = process.cwd();
2815
+ if (!fs4.existsSync(path4.join(cwd, ".git"))) return null;
2816
+ const tempIndex = path4.join(cwd, ".git", `node9_index_${Date.now()}`);
2817
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
2818
+ spawnSync("git", ["add", "-A"], { env });
2819
+ const treeRes = spawnSync("git", ["write-tree"], { env });
2820
+ const treeHash = treeRes.stdout.toString().trim();
2821
+ if (fs4.existsSync(tempIndex)) fs4.unlinkSync(tempIndex);
2822
+ if (!treeHash || treeRes.status !== 0) return null;
2823
+ const commitRes = spawnSync("git", [
2824
+ "commit-tree",
2825
+ treeHash,
2826
+ "-m",
2827
+ `Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
2828
+ ]);
2829
+ const commitHash = commitRes.stdout.toString().trim();
2830
+ if (commitHash && commitRes.status === 0) {
2831
+ const dir = path4.dirname(UNDO_LATEST_PATH);
2832
+ if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
2833
+ fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
2834
+ return commitHash;
2835
+ }
2836
+ } catch (err) {
2837
+ if (process.env.NODE9_DEBUG === "1") {
2838
+ console.error("[Node9 Undo Engine Error]:", err);
2839
+ }
2840
+ }
2841
+ return null;
2842
+ }
2843
+ function applyUndo(hash) {
2844
+ try {
2845
+ const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
2846
+ if (restore.status !== 0) return false;
2847
+ const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash]);
2848
+ const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
2849
+ const tracked = spawnSync("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
2850
+ const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
2851
+ for (const file of [...tracked, ...untracked]) {
2852
+ if (!snapshotFiles.has(file) && fs4.existsSync(file)) {
2853
+ fs4.unlinkSync(file);
2854
+ }
2855
+ }
2856
+ return true;
2857
+ } catch {
2858
+ return false;
2859
+ }
2860
+ }
2861
+ function getLatestSnapshotHash() {
2862
+ if (!fs4.existsSync(UNDO_LATEST_PATH)) return null;
2863
+ return fs4.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
2864
+ }
2865
+
2866
+ // src/cli.ts
2867
+ import { confirm as confirm3 } from "@inquirer/prompts";
2175
2868
  var { version } = JSON.parse(
2176
- fs4.readFileSync(path4.join(__dirname, "../package.json"), "utf-8")
2869
+ fs5.readFileSync(path5.join(__dirname, "../package.json"), "utf-8")
2177
2870
  );
2871
+ function parseDuration(str) {
2872
+ const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
2873
+ if (!m) return null;
2874
+ const n = parseFloat(m[1]);
2875
+ switch ((m[2] ?? "m").toLowerCase()) {
2876
+ case "s":
2877
+ return Math.round(n * 1e3);
2878
+ case "m":
2879
+ return Math.round(n * 6e4);
2880
+ case "h":
2881
+ return Math.round(n * 36e5);
2882
+ case "d":
2883
+ return Math.round(n * 864e5);
2884
+ default:
2885
+ return null;
2886
+ }
2887
+ }
2178
2888
  function sanitize(value) {
2179
2889
  return value.replace(/[\x00-\x1F\x7F]/g, "");
2180
2890
  }
@@ -2182,23 +2892,33 @@ function openBrowserLocal() {
2182
2892
  const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2183
2893
  try {
2184
2894
  const opts = { stdio: "ignore" };
2185
- if (process.platform === "darwin") execSync2(`open "${url}"`, opts);
2186
- else if (process.platform === "win32") execSync2(`cmd /c start "" "${url}"`, opts);
2187
- else execSync2(`xdg-open "${url}"`, opts);
2895
+ if (process.platform === "darwin") execSync(`open "${url}"`, opts);
2896
+ else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
2897
+ else execSync(`xdg-open "${url}"`, opts);
2188
2898
  } catch {
2189
2899
  }
2190
2900
  }
2191
2901
  async function autoStartDaemonAndWait() {
2192
2902
  try {
2193
- const child = spawn("node9", ["daemon"], {
2903
+ const child = spawn3("node9", ["daemon"], {
2194
2904
  detached: true,
2195
2905
  stdio: "ignore",
2196
2906
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
2197
2907
  });
2198
2908
  child.unref();
2199
- for (let i = 0; i < 12; i++) {
2909
+ for (let i = 0; i < 20; i++) {
2200
2910
  await new Promise((r) => setTimeout(r, 250));
2201
- if (isDaemonRunning()) return true;
2911
+ if (!isDaemonRunning()) continue;
2912
+ try {
2913
+ const res = await fetch("http://127.0.0.1:7391/settings", {
2914
+ signal: AbortSignal.timeout(500)
2915
+ });
2916
+ if (res.ok) {
2917
+ openBrowserLocal();
2918
+ return true;
2919
+ }
2920
+ } catch {
2921
+ }
2202
2922
  }
2203
2923
  } catch {
2204
2924
  }
@@ -2216,48 +2936,72 @@ async function runProxy(targetCommand) {
2216
2936
  if (stdout) executable = stdout.trim();
2217
2937
  } catch {
2218
2938
  }
2219
- console.log(chalk4.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2220
- const child = spawn(executable, args, {
2939
+ console.log(chalk5.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2940
+ const child = spawn3(executable, args, {
2221
2941
  stdio: ["pipe", "pipe", "inherit"],
2942
+ // We control STDIN and STDOUT
2222
2943
  shell: true,
2223
- env: { ...process.env, FORCE_COLOR: "1", TERM: process.env.TERM || "xterm-256color" }
2944
+ env: { ...process.env, FORCE_COLOR: "1" }
2224
2945
  });
2225
- process.stdin.pipe(child.stdin);
2226
- const childOut = readline.createInterface({ input: child.stdout, terminal: false });
2227
- childOut.on("line", async (line) => {
2946
+ const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
2947
+ agentIn.on("line", async (line) => {
2948
+ let message;
2228
2949
  try {
2229
- const message = JSON.parse(line);
2230
- if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2950
+ message = JSON.parse(line);
2951
+ } catch {
2952
+ child.stdin.write(line + "\n");
2953
+ return;
2954
+ }
2955
+ if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2956
+ agentIn.pause();
2957
+ try {
2231
2958
  const name = message.params?.name || message.params?.tool_name || "unknown";
2232
2959
  const toolArgs = message.params?.arguments || message.params?.tool_input || {};
2233
- const approved = await authorizeAction(sanitize(name), toolArgs);
2234
- if (!approved) {
2960
+ const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
2961
+ agent: "Proxy/MCP"
2962
+ });
2963
+ if (!result.approved) {
2235
2964
  const errorResponse = {
2236
2965
  jsonrpc: "2.0",
2237
2966
  id: message.id,
2238
- error: { code: -32e3, message: "Node9: Action denied." }
2967
+ error: {
2968
+ code: -32e3,
2969
+ message: `Node9: Action denied. ${result.reason || ""}`
2970
+ }
2239
2971
  };
2240
- child.stdin.write(JSON.stringify(errorResponse) + "\n");
2972
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
2241
2973
  return;
2242
2974
  }
2975
+ } catch {
2976
+ const errorResponse = {
2977
+ jsonrpc: "2.0",
2978
+ id: message.id,
2979
+ error: {
2980
+ code: -32e3,
2981
+ message: `Node9: Security engine encountered an error. Action blocked for safety.`
2982
+ }
2983
+ };
2984
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
2985
+ return;
2986
+ } finally {
2987
+ agentIn.resume();
2243
2988
  }
2244
- process.stdout.write(line + "\n");
2245
- } catch {
2246
- process.stdout.write(line + "\n");
2247
2989
  }
2990
+ child.stdin.write(line + "\n");
2248
2991
  });
2992
+ child.stdout.pipe(process.stdout);
2249
2993
  child.on("exit", (code) => process.exit(code || 0));
2250
2994
  }
2251
2995
  program.command("login").argument("<apiKey>").option("--local", "Save key for audit/logging only \u2014 local config still controls all decisions").option("--profile <name>", 'Save as a named profile (default: "default")').action((apiKey, options) => {
2252
2996
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
2253
- const credPath = path4.join(os4.homedir(), ".node9", "credentials.json");
2254
- if (!fs4.existsSync(path4.dirname(credPath)))
2255
- fs4.mkdirSync(path4.dirname(credPath), { recursive: true });
2997
+ const credPath = path5.join(os5.homedir(), ".node9", "credentials.json");
2998
+ if (!fs5.existsSync(path5.dirname(credPath)))
2999
+ fs5.mkdirSync(path5.dirname(credPath), { recursive: true });
2256
3000
  const profileName = options.profile || "default";
2257
3001
  let existingCreds = {};
2258
3002
  try {
2259
- if (fs4.existsSync(credPath)) {
2260
- const raw = JSON.parse(fs4.readFileSync(credPath, "utf-8"));
3003
+ if (fs5.existsSync(credPath)) {
3004
+ const raw = JSON.parse(fs5.readFileSync(credPath, "utf-8"));
2261
3005
  if (raw.apiKey) {
2262
3006
  existingCreds = {
2263
3007
  default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
@@ -2269,60 +3013,65 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
2269
3013
  } catch {
2270
3014
  }
2271
3015
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
2272
- fs4.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3016
+ fs5.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
2273
3017
  if (profileName === "default") {
2274
- const configPath = path4.join(os4.homedir(), ".node9", "config.json");
3018
+ const configPath = path5.join(os5.homedir(), ".node9", "config.json");
2275
3019
  let config = {};
2276
3020
  try {
2277
- if (fs4.existsSync(configPath))
2278
- config = JSON.parse(fs4.readFileSync(configPath, "utf-8"));
3021
+ if (fs5.existsSync(configPath))
3022
+ config = JSON.parse(fs5.readFileSync(configPath, "utf-8"));
2279
3023
  } catch {
2280
3024
  }
2281
3025
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
2282
- config.settings.agentMode = !options.local;
2283
- if (!fs4.existsSync(path4.dirname(configPath)))
2284
- fs4.mkdirSync(path4.dirname(configPath), { recursive: true });
2285
- fs4.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
3026
+ const s = config.settings;
3027
+ const approvers = s.approvers || {
3028
+ native: true,
3029
+ browser: true,
3030
+ cloud: true,
3031
+ terminal: true
3032
+ };
3033
+ approvers.cloud = !options.local;
3034
+ s.approvers = approvers;
3035
+ if (!fs5.existsSync(path5.dirname(configPath)))
3036
+ fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
3037
+ fs5.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
2286
3038
  }
2287
3039
  if (options.profile && profileName !== "default") {
2288
- console.log(chalk4.green(`\u2705 Profile "${profileName}" saved`));
2289
- console.log(chalk4.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
2290
- console.log(
2291
- chalk4.gray(
2292
- ` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
2293
- )
2294
- );
3040
+ console.log(chalk5.green(`\u2705 Profile "${profileName}" saved`));
3041
+ console.log(chalk5.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
2295
3042
  } else if (options.local) {
2296
- console.log(chalk4.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
2297
- console.log(chalk4.gray(` All decisions stay on this machine.`));
2298
- console.log(
2299
- chalk4.gray(` No data is sent to the cloud. Local config is the only authority.`)
2300
- );
2301
- console.log(chalk4.gray(` To enable cloud enforcement: node9 login <apiKey>`));
3043
+ console.log(chalk5.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
3044
+ console.log(chalk5.gray(` All decisions stay on this machine.`));
2302
3045
  } else {
2303
- console.log(chalk4.green(`\u2705 Logged in \u2014 agent mode`));
2304
- console.log(chalk4.gray(` Team policy enforced for all calls via Node9 cloud.`));
2305
- console.log(chalk4.gray(` To keep local control only: node9 login <apiKey> --local`));
3046
+ console.log(chalk5.green(`\u2705 Logged in \u2014 agent mode`));
3047
+ console.log(chalk5.gray(` Team policy enforced for all calls via Node9 cloud.`));
2306
3048
  }
2307
3049
  });
2308
3050
  program.command("addto").description("Integrate Node9 with an AI agent").addHelpText("after", "\n Supported targets: claude gemini cursor").argument("<target>", "The agent to protect: claude | gemini | cursor").action(async (target) => {
2309
3051
  if (target === "gemini") return await setupGemini();
2310
3052
  if (target === "claude") return await setupClaude();
2311
3053
  if (target === "cursor") return await setupCursor();
2312
- console.error(chalk4.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
3054
+ console.error(chalk5.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
2313
3055
  process.exit(1);
2314
3056
  });
2315
3057
  program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").action((options) => {
2316
- const configPath = path4.join(os4.homedir(), ".node9", "config.json");
2317
- if (fs4.existsSync(configPath) && !options.force) {
2318
- console.log(chalk4.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
2319
- console.log(chalk4.gray(` Run with --force to overwrite.`));
3058
+ const configPath = path5.join(os5.homedir(), ".node9", "config.json");
3059
+ if (fs5.existsSync(configPath) && !options.force) {
3060
+ console.log(chalk5.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
3061
+ console.log(chalk5.gray(` Run with --force to overwrite.`));
2320
3062
  return;
2321
3063
  }
2322
3064
  const defaultConfig = {
2323
3065
  version: "1.0",
2324
- settings: { mode: "standard" },
3066
+ settings: {
3067
+ mode: "standard",
3068
+ autoStartDaemon: true,
3069
+ enableUndo: true,
3070
+ enableHookLogDebug: false,
3071
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
3072
+ },
2325
3073
  policy: {
3074
+ sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
2326
3075
  dangerousWords: DANGEROUS_WORDS,
2327
3076
  ignoredTools: [
2328
3077
  "list_*",
@@ -2332,161 +3081,136 @@ program.command("init").description("Create ~/.node9/config.json with default po
2332
3081
  "read",
2333
3082
  "write",
2334
3083
  "edit",
2335
- "multiedit",
2336
3084
  "glob",
2337
3085
  "grep",
2338
3086
  "ls",
2339
3087
  "notebookread",
2340
3088
  "notebookedit",
2341
- "todoread",
2342
- "todowrite",
2343
3089
  "webfetch",
2344
3090
  "websearch",
2345
3091
  "exitplanmode",
2346
- "askuserquestion"
3092
+ "askuserquestion",
3093
+ "agent",
3094
+ "task*"
2347
3095
  ],
2348
3096
  toolInspection: {
2349
3097
  bash: "command",
2350
3098
  shell: "command",
2351
3099
  run_shell_command: "command",
2352
- "terminal.execute": "command"
3100
+ "terminal.execute": "command",
3101
+ "postgres:query": "sql"
2353
3102
  },
2354
3103
  rules: [
2355
3104
  {
2356
3105
  action: "rm",
2357
- allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"]
3106
+ allowPaths: [
3107
+ "**/node_modules/**",
3108
+ "dist/**",
3109
+ "build/**",
3110
+ ".next/**",
3111
+ "coverage/**",
3112
+ ".cache/**",
3113
+ "tmp/**",
3114
+ "temp/**",
3115
+ ".DS_Store"
3116
+ ]
2358
3117
  }
2359
3118
  ]
2360
3119
  }
2361
3120
  };
2362
- if (!fs4.existsSync(path4.dirname(configPath)))
2363
- fs4.mkdirSync(path4.dirname(configPath), { recursive: true });
2364
- fs4.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
2365
- console.log(chalk4.green(`\u2705 Global config created: ${configPath}`));
2366
- console.log(chalk4.gray(` Edit this file to add custom tool inspection or security rules.`));
3121
+ if (!fs5.existsSync(path5.dirname(configPath)))
3122
+ fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
3123
+ fs5.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
3124
+ console.log(chalk5.green(`\u2705 Global config created: ${configPath}`));
3125
+ console.log(chalk5.gray(` Edit this file to add custom tool inspection or security rules.`));
2367
3126
  });
2368
3127
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
2369
3128
  const creds = getCredentials();
2370
3129
  const daemonRunning = isDaemonRunning();
2371
- const settings = getGlobalSettings();
3130
+ const mergedConfig = getConfig();
3131
+ const settings = mergedConfig.settings;
2372
3132
  console.log("");
2373
- if (creds && settings.agentMode) {
2374
- console.log(chalk4.green(" \u25CF Agent mode") + chalk4.gray(" \u2014 cloud team policy enforced"));
2375
- console.log(chalk4.gray(" All calls \u2192 Node9 cloud \u2192 Policy Studio rules apply"));
2376
- console.log(chalk4.gray(" Switch to local control: node9 login <apiKey> --local"));
2377
- } else if (creds && !settings.agentMode) {
3133
+ if (creds && settings.approvers.cloud) {
3134
+ console.log(chalk5.green(" \u25CF Agent mode") + chalk5.gray(" \u2014 cloud team policy enforced"));
3135
+ } else if (creds && !settings.approvers.cloud) {
2378
3136
  console.log(
2379
- chalk4.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 all decisions stay on this machine")
3137
+ chalk5.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + chalk5.gray(" \u2014 all decisions stay on this machine")
2380
3138
  );
3139
+ } else {
2381
3140
  console.log(
2382
- chalk4.gray(" No data is sent to the cloud. Local config is the only authority.")
3141
+ chalk5.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk5.gray(" \u2014 no API key (Local rules only)")
2383
3142
  );
2384
- console.log(chalk4.gray(" Enable cloud enforcement: node9 login <apiKey>"));
2385
- } else {
2386
- console.log(chalk4.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 no API key"));
2387
- console.log(chalk4.gray(" All decisions stay on this machine."));
2388
- console.log(chalk4.gray(" Connect to your team: node9 login <apiKey>"));
2389
3143
  }
2390
3144
  console.log("");
2391
3145
  if (daemonRunning) {
2392
3146
  console.log(
2393
- chalk4.green(" \u25CF Daemon running") + chalk4.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
3147
+ chalk5.green(" \u25CF Daemon running") + chalk5.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
2394
3148
  );
2395
3149
  } else {
2396
- console.log(chalk4.gray(" \u25CB Daemon stopped"));
2397
- console.log(chalk4.gray(" Start: node9 daemon --background"));
3150
+ console.log(chalk5.gray(" \u25CB Daemon stopped"));
2398
3151
  }
2399
- console.log("");
2400
- console.log(` Mode: ${chalk4.white(settings.mode)}`);
2401
- const projectConfig = path4.join(process.cwd(), "node9.config.json");
2402
- const globalConfig = path4.join(os4.homedir(), ".node9", "config.json");
2403
- const configSource = fs4.existsSync(projectConfig) ? projectConfig : fs4.existsSync(globalConfig) ? globalConfig : chalk4.gray("none (built-in defaults)");
2404
- console.log(` Config: ${chalk4.gray(configSource)}`);
2405
- const profiles = listCredentialProfiles();
2406
- if (profiles.length > 1) {
2407
- const activeProfile = process.env.NODE9_PROFILE || "default";
2408
- console.log("");
2409
- console.log(` Active profile: ${chalk4.white(activeProfile)}`);
3152
+ if (settings.enableUndo) {
2410
3153
  console.log(
2411
- ` All profiles: ${profiles.map((p) => p === activeProfile ? chalk4.green(p) : chalk4.gray(p)).join(chalk4.gray(", "))}`
3154
+ chalk5.magenta(" \u25CF Undo Engine") + chalk5.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
2412
3155
  );
2413
- console.log(chalk4.gray(` Switch: NODE9_PROFILE=<name> claude`));
2414
3156
  }
2415
- const decisionsFile = path4.join(os4.homedir(), ".node9", "decisions.json");
2416
- let decisions = {};
2417
- try {
2418
- if (fs4.existsSync(decisionsFile))
2419
- decisions = JSON.parse(fs4.readFileSync(decisionsFile, "utf-8"));
2420
- } catch {
2421
- }
2422
- const keys = Object.keys(decisions);
2423
3157
  console.log("");
2424
- if (keys.length > 0) {
2425
- console.log(` Persistent decisions (${keys.length}):`);
2426
- keys.forEach((tool) => {
2427
- const d = decisions[tool];
2428
- const badge = d === "allow" ? chalk4.green("allow") : chalk4.red("deny");
2429
- console.log(` ${chalk4.gray("\xB7")} ${tool.padEnd(35)} ${badge}`);
2430
- });
2431
- console.log(chalk4.gray("\n Manage: node9 daemon --openui \u2192 Decisions tab"));
2432
- } else {
2433
- console.log(chalk4.gray(" No persistent decisions set"));
3158
+ const modeLabel = settings.mode === "audit" ? chalk5.blue("audit") : settings.mode === "strict" ? chalk5.red("strict") : chalk5.white("standard");
3159
+ console.log(` Mode: ${modeLabel}`);
3160
+ const projectConfig = path5.join(process.cwd(), "node9.config.json");
3161
+ const globalConfig = path5.join(os5.homedir(), ".node9", "config.json");
3162
+ console.log(
3163
+ ` Local: ${fs5.existsSync(projectConfig) ? chalk5.green("Active (node9.config.json)") : chalk5.gray("Not present")}`
3164
+ );
3165
+ console.log(
3166
+ ` Global: ${fs5.existsSync(globalConfig) ? chalk5.green("Active (~/.node9/config.json)") : chalk5.gray("Not present")}`
3167
+ );
3168
+ if (mergedConfig.policy.sandboxPaths.length > 0) {
3169
+ console.log(
3170
+ ` Sandbox: ${chalk5.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
3171
+ );
2434
3172
  }
2435
- const auditLogPath = path4.join(os4.homedir(), ".node9", "audit.log");
2436
- try {
2437
- if (fs4.existsSync(auditLogPath)) {
2438
- const lines = fs4.readFileSync(auditLogPath, "utf-8").split("\n").filter((l) => l.trim().length > 0);
2439
- console.log("");
2440
- console.log(
2441
- ` \u{1F4CB} Local Audit Log: ` + chalk4.white(`${lines.length} agent action${lines.length !== 1 ? "s" : ""} recorded`) + chalk4.gray(` (cat ~/.node9/audit.log to view)`)
2442
- );
2443
- }
2444
- } catch {
3173
+ const pauseState = checkPause();
3174
+ if (pauseState.paused) {
3175
+ const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
3176
+ console.log("");
3177
+ console.log(
3178
+ chalk5.yellow(` \u23F8 PAUSED until ${expiresAt}`) + chalk5.gray(" \u2014 all tool calls allowed")
3179
+ );
2445
3180
  }
2446
3181
  console.log("");
2447
3182
  });
2448
- program.command("daemon").description("Run the local approval server (browser HITL for free tier)").addHelpText(
2449
- "after",
2450
- "\n Subcommands: start (default), stop, status\n Options:\n --background (-b) start detached, no second terminal needed\n --openui (-o) start in background and open the browser (or just open if already running)\n Example: node9 daemon --background"
2451
- ).argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option(
2452
- "-o, --openui",
2453
- "Start in background and open browser (or just open browser if already running)"
2454
- ).action(
3183
+ program.command("daemon").description("Run the local approval server").argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option("-o, --openui", "Start in background and open browser").action(
2455
3184
  async (action, options) => {
2456
3185
  const cmd = (action ?? "start").toLowerCase();
2457
3186
  if (cmd === "stop") return stopDaemon();
2458
3187
  if (cmd === "status") return daemonStatus();
2459
3188
  if (cmd !== "start" && action !== void 0) {
2460
- console.error(chalk4.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
3189
+ console.error(chalk5.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
2461
3190
  process.exit(1);
2462
3191
  }
2463
3192
  if (options.openui) {
2464
3193
  if (isDaemonRunning()) {
2465
3194
  openBrowserLocal();
2466
- console.log(chalk4.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
3195
+ console.log(chalk5.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2467
3196
  process.exit(0);
2468
3197
  }
2469
- const child = spawn("node9", ["daemon"], { detached: true, stdio: "ignore" });
3198
+ const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
2470
3199
  child.unref();
2471
3200
  for (let i = 0; i < 12; i++) {
2472
3201
  await new Promise((r) => setTimeout(r, 250));
2473
3202
  if (isDaemonRunning()) break;
2474
3203
  }
2475
3204
  openBrowserLocal();
2476
- console.log(chalk4.green(`
3205
+ console.log(chalk5.green(`
2477
3206
  \u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
2478
- console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2479
3207
  process.exit(0);
2480
3208
  }
2481
3209
  if (options.background) {
2482
- const child = spawn("node9", ["daemon"], { detached: true, stdio: "ignore" });
3210
+ const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
2483
3211
  child.unref();
2484
- console.log(chalk4.green(`
3212
+ console.log(chalk5.green(`
2485
3213
  \u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
2486
- console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2487
- console.log(chalk4.gray(` node9 daemon status \u2014 check if running`));
2488
- console.log(chalk4.gray(` node9 daemon stop \u2014 stop it
2489
- `));
2490
3214
  process.exit(0);
2491
3215
  }
2492
3216
  startDaemon();
@@ -2496,53 +3220,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2496
3220
  const processPayload = async (raw) => {
2497
3221
  try {
2498
3222
  if (!raw || raw.trim() === "") process.exit(0);
2499
- if (process.env.NODE9_DEBUG === "1") {
2500
- const logPath = path4.join(os4.homedir(), ".node9", "hook-debug.log");
2501
- if (!fs4.existsSync(path4.dirname(logPath)))
2502
- fs4.mkdirSync(path4.dirname(logPath), { recursive: true });
2503
- fs4.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
2504
- `);
2505
- fs4.appendFileSync(
2506
- logPath,
2507
- `[${(/* @__PURE__ */ new Date()).toISOString()}] TTY: ${process.stdout.isTTY}
3223
+ let payload = JSON.parse(raw);
3224
+ try {
3225
+ payload = JSON.parse(raw);
3226
+ } catch (err) {
3227
+ const tempConfig = getConfig();
3228
+ if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
3229
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
3230
+ const errMsg = err instanceof Error ? err.message : String(err);
3231
+ fs5.appendFileSync(
3232
+ logPath,
3233
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
3234
+ RAW: ${raw}
2508
3235
  `
2509
- );
3236
+ );
3237
+ }
3238
+ process.exit(0);
3239
+ return;
3240
+ }
3241
+ if (payload.cwd) {
3242
+ try {
3243
+ process.chdir(payload.cwd);
3244
+ _resetConfigCache();
3245
+ } catch {
3246
+ }
3247
+ }
3248
+ const config = getConfig();
3249
+ if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
3250
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
3251
+ if (!fs5.existsSync(path5.dirname(logPath)))
3252
+ fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
3253
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
3254
+ `);
2510
3255
  }
2511
- const payload = JSON.parse(raw);
2512
3256
  const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
2513
3257
  const toolInput = payload.tool_input ?? payload.args ?? {};
2514
- const agent = payload.tool_name !== void 0 ? "Claude Code" : payload.name !== void 0 ? "Gemini CLI" : "Terminal";
3258
+ const agent = payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : payload.tool_name !== void 0 || payload.name !== void 0 ? "Unknown Agent" : "Terminal";
2515
3259
  const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
2516
3260
  const mcpServer = mcpMatch?.[1];
2517
3261
  const sendBlock = (msg, result2) => {
2518
- const BLOCKED_BY_LABELS = {
2519
- "team-policy": "team policy (set by your admin)",
2520
- "persistent-deny": "you set this tool to always deny",
2521
- "local-config": "your local config (dangerousWords / rules)",
2522
- "local-decision": "you denied it in the browser",
2523
- "no-approval-mechanism": "no approval method is configured"
2524
- };
2525
- console.error(chalk4.red(`
3262
+ const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
3263
+ const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
3264
+ console.error(chalk5.red(`
2526
3265
  \u{1F6D1} Node9 blocked "${toolName}"`));
2527
- if (result2?.blockedBy) {
2528
- console.error(
2529
- chalk4.gray(
2530
- ` Blocked by: ${BLOCKED_BY_LABELS[result2.blockedBy] ?? result2.blockedBy}`
2531
- )
2532
- );
2533
- }
2534
- if (result2?.changeHint) {
2535
- console.error(chalk4.cyan(` To change: ${result2.changeHint}`));
2536
- }
3266
+ console.error(chalk5.gray(` Triggered by: ${blockedByContext}`));
3267
+ if (result2?.changeHint) console.error(chalk5.cyan(` To change: ${result2.changeHint}`));
2537
3268
  console.error("");
3269
+ let aiFeedbackMessage = "";
3270
+ if (isHumanDecision) {
3271
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
3272
+ REASON: ${msg || "No specific reason provided by user."}
3273
+
3274
+ INSTRUCTIONS FOR AI AGENT:
3275
+ - Do NOT retry this exact command immediately.
3276
+ - Explain to the user that you understand they blocked the action.
3277
+ - Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
3278
+ - If you believe this action is critical, explain your reasoning to the user and ask them to run 'node9 pause 15m' to allow you to proceed.`;
3279
+ } else {
3280
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
3281
+ REASON: ${msg}
3282
+
3283
+ INSTRUCTIONS FOR AI AGENT:
3284
+ - This command violates the current security configuration.
3285
+ - Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
3286
+ - Pivot to a non-destructive or read-only alternative.
3287
+ - Inform the user which security rule was triggered.`;
3288
+ }
2538
3289
  process.stdout.write(
2539
3290
  JSON.stringify({
2540
3291
  decision: "block",
2541
- reason: msg,
3292
+ reason: aiFeedbackMessage,
3293
+ // This is the core instruction
2542
3294
  hookSpecificOutput: {
2543
3295
  hookEventName: "PreToolUse",
2544
3296
  permissionDecision: "deny",
2545
- permissionDecisionReason: msg
3297
+ permissionDecisionReason: aiFeedbackMessage
2546
3298
  }
2547
3299
  }) + "\n"
2548
3300
  );
@@ -2553,36 +3305,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2553
3305
  return;
2554
3306
  }
2555
3307
  const meta = { agent, mcpServer };
3308
+ const STATE_CHANGING_TOOLS_PRE = [
3309
+ "bash",
3310
+ "shell",
3311
+ "write_file",
3312
+ "edit_file",
3313
+ "replace",
3314
+ "terminal.execute",
3315
+ "str_replace_based_edit_tool",
3316
+ "create_file"
3317
+ ];
3318
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
3319
+ await createShadowSnapshot();
3320
+ }
2556
3321
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
2557
3322
  if (result.approved) {
2558
- if (result.checkedBy) {
3323
+ if (result.checkedBy)
2559
3324
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
2560
3325
  `);
2561
- }
2562
3326
  process.exit(0);
2563
3327
  }
2564
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && getGlobalSettings().autoStartDaemon) {
2565
- console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
3328
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
3329
+ console.error(chalk5.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2566
3330
  const daemonReady = await autoStartDaemonAndWait();
2567
3331
  if (daemonReady) {
2568
3332
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
2569
3333
  if (retry.approved) {
2570
- if (retry.checkedBy) {
3334
+ if (retry.checkedBy)
2571
3335
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
2572
3336
  `);
2573
- }
2574
3337
  process.exit(0);
2575
3338
  }
2576
- sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, retry);
3339
+ sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
3340
+ ...retry,
3341
+ blockedByLabel: retry.blockedByLabel
3342
+ });
2577
3343
  return;
2578
3344
  }
2579
3345
  }
2580
- sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, result);
3346
+ sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
3347
+ ...result,
3348
+ blockedByLabel: result.blockedByLabel
3349
+ });
2581
3350
  } catch (err) {
2582
3351
  if (process.env.NODE9_DEBUG === "1") {
2583
- const logPath = path4.join(os4.homedir(), ".node9", "hook-debug.log");
3352
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
2584
3353
  const errMsg = err instanceof Error ? err.message : String(err);
2585
- fs4.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
3354
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
2586
3355
  `);
2587
3356
  }
2588
3357
  process.exit(0);
@@ -2593,48 +3362,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2593
3362
  } else {
2594
3363
  let raw = "";
2595
3364
  let processed = false;
3365
+ let inactivityTimer = null;
2596
3366
  const done = async () => {
2597
3367
  if (processed) return;
2598
3368
  processed = true;
3369
+ if (inactivityTimer) clearTimeout(inactivityTimer);
2599
3370
  if (!raw.trim()) return process.exit(0);
2600
3371
  await processPayload(raw);
2601
3372
  };
2602
3373
  process.stdin.setEncoding("utf-8");
2603
- process.stdin.on("data", (chunk) => raw += chunk);
2604
- process.stdin.on("end", () => void done());
2605
- setTimeout(() => void done(), 5e3);
3374
+ process.stdin.on("data", (chunk) => {
3375
+ raw += chunk;
3376
+ if (inactivityTimer) clearTimeout(inactivityTimer);
3377
+ inactivityTimer = setTimeout(() => void done(), 2e3);
3378
+ });
3379
+ process.stdin.on("end", () => {
3380
+ void done();
3381
+ });
3382
+ inactivityTimer = setTimeout(() => void done(), 5e3);
2606
3383
  }
2607
3384
  });
2608
3385
  program.command("log").description("PostToolUse hook \u2014 records executed tool calls").argument("[data]", "JSON string of the tool call").action(async (data) => {
2609
- const logPayload = (raw) => {
3386
+ const logPayload = async (raw) => {
2610
3387
  try {
2611
3388
  if (!raw || raw.trim() === "") process.exit(0);
2612
3389
  const payload = JSON.parse(raw);
3390
+ const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
3391
+ const rawInput = payload.tool_input ?? payload.args ?? {};
2613
3392
  const entry = {
2614
3393
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2615
- tool: sanitize(payload.tool_name ?? "unknown"),
2616
- input: JSON.parse(redactSecrets(JSON.stringify(payload.tool_input || {})))
3394
+ tool,
3395
+ args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
3396
+ decision: "allowed",
3397
+ source: "post-hook"
2617
3398
  };
2618
- const logPath = path4.join(os4.homedir(), ".node9", "audit.log");
2619
- if (!fs4.existsSync(path4.dirname(logPath)))
2620
- fs4.mkdirSync(path4.dirname(logPath), { recursive: true });
2621
- fs4.appendFileSync(logPath, JSON.stringify(entry) + "\n");
3399
+ const logPath = path5.join(os5.homedir(), ".node9", "audit.log");
3400
+ if (!fs5.existsSync(path5.dirname(logPath)))
3401
+ fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
3402
+ fs5.appendFileSync(logPath, JSON.stringify(entry) + "\n");
3403
+ const config = getConfig();
3404
+ const STATE_CHANGING_TOOLS = [
3405
+ "bash",
3406
+ "shell",
3407
+ "write_file",
3408
+ "edit_file",
3409
+ "replace",
3410
+ "terminal.execute"
3411
+ ];
3412
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
3413
+ await createShadowSnapshot();
3414
+ }
2622
3415
  } catch {
2623
3416
  }
2624
3417
  process.exit(0);
2625
3418
  };
2626
3419
  if (data) {
2627
- logPayload(data);
3420
+ await logPayload(data);
2628
3421
  } else {
2629
3422
  let raw = "";
2630
3423
  process.stdin.setEncoding("utf-8");
2631
3424
  process.stdin.on("data", (chunk) => raw += chunk);
2632
- process.stdin.on("end", () => logPayload(raw));
3425
+ process.stdin.on("end", () => {
3426
+ void logPayload(raw);
3427
+ });
2633
3428
  setTimeout(() => {
2634
3429
  if (!raw) process.exit(0);
2635
3430
  }, 500);
2636
3431
  }
2637
3432
  });
3433
+ program.command("pause").description("Temporarily disable Node9 protection for a set duration").option("-d, --duration <duration>", "How long to pause (e.g. 15m, 1h, 30s)", "15m").action((options) => {
3434
+ const ms = parseDuration(options.duration);
3435
+ if (ms === null) {
3436
+ console.error(
3437
+ chalk5.red(`
3438
+ \u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
3439
+ `)
3440
+ );
3441
+ process.exit(1);
3442
+ }
3443
+ pauseNode9(ms, options.duration);
3444
+ const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
3445
+ console.log(chalk5.yellow(`
3446
+ \u23F8 Node9 paused until ${expiresAt}`));
3447
+ console.log(chalk5.gray(` All tool calls will be allowed without review.`));
3448
+ console.log(chalk5.gray(` Run "node9 resume" to re-enable early.
3449
+ `));
3450
+ });
3451
+ program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
3452
+ const { paused } = checkPause();
3453
+ if (!paused) {
3454
+ console.log(chalk5.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
3455
+ return;
3456
+ }
3457
+ resumeNode9();
3458
+ console.log(chalk5.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
3459
+ });
2638
3460
  var HOOK_BASED_AGENTS = {
2639
3461
  claude: "claude",
2640
3462
  gemini: "gemini",
@@ -2646,39 +3468,21 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2646
3468
  if (HOOK_BASED_AGENTS[firstArg] !== void 0) {
2647
3469
  const target = HOOK_BASED_AGENTS[firstArg];
2648
3470
  console.error(
2649
- chalk4.yellow(`
3471
+ chalk5.yellow(`
2650
3472
  \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
2651
3473
  );
3474
+ console.error(chalk5.white(`
3475
+ "${target}" uses its own hook system. Use:`));
2652
3476
  console.error(
2653
- chalk4.white(`
2654
- "${target}" is an interactive terminal app \u2014 it needs a real`)
2655
- );
2656
- console.error(
2657
- chalk4.white(` TTY and communicates via its own hook system, not JSON-RPC.
2658
- `)
2659
- );
2660
- console.error(chalk4.bold(` Use the hook-based integration instead:
2661
- `));
2662
- console.error(
2663
- chalk4.green(` node9 addto ${target} `) + chalk4.gray("# one-time setup")
2664
- );
2665
- console.error(
2666
- chalk4.green(` ${target} `) + chalk4.gray("# run normally \u2014 Node9 hooks fire automatically")
2667
- );
2668
- console.error(chalk4.white(`
2669
- For browser approval popups (no API key required):`));
2670
- console.error(
2671
- chalk4.green(` node9 daemon --background`) + chalk4.gray("# start (no second terminal needed)")
2672
- );
2673
- console.error(
2674
- chalk4.green(` ${target} `) + chalk4.gray("# Node9 will open browser on dangerous actions\n")
3477
+ chalk5.green(` node9 addto ${target} `) + chalk5.gray("# one-time setup")
2675
3478
  );
3479
+ console.error(chalk5.green(` ${target} `) + chalk5.gray("# run normally"));
2676
3480
  process.exit(1);
2677
3481
  }
2678
3482
  const fullCommand = commandArgs.join(" ");
2679
3483
  let result = await authorizeHeadless("shell", { command: fullCommand });
2680
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getGlobalSettings().autoStartDaemon) {
2681
- console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
3484
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
3485
+ console.error(chalk5.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2682
3486
  const daemonReady = await autoStartDaemonAndWait();
2683
3487
  if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
2684
3488
  }
@@ -2687,39 +3491,44 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2687
3491
  }
2688
3492
  if (!result.approved) {
2689
3493
  console.error(
2690
- chalk4.red(`
3494
+ chalk5.red(`
2691
3495
  \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
2692
3496
  );
2693
- if (result.blockedBy) {
2694
- const BLOCKED_BY_LABELS = {
2695
- "team-policy": "Team policy (Node9 cloud)",
2696
- "persistent-deny": "Persistent deny rule",
2697
- "local-config": "Local config",
2698
- "local-decision": "Browser UI decision",
2699
- "no-approval-mechanism": "No approval mechanism available"
2700
- };
2701
- console.error(
2702
- chalk4.gray(` Blocked by: ${BLOCKED_BY_LABELS[result.blockedBy] ?? result.blockedBy}`)
2703
- );
2704
- }
2705
- if (result.changeHint) {
2706
- console.error(chalk4.cyan(` To change: ${result.changeHint}`));
2707
- }
2708
3497
  process.exit(1);
2709
3498
  }
2710
- console.error(chalk4.green("\n\u2705 Approved \u2014 running command...\n"));
3499
+ console.error(chalk5.green("\n\u2705 Approved \u2014 running command...\n"));
2711
3500
  await runProxy(fullCommand);
2712
3501
  } else {
2713
3502
  program.help();
2714
3503
  }
2715
3504
  });
3505
+ program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
3506
+ const hash = getLatestSnapshotHash();
3507
+ if (!hash) {
3508
+ console.log(chalk5.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
3509
+ return;
3510
+ }
3511
+ console.log(chalk5.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
3512
+ console.log(chalk5.white(`Target Snapshot: ${chalk5.gray(hash.slice(0, 7))}`));
3513
+ const proceed = await confirm3({
3514
+ message: "Revert all files to the state before the last AI action?",
3515
+ default: false
3516
+ });
3517
+ if (proceed) {
3518
+ if (applyUndo(hash)) {
3519
+ console.log(chalk5.green("\u2705 Project reverted successfully.\n"));
3520
+ } else {
3521
+ console.error(chalk5.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
3522
+ }
3523
+ }
3524
+ });
2716
3525
  process.on("unhandledRejection", (reason) => {
2717
3526
  const isCheckHook = process.argv[2] === "check";
2718
3527
  if (isCheckHook) {
2719
- if (process.env.NODE9_DEBUG === "1") {
2720
- const logPath = path4.join(os4.homedir(), ".node9", "hook-debug.log");
3528
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
3529
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
2721
3530
  const msg = reason instanceof Error ? reason.message : String(reason);
2722
- fs4.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
3531
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
2723
3532
  `);
2724
3533
  }
2725
3534
  process.exit(0);