@node9/proxy 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +66 -319
  2. package/dist/cli.js +1372 -540
  3. package/dist/cli.mjs +1372 -540
  4. package/dist/index.js +744 -260
  5. package/dist/index.mjs +744 -260
  6. package/package.json +15 -8
package/dist/cli.mjs CHANGED
@@ -11,6 +11,270 @@ 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
+ var isTestEnv = () => {
18
+ 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";
19
+ };
20
+ function sendDesktopNotification(title, body) {
21
+ if (isTestEnv()) return;
22
+ try {
23
+ const safeTitle = title.replace(/"/g, '\\"');
24
+ const safeBody = body.replace(/"/g, '\\"');
25
+ if (process.platform === "darwin") {
26
+ const script = `display notification "${safeBody}" with title "${safeTitle}"`;
27
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
28
+ } else if (process.platform === "linux") {
29
+ spawn("notify-send", [safeTitle, safeBody, "--icon=dialog-warning"], {
30
+ detached: true,
31
+ stdio: "ignore"
32
+ }).unref();
33
+ }
34
+ } catch {
35
+ }
36
+ }
37
+ function formatArgs(args) {
38
+ if (args === null || args === void 0) return "(none)";
39
+ if (typeof args !== "object" || Array.isArray(args)) {
40
+ const str = typeof args === "string" ? args : JSON.stringify(args);
41
+ return str.length > 200 ? str.slice(0, 200) + "\u2026" : str;
42
+ }
43
+ const entries = Object.entries(args).filter(
44
+ ([, v]) => v !== null && v !== void 0 && v !== ""
45
+ );
46
+ if (entries.length === 0) return "(none)";
47
+ const MAX_FIELDS = 5;
48
+ const MAX_VALUE_LEN = 120;
49
+ const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
50
+ const str = typeof val === "string" ? val : JSON.stringify(val);
51
+ const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
52
+ return ` ${key}: ${truncated}`;
53
+ });
54
+ if (entries.length > MAX_FIELDS) {
55
+ lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
60
+ if (isTestEnv()) return "deny";
61
+ if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
62
+ console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
63
+ console.log(`[DEBUG Native] isTestEnv check:`, {
64
+ VITEST: process.env.VITEST,
65
+ NODE_ENV: process.env.NODE_ENV,
66
+ CI: process.env.CI,
67
+ isTest: isTestEnv()
68
+ });
69
+ }
70
+ const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
71
+ let message = "";
72
+ if (locked) {
73
+ message += `\u26A1 Awaiting remote approval via Slack. Local override is disabled.
74
+ `;
75
+ message += `\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
76
+ `;
77
+ }
78
+ message += `Tool: ${toolName}
79
+ `;
80
+ message += `Agent: ${agent || "AI Agent"}
81
+ `;
82
+ if (explainableLabel) {
83
+ message += `Reason: ${explainableLabel}
84
+ `;
85
+ }
86
+ message += `
87
+ Arguments:
88
+ ${formatArgs(args)}`;
89
+ if (!locked) {
90
+ message += `
91
+
92
+ Enter = Allow | Click "Block" to deny`;
93
+ }
94
+ const safeMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "'");
95
+ const safeTitle = title.replace(/"/g, '\\"');
96
+ return new Promise((resolve) => {
97
+ let childProcess = null;
98
+ const onAbort = () => {
99
+ if (childProcess) {
100
+ try {
101
+ process.kill(childProcess.pid, "SIGKILL");
102
+ } catch {
103
+ }
104
+ }
105
+ resolve("deny");
106
+ };
107
+ if (signal) {
108
+ if (signal.aborted) return resolve("deny");
109
+ signal.addEventListener("abort", onAbort);
110
+ }
111
+ const cleanup = () => {
112
+ if (signal) signal.removeEventListener("abort", onAbort);
113
+ };
114
+ try {
115
+ if (process.platform === "darwin") {
116
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
117
+ const script = `
118
+ tell application "System Events"
119
+ activate
120
+ display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
121
+ end tell`;
122
+ childProcess = spawn("osascript", ["-e", script]);
123
+ let output = "";
124
+ childProcess.stdout?.on("data", (d) => output += d.toString());
125
+ childProcess.on("close", (code) => {
126
+ cleanup();
127
+ if (locked) return resolve("deny");
128
+ if (code === 0) {
129
+ if (output.includes("Always Allow")) return resolve("always_allow");
130
+ if (output.includes("Allow")) return resolve("allow");
131
+ }
132
+ resolve("deny");
133
+ });
134
+ } else if (process.platform === "linux") {
135
+ const argsList = locked ? [
136
+ "--info",
137
+ "--title",
138
+ title,
139
+ "--text",
140
+ safeMessage,
141
+ "--ok-label",
142
+ "Waiting for Slack\u2026",
143
+ "--timeout",
144
+ "300"
145
+ ] : [
146
+ "--question",
147
+ "--title",
148
+ title,
149
+ "--text",
150
+ safeMessage,
151
+ "--ok-label",
152
+ "Allow",
153
+ "--cancel-label",
154
+ "Block",
155
+ "--extra-button",
156
+ "Always Allow",
157
+ "--timeout",
158
+ "300"
159
+ ];
160
+ childProcess = spawn("zenity", argsList);
161
+ let output = "";
162
+ childProcess.stdout?.on("data", (d) => output += d.toString());
163
+ childProcess.on("close", (code) => {
164
+ cleanup();
165
+ if (locked) return resolve("deny");
166
+ if (output.trim() === "Always Allow") return resolve("always_allow");
167
+ if (code === 0) return resolve("allow");
168
+ resolve("deny");
169
+ });
170
+ } else if (process.platform === "win32") {
171
+ const buttonType = locked ? "OK" : "YesNo";
172
+ const ps = `
173
+ Add-Type -AssemblyName PresentationFramework;
174
+ $res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
175
+ if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
176
+ childProcess = spawn("powershell", ["-Command", ps]);
177
+ childProcess.on("close", (code) => {
178
+ cleanup();
179
+ if (locked) return resolve("deny");
180
+ resolve(code === 0 ? "allow" : "deny");
181
+ });
182
+ } else {
183
+ cleanup();
184
+ resolve("deny");
185
+ }
186
+ } catch {
187
+ cleanup();
188
+ resolve("deny");
189
+ }
190
+ });
191
+ }
192
+
193
+ // src/core.ts
194
+ var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
195
+ var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
196
+ function checkPause() {
197
+ try {
198
+ if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
199
+ const state = JSON.parse(fs.readFileSync(PAUSED_FILE, "utf-8"));
200
+ if (state.expiry > 0 && Date.now() >= state.expiry) {
201
+ try {
202
+ fs.unlinkSync(PAUSED_FILE);
203
+ } catch {
204
+ }
205
+ return { paused: false };
206
+ }
207
+ return { paused: true, expiresAt: state.expiry, duration: state.duration };
208
+ } catch {
209
+ return { paused: false };
210
+ }
211
+ }
212
+ function atomicWriteSync(filePath, data, options) {
213
+ const dir = path.dirname(filePath);
214
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
215
+ const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
216
+ fs.writeFileSync(tmpPath, data, options);
217
+ fs.renameSync(tmpPath, filePath);
218
+ }
219
+ function pauseNode9(durationMs, durationStr) {
220
+ const state = { expiry: Date.now() + durationMs, duration: durationStr };
221
+ atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
222
+ }
223
+ function resumeNode9() {
224
+ try {
225
+ if (fs.existsSync(PAUSED_FILE)) fs.unlinkSync(PAUSED_FILE);
226
+ } catch {
227
+ }
228
+ }
229
+ function getActiveTrustSession(toolName) {
230
+ try {
231
+ if (!fs.existsSync(TRUST_FILE)) return false;
232
+ const trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
233
+ const now = Date.now();
234
+ const active = trust.entries.filter((e) => e.expiry > now);
235
+ if (active.length !== trust.entries.length) {
236
+ fs.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
237
+ }
238
+ return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+ function writeTrustSession(toolName, durationMs) {
244
+ try {
245
+ let trust = { entries: [] };
246
+ try {
247
+ if (fs.existsSync(TRUST_FILE)) {
248
+ trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
249
+ }
250
+ } catch {
251
+ }
252
+ const now = Date.now();
253
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
254
+ trust.entries.push({ tool: toolName, expiry: now + durationMs });
255
+ atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
256
+ } catch (err) {
257
+ if (process.env.NODE9_DEBUG === "1") {
258
+ console.error("[Node9 Trust Error]:", err);
259
+ }
260
+ }
261
+ }
262
+ function appendAuditModeEntry(toolName, args) {
263
+ try {
264
+ const entry = JSON.stringify({
265
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
266
+ tool: toolName,
267
+ args,
268
+ decision: "would-have-blocked",
269
+ source: "audit-mode"
270
+ });
271
+ const logPath = path.join(os.homedir(), ".node9", "audit.log");
272
+ const dir = path.dirname(logPath);
273
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
274
+ fs.appendFileSync(logPath, entry + "\n");
275
+ } catch {
276
+ }
277
+ }
14
278
  var DANGEROUS_WORDS = [
15
279
  "delete",
16
280
  "drop",
@@ -28,10 +292,6 @@ var DANGEROUS_WORDS = [
28
292
  function tokenize(toolName) {
29
293
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
30
294
  }
31
- function containsDangerousWord(toolName, dangerousWords) {
32
- const tokens = tokenize(toolName);
33
- return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
34
- }
35
295
  function matchesPattern(text, patterns) {
36
296
  const p = Array.isArray(patterns) ? patterns : [patterns];
37
297
  if (p.length === 0) return false;
@@ -42,9 +302,9 @@ function matchesPattern(text, patterns) {
42
302
  const withoutDotSlash = text.replace(/^\.\//, "");
43
303
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
44
304
  }
45
- function getNestedValue(obj, path5) {
305
+ function getNestedValue(obj, path6) {
46
306
  if (!obj || typeof obj !== "object") return null;
47
- return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
307
+ return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
48
308
  }
49
309
  function extractShellCommand(toolName, args, toolInspection) {
50
310
  const patterns = Object.keys(toolInspection);
@@ -136,8 +396,15 @@ function redactSecrets(text) {
136
396
  return redacted;
137
397
  }
138
398
  var DEFAULT_CONFIG = {
139
- settings: { mode: "standard" },
399
+ settings: {
400
+ mode: "standard",
401
+ autoStartDaemon: true,
402
+ enableUndo: false,
403
+ enableHookLogDebug: false,
404
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
405
+ },
140
406
  policy: {
407
+ sandboxPaths: [],
141
408
  dangerousWords: DANGEROUS_WORDS,
142
409
  ignoredTools: [
143
410
  "list_*",
@@ -145,34 +412,19 @@ var DEFAULT_CONFIG = {
145
412
  "read_*",
146
413
  "describe_*",
147
414
  "read",
148
- "write",
149
- "edit",
150
- "multiedit",
151
- "glob",
152
415
  "grep",
153
416
  "ls",
154
- "notebookread",
155
- "notebookedit",
156
- "todoread",
157
- "todowrite",
158
- "webfetch",
159
- "websearch",
160
- "exitplanmode",
161
417
  "askuserquestion"
162
418
  ],
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
- ]
419
+ toolInspection: { bash: "command", shell: "command" },
420
+ rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
172
421
  },
173
422
  environments: {}
174
423
  };
175
424
  var cachedConfig = null;
425
+ function _resetConfigCache() {
426
+ cachedConfig = null;
427
+ }
176
428
  function getGlobalSettings() {
177
429
  try {
178
430
  const globalConfigPath = path.join(os.homedir(), ".node9", "config.json");
@@ -183,18 +435,19 @@ function getGlobalSettings() {
183
435
  mode: settings.mode || "standard",
184
436
  autoStartDaemon: settings.autoStartDaemon !== false,
185
437
  slackEnabled: settings.slackEnabled !== false,
186
- // agentMode defaults to false — user must explicitly opt in via `node9 login`
187
- agentMode: settings.agentMode === true
438
+ enableTrustSessions: settings.enableTrustSessions === true,
439
+ allowGlobalPause: settings.allowGlobalPause !== false
188
440
  };
189
441
  }
190
442
  } catch {
191
443
  }
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;
444
+ return {
445
+ mode: "standard",
446
+ autoStartDaemon: true,
447
+ slackEnabled: true,
448
+ enableTrustSessions: false,
449
+ allowGlobalPause: true
450
+ };
198
451
  }
199
452
  function getInternalToken() {
200
453
  try {
@@ -207,51 +460,83 @@ function getInternalToken() {
207
460
  return null;
208
461
  }
209
462
  }
210
- async function evaluatePolicy(toolName, args) {
463
+ async function evaluatePolicy(toolName, args, agent) {
211
464
  const config = getConfig();
212
- if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
465
+ if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
466
+ let allTokens = [];
467
+ let actionTokens = [];
468
+ let pathTokens = [];
213
469
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
214
470
  if (shellCommand) {
215
- const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
471
+ const analyzed = await analyzeShellCommand(shellCommand);
472
+ allTokens = analyzed.allTokens;
473
+ actionTokens = analyzed.actions;
474
+ pathTokens = analyzed.paths;
216
475
  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
- }
476
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
477
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
232
478
  }
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
- })
479
+ } else {
480
+ allTokens = tokenize(toolName);
481
+ actionTokens = [toolName];
482
+ }
483
+ const isManual = agent === "Terminal";
484
+ if (isManual) {
485
+ const NUCLEAR_COMMANDS = [
486
+ "drop",
487
+ "destroy",
488
+ "purge",
489
+ "rmdir",
490
+ "format",
491
+ "truncate",
492
+ "alter",
493
+ "grant",
494
+ "revoke",
495
+ "docker"
496
+ ];
497
+ const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
498
+ if (!hasNuclear) return { decision: "allow" };
499
+ }
500
+ if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
501
+ const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
502
+ if (allInSandbox) return { decision: "allow" };
503
+ }
504
+ for (const action of actionTokens) {
505
+ const rule = config.policy.rules.find(
506
+ (r) => r.action === action || matchesPattern(action, r.action)
243
507
  );
244
- if (isDangerous2) return "review";
245
- if (config.settings.mode === "strict") return "review";
246
- return "allow";
508
+ if (rule) {
509
+ if (pathTokens.length > 0) {
510
+ const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
511
+ if (anyBlocked)
512
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
513
+ const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
514
+ if (allAllowed) return { decision: "allow" };
515
+ }
516
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
517
+ }
247
518
  }
248
- const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
249
- if (isDangerous || config.settings.mode === "strict") {
519
+ const isDangerous = allTokens.some(
520
+ (token) => config.policy.dangerousWords.some((word) => {
521
+ const w = word.toLowerCase();
522
+ if (token === w) return true;
523
+ try {
524
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
525
+ } catch {
526
+ return false;
527
+ }
528
+ })
529
+ );
530
+ if (isDangerous) {
531
+ const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
532
+ return { decision: "review", blockedByLabel: label };
533
+ }
534
+ if (config.settings.mode === "strict") {
250
535
  const envConfig = getActiveEnvironment(config);
251
- if (envConfig?.requireApproval === false) return "allow";
252
- return "review";
536
+ if (envConfig?.requireApproval === false) return { decision: "allow" };
537
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
253
538
  }
254
- return "allow";
539
+ return { decision: "allow" };
255
540
  }
256
541
  function isIgnoredTool(toolName) {
257
542
  const config = getConfig();
@@ -282,22 +567,40 @@ function getPersistentDecision(toolName) {
282
567
  }
283
568
  return null;
284
569
  }
285
- async function askDaemon(toolName, args, meta) {
570
+ async function askDaemon(toolName, args, meta, signal) {
286
571
  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";
572
+ const checkCtrl = new AbortController();
573
+ const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
574
+ const onAbort = () => checkCtrl.abort();
575
+ if (signal) signal.addEventListener("abort", onAbort);
576
+ try {
577
+ const checkRes = await fetch(`${base}/check`, {
578
+ method: "POST",
579
+ headers: { "Content-Type": "application/json" },
580
+ body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
581
+ signal: checkCtrl.signal
582
+ });
583
+ if (!checkRes.ok) throw new Error("Daemon fail");
584
+ const { id } = await checkRes.json();
585
+ const waitCtrl = new AbortController();
586
+ const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
587
+ const onWaitAbort = () => waitCtrl.abort();
588
+ if (signal) signal.addEventListener("abort", onWaitAbort);
589
+ try {
590
+ const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
591
+ if (!waitRes.ok) return "deny";
592
+ const { decision } = await waitRes.json();
593
+ if (decision === "allow") return "allow";
594
+ if (decision === "abandoned") return "abandoned";
595
+ return "deny";
596
+ } finally {
597
+ clearTimeout(waitTimer);
598
+ if (signal) signal.removeEventListener("abort", onWaitAbort);
599
+ }
600
+ } finally {
601
+ clearTimeout(checkTimer);
602
+ if (signal) signal.removeEventListener("abort", onAbort);
603
+ }
301
604
  }
302
605
  async function notifyDaemonViewer(toolName, args, meta) {
303
606
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
@@ -327,176 +630,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
327
630
  });
328
631
  }
329
632
  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" };
633
+ if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
634
+ const pauseState = checkPause();
635
+ if (pauseState.paused) return { approved: true, checkedBy: "paused" };
636
+ const creds = getCredentials();
637
+ const config = getConfig();
638
+ const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
639
+ const approvers = {
640
+ ...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
641
+ };
642
+ if (isTestEnv2) {
643
+ approvers.native = false;
644
+ approvers.browser = false;
645
+ approvers.terminal = false;
646
+ }
647
+ const isManual = meta?.agent === "Terminal";
648
+ let explainableLabel = "Local Config";
649
+ if (config.settings.mode === "audit") {
650
+ if (!isIgnoredTool(toolName)) {
651
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
652
+ if (policyResult.decision === "review") {
653
+ appendAuditModeEntry(toolName, args);
654
+ sendDesktopNotification(
655
+ "Node9 Audit Mode",
656
+ `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
657
+ );
658
+ }
659
+ }
660
+ return { approved: true, checkedBy: "audit" };
661
+ }
662
+ if (!isIgnoredTool(toolName)) {
663
+ if (getActiveTrustSession(toolName)) {
664
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
665
+ return { approved: true, checkedBy: "trust" };
666
+ }
667
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
668
+ if (policyResult.decision === "allow") {
669
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
670
+ return { approved: true, checkedBy: "local-policy" };
671
+ }
672
+ explainableLabel = policyResult.blockedByLabel || "Local Config";
336
673
  const persistent = getPersistentDecision(toolName);
337
- if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
338
- if (persistent === "deny")
674
+ if (persistent === "allow") {
675
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
676
+ return { approved: true, checkedBy: "persistent" };
677
+ }
678
+ if (persistent === "deny") {
339
679
  return {
340
680
  approved: false,
341
- reason: `Node9: "${toolName}" is set to always deny.`,
681
+ reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
342
682
  blockedBy: "persistent-deny",
343
- changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
683
+ blockedByLabel: "Persistent User Rule"
344
684
  };
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
685
  }
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
- }
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
- };
686
+ } else {
687
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
688
+ return { approved: true };
364
689
  }
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
- `));
690
+ let cloudRequestId = null;
691
+ let isRemoteLocked = false;
692
+ const cloudEnforced = approvers.cloud && !!creds?.apiKey;
693
+ if (cloudEnforced) {
369
694
  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 {
695
+ const envConfig = getActiveEnvironment(getConfig());
696
+ const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
697
+ if (!initResult.pending) {
374
698
  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
699
+ approved: !!initResult.approved,
700
+ reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
701
+ checkedBy: initResult.approved ? "cloud" : void 0,
702
+ blockedBy: initResult.approved ? void 0 : "team-policy",
703
+ blockedByLabel: "Organization Policy (SaaS)"
380
704
  };
381
705
  }
382
- } catch {
706
+ cloudRequestId = initResult.requestId || null;
707
+ isRemoteLocked = !!initResult.remoteApprovalOnly;
708
+ explainableLabel = "Organization Policy (SaaS)";
709
+ } catch (err) {
710
+ const error = err;
711
+ const isAuthError = error.message.includes("401") || error.message.includes("403");
712
+ const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
713
+ 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;
714
+ console.error(
715
+ chalk.yellow(`
716
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk.dim(`
717
+ Falling back to local rules...
718
+ `)
719
+ );
383
720
  }
384
721
  }
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)}`
722
+ if (cloudEnforced && cloudRequestId) {
723
+ console.error(
724
+ chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
725
+ );
726
+ console.error(chalk.cyan(" Dashboard \u2192 ") + chalk.bold("Mission Control > Activity Feed\n"));
727
+ } else if (!cloudEnforced) {
728
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
729
+ console.error(
730
+ chalk.dim(`
731
+ \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
732
+ `)
392
733
  );
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
734
  }
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 {
735
+ const abortController = new AbortController();
736
+ const { signal } = abortController;
737
+ const racePromises = [];
738
+ let viewerId = null;
739
+ const internalToken = getInternalToken();
740
+ if (cloudEnforced && cloudRequestId) {
741
+ racePromises.push(
742
+ (async () => {
743
+ try {
744
+ if (isDaemonRunning() && internalToken) {
745
+ viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
746
+ }
747
+ const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
748
+ return {
749
+ approved: cloudResult.approved,
750
+ reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
751
+ checkedBy: cloudResult.approved ? "cloud" : void 0,
752
+ blockedBy: cloudResult.approved ? void 0 : "team-policy",
753
+ blockedByLabel: "Organization Policy (SaaS)"
754
+ };
755
+ } catch (err) {
756
+ const error = err;
757
+ if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
758
+ throw err;
759
+ }
760
+ })()
761
+ );
762
+ }
763
+ if (approvers.native && !isManual) {
764
+ racePromises.push(
765
+ (async () => {
766
+ const decision = await askNativePopup(
767
+ toolName,
768
+ args,
769
+ meta?.agent,
770
+ explainableLabel,
771
+ isRemoteLocked,
772
+ signal
773
+ );
774
+ if (decision === "always_allow") {
775
+ writeTrustSession(toolName, 36e5);
776
+ return { approved: true, checkedBy: "trust" };
777
+ }
778
+ const isApproved = decision === "allow";
779
+ return {
780
+ approved: isApproved,
781
+ reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
782
+ checkedBy: isApproved ? "daemon" : void 0,
783
+ blockedBy: isApproved ? void 0 : "local-decision",
784
+ blockedByLabel: "User Decision (Native)"
785
+ };
786
+ })()
787
+ );
788
+ }
789
+ if (approvers.browser && isDaemonRunning()) {
790
+ racePromises.push(
791
+ (async () => {
792
+ try {
793
+ if (!approvers.native && !cloudEnforced) {
794
+ console.error(
795
+ chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
796
+ );
797
+ console.error(chalk.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
798
+ `));
799
+ }
800
+ const daemonDecision = await askDaemon(toolName, args, meta, signal);
801
+ if (daemonDecision === "abandoned") throw new Error("Abandoned");
802
+ const isApproved = daemonDecision === "allow";
803
+ return {
804
+ approved: isApproved,
805
+ reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
806
+ checkedBy: isApproved ? "daemon" : void 0,
807
+ blockedBy: isApproved ? void 0 : "local-decision",
808
+ blockedByLabel: "User Decision (Browser)"
809
+ };
810
+ } catch (err) {
811
+ throw err;
812
+ }
813
+ })()
814
+ );
815
+ }
816
+ if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
817
+ racePromises.push(
818
+ (async () => {
819
+ try {
820
+ console.log(chalk.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
821
+ console.log(`${chalk.bold("Action:")} ${chalk.red(toolName)}`);
822
+ console.log(`${chalk.bold("Flagged By:")} ${chalk.yellow(explainableLabel)}`);
823
+ if (isRemoteLocked) {
824
+ console.log(chalk.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
825
+ `));
826
+ await new Promise((_, reject) => {
827
+ signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
828
+ });
829
+ }
830
+ const TIMEOUT_MS = 6e4;
831
+ let timer;
832
+ const result = await new Promise((resolve, reject) => {
833
+ timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
834
+ confirm(
835
+ { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
836
+ { signal }
837
+ ).then(resolve).catch(reject);
838
+ });
839
+ clearTimeout(timer);
840
+ return {
841
+ approved: result,
842
+ reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
843
+ checkedBy: result ? "terminal" : void 0,
844
+ blockedBy: result ? void 0 : "local-decision",
845
+ blockedByLabel: "User Decision (Terminal)"
846
+ };
847
+ } catch (err) {
848
+ const error = err;
849
+ if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
850
+ throw err;
851
+ if (error.message === "Terminal Timeout") {
852
+ return {
853
+ approved: false,
854
+ reason: "The terminal prompt timed out without a human response.",
855
+ blockedBy: "local-decision"
856
+ };
857
+ }
858
+ throw err;
859
+ }
860
+ })()
861
+ );
862
+ }
863
+ if (racePromises.length === 0) {
864
+ return {
865
+ approved: false,
866
+ noApprovalMechanism: true,
867
+ reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
868
+ REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
869
+ blockedBy: "no-approval-mechanism",
870
+ blockedByLabel: explainableLabel
871
+ };
425
872
  }
426
- return [];
873
+ const finalResult = await new Promise((resolve) => {
874
+ let resolved = false;
875
+ let failures = 0;
876
+ const total = racePromises.length;
877
+ const finish = (res) => {
878
+ if (!resolved) {
879
+ resolved = true;
880
+ abortController.abort();
881
+ if (viewerId && internalToken) {
882
+ resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
883
+ () => null
884
+ );
885
+ }
886
+ resolve(res);
887
+ }
888
+ };
889
+ for (const p of racePromises) {
890
+ p.then(finish).catch((err) => {
891
+ if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
892
+ return;
893
+ if (err.message === "Abandoned") {
894
+ finish({
895
+ approved: false,
896
+ reason: "Browser dashboard closed without making a decision.",
897
+ blockedBy: "local-decision",
898
+ blockedByLabel: "Browser Dashboard (Abandoned)"
899
+ });
900
+ return;
901
+ }
902
+ failures++;
903
+ if (failures === total && !resolved) {
904
+ finish({ approved: false, reason: "All approval channels failed or disconnected." });
905
+ }
906
+ });
907
+ }
908
+ });
909
+ if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
910
+ await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
911
+ }
912
+ return finalResult;
427
913
  }
428
914
  function getConfig() {
429
915
  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;
916
+ const globalPath = path.join(os.homedir(), ".node9", "config.json");
917
+ const projectPath = path.join(process.cwd(), "node9.config.json");
918
+ const globalConfig = tryLoadConfig(globalPath);
919
+ const projectConfig = tryLoadConfig(projectPath);
920
+ const mergedSettings = {
921
+ ...DEFAULT_CONFIG.settings,
922
+ approvers: { ...DEFAULT_CONFIG.settings.approvers }
923
+ };
924
+ const mergedPolicy = {
925
+ sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
926
+ dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
927
+ ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
928
+ toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
929
+ rules: [...DEFAULT_CONFIG.policy.rules]
930
+ };
931
+ const applyLayer = (source) => {
932
+ if (!source) return;
933
+ const s = source.settings || {};
934
+ const p = source.policy || {};
935
+ if (s.mode !== void 0) mergedSettings.mode = s.mode;
936
+ if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
937
+ if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
938
+ if (s.enableHookLogDebug !== void 0)
939
+ mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
940
+ if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
941
+ if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
942
+ if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
943
+ if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
944
+ if (p.toolInspection)
945
+ mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
946
+ if (p.rules) mergedPolicy.rules.push(...p.rules);
947
+ };
948
+ applyLayer(globalConfig);
949
+ applyLayer(projectConfig);
950
+ if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
951
+ mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
952
+ mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
953
+ mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
954
+ cachedConfig = {
955
+ settings: mergedSettings,
956
+ policy: mergedPolicy,
957
+ environments: {}
958
+ };
441
959
  return cachedConfig;
442
960
  }
443
961
  function tryLoadConfig(filePath) {
444
962
  if (!fs.existsSync(filePath)) return null;
445
963
  try {
446
- const config = JSON.parse(fs.readFileSync(filePath, "utf-8"));
447
- validateConfig(config, filePath);
448
- return config;
964
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
449
965
  } catch {
450
966
  return null;
451
967
  }
452
968
  }
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
969
  function getActiveEnvironment(config) {
478
970
  const env = process.env.NODE_ENV || "development";
479
971
  return config.environments[env] ?? null;
480
972
  }
481
973
  function getCredentials() {
482
974
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
483
- if (process.env.NODE9_API_KEY)
975
+ if (process.env.NODE9_API_KEY) {
484
976
  return {
485
977
  apiKey: process.env.NODE9_API_KEY,
486
978
  apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
487
979
  };
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
980
  }
501
981
  try {
502
982
  const credPath = path.join(os.homedir(), ".node9", "credentials.json");
@@ -521,14 +1001,32 @@ function getCredentials() {
521
1001
  }
522
1002
  return null;
523
1003
  }
524
- async function authorizeAction(toolName, args) {
525
- const result = await authorizeHeadless(toolName, args, true);
526
- return result.approved;
1004
+ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1005
+ const controller = new AbortController();
1006
+ setTimeout(() => controller.abort(), 5e3);
1007
+ fetch(`${creds.apiUrl}/audit`, {
1008
+ method: "POST",
1009
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1010
+ body: JSON.stringify({
1011
+ toolName,
1012
+ args,
1013
+ checkedBy,
1014
+ context: {
1015
+ agent: meta?.agent,
1016
+ mcpServer: meta?.mcpServer,
1017
+ hostname: os.hostname(),
1018
+ cwd: process.cwd(),
1019
+ platform: os.platform()
1020
+ }
1021
+ }),
1022
+ signal: controller.signal
1023
+ }).catch(() => {
1024
+ });
527
1025
  }
528
- async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
1026
+ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
1027
+ const controller = new AbortController();
1028
+ const timeout = setTimeout(() => controller.abort(), 1e4);
529
1029
  try {
530
- const controller = new AbortController();
531
- const timeout = setTimeout(() => controller.abort(), 35e3);
532
1030
  const response = await fetch(creds.apiUrl, {
533
1031
  method: "POST",
534
1032
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
@@ -546,46 +1044,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
546
1044
  }),
547
1045
  signal: controller.signal
548
1046
  });
1047
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1048
+ return await response.json();
1049
+ } finally {
549
1050
  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 {
1051
+ }
1052
+ }
1053
+ async function pollNode9SaaS(requestId, creds, signal) {
1054
+ const statusUrl = `${creds.apiUrl}/status/${requestId}`;
1055
+ const POLL_INTERVAL_MS = 1e3;
1056
+ const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
1057
+ while (Date.now() < POLL_DEADLINE) {
1058
+ if (signal.aborted) throw new Error("Aborted");
1059
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1060
+ try {
1061
+ const pollCtrl = new AbortController();
1062
+ const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
1063
+ const statusRes = await fetch(statusUrl, {
1064
+ headers: { Authorization: `Bearer ${creds.apiKey}` },
1065
+ signal: pollCtrl.signal
1066
+ });
1067
+ clearTimeout(pollTimer);
1068
+ if (!statusRes.ok) continue;
1069
+ const { status, reason } = await statusRes.json();
1070
+ if (status === "APPROVED") {
1071
+ console.error(chalk.green("\u2705 Approved via Cloud.\n"));
1072
+ return { approved: true, reason };
1073
+ }
1074
+ if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
1075
+ console.error(chalk.red("\u274C Denied via Cloud.\n"));
1076
+ return { approved: false, reason };
583
1077
  }
1078
+ } catch {
584
1079
  }
585
- console.error(chalk.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
586
- return false;
1080
+ }
1081
+ return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
1082
+ }
1083
+ async function resolveNode9SaaS(requestId, creds, approved) {
1084
+ try {
1085
+ const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
1086
+ const ctrl = new AbortController();
1087
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
1088
+ await fetch(resolveUrl, {
1089
+ method: "PATCH",
1090
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1091
+ body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
1092
+ signal: ctrl.signal
1093
+ });
1094
+ clearTimeout(timer);
587
1095
  } catch {
588
- return false;
589
1096
  }
590
1097
  }
591
1098
 
@@ -597,7 +1104,7 @@ import chalk2 from "chalk";
597
1104
  import { confirm as confirm2 } from "@inquirer/prompts";
598
1105
  function printDaemonTip() {
599
1106
  console.log(
600
- chalk2.cyan("\n \u{1F4A1} Enable browser approvals (no API key needed):") + chalk2.green(" node9 daemon --background")
1107
+ chalk2.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + chalk2.white("\n To view your history or manage persistent rules, run:") + chalk2.green("\n node9 daemon --openui")
601
1108
  );
602
1109
  }
603
1110
  function fullPathCommand(subcommand) {
@@ -648,7 +1155,7 @@ async function setupClaude() {
648
1155
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
649
1156
  settings.hooks.PostToolUse.push({
650
1157
  matcher: ".*",
651
- hooks: [{ type: "command", command: fullPathCommand("log") }]
1158
+ hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
652
1159
  });
653
1160
  console.log(chalk2.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
654
1161
  anythingChanged = true;
@@ -712,7 +1219,12 @@ async function setupGemini() {
712
1219
  settings.hooks.BeforeTool.push({
713
1220
  matcher: ".*",
714
1221
  hooks: [
715
- { name: "node9-check", type: "command", command: fullPathCommand("check"), timeout: 6e4 }
1222
+ {
1223
+ name: "node9-check",
1224
+ type: "command",
1225
+ command: fullPathCommand("check"),
1226
+ timeout: 6e5
1227
+ }
716
1228
  ]
717
1229
  });
718
1230
  console.log(chalk2.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
@@ -1109,6 +1621,27 @@ var ui_default = `<!doctype html>
1109
1621
  font-size: 12px;
1110
1622
  font-weight: 500;
1111
1623
  }
1624
+ .btn-trust {
1625
+ background: rgba(240, 136, 62, 0.1);
1626
+ border: 1px solid rgba(240, 136, 62, 0.35);
1627
+ color: var(--primary);
1628
+ font-size: 12px;
1629
+ font-weight: 600;
1630
+ }
1631
+ .btn-trust:hover:not(:disabled) {
1632
+ background: rgba(240, 136, 62, 0.2);
1633
+ filter: none;
1634
+ transform: translateY(-1px);
1635
+ }
1636
+ .trust-row {
1637
+ display: none;
1638
+ grid-column: span 2;
1639
+ grid-template-columns: 1fr 1fr;
1640
+ gap: 8px;
1641
+ }
1642
+ .trust-row.show {
1643
+ display: grid;
1644
+ }
1112
1645
  button:hover:not(:disabled) {
1113
1646
  filter: brightness(1.15);
1114
1647
  transform: translateY(-1px);
@@ -1412,15 +1945,31 @@ var ui_default = `<!doctype html>
1412
1945
  <span class="slider"></span>
1413
1946
  </label>
1414
1947
  </div>
1948
+ <div class="setting-row">
1949
+ <div class="setting-text">
1950
+ <div class="setting-label">Trust Sessions</div>
1951
+ <div class="setting-desc">
1952
+ Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
1953
+ </div>
1954
+ </div>
1955
+ <label class="toggle">
1956
+ <input
1957
+ type="checkbox"
1958
+ id="trustSessionsToggle"
1959
+ onchange="onTrustToggle(this.checked)"
1960
+ />
1961
+ <span class="slider"></span>
1962
+ </label>
1963
+ </div>
1415
1964
  </div>
1416
1965
 
1417
1966
  <div class="panel">
1418
- <div class="panel-title">\u{1F4AC} Slack Approvals</div>
1967
+ <div class="panel-title">\u{1F4AC} Cloud Approvals</div>
1419
1968
  <div class="setting-row">
1420
1969
  <div class="setting-text">
1421
- <div class="setting-label">Enable Slack</div>
1970
+ <div class="setting-label">Enable Cloud</div>
1422
1971
  <div class="setting-desc">
1423
- Use Slack as the approval authority when a key is saved.
1972
+ Use Cloud/Slack as the approval authority when a key is saved.
1424
1973
  </div>
1425
1974
  </div>
1426
1975
  <label class="toggle">
@@ -1474,6 +2023,7 @@ var ui_default = `<!doctype html>
1474
2023
  const requests = new Set();
1475
2024
  let orgName = null;
1476
2025
  let autoDenyMs = 120000;
2026
+ let trustEnabled = false;
1477
2027
 
1478
2028
  function highlightSyntax(code) {
1479
2029
  if (typeof code !== 'string') return esc(code);
@@ -1526,6 +2076,21 @@ var ui_default = `<!doctype html>
1526
2076
  }, 200);
1527
2077
  }
1528
2078
 
2079
+ function sendTrust(id, duration) {
2080
+ const card = document.getElementById('c-' + id);
2081
+ if (card) card.style.opacity = '0.5';
2082
+ fetch('/decision/' + id, {
2083
+ method: 'POST',
2084
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
2085
+ body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
2086
+ });
2087
+ setTimeout(() => {
2088
+ card?.remove();
2089
+ requests.delete(id);
2090
+ refresh();
2091
+ }, 200);
2092
+ }
2093
+
1529
2094
  function addCard(req) {
1530
2095
  if (requests.has(req.id)) return;
1531
2096
  requests.add(req.id);
@@ -1545,6 +2110,7 @@ var ui_default = `<!doctype html>
1545
2110
  card.id = 'c-' + req.id;
1546
2111
  const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
1547
2112
  const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
2113
+ const dis = isSlack ? 'disabled' : '';
1548
2114
  card.innerHTML = \`
1549
2115
  <div class="source-row">
1550
2116
  <span class="agent-badge">\${agentLabel}</span>
@@ -1554,11 +2120,15 @@ var ui_default = `<!doctype html>
1554
2120
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
1555
2121
  <span class="label">Input Payload</span>
1556
2122
  <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>
2123
+ <div class="actions" id="act-\${req.id}">
2124
+ <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
2125
+ <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
2126
+ <div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
2127
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
2128
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
2129
+ </div>
2130
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
2131
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
1562
2132
  </div>
1563
2133
  \`;
1564
2134
  list.appendChild(card);
@@ -1590,8 +2160,10 @@ var ui_default = `<!doctype html>
1590
2160
  autoDenyMs = data.autoDenyMs;
1591
2161
  if (orgName) {
1592
2162
  const b = document.getElementById('cloudBadge');
1593
- b.innerText = orgName;
1594
- b.classList.add('online');
2163
+ if (b) {
2164
+ b.innerText = orgName;
2165
+ b.classList.add('online');
2166
+ }
1595
2167
  }
1596
2168
  data.requests.forEach(addCard);
1597
2169
  });
@@ -1621,6 +2193,14 @@ var ui_default = `<!doctype html>
1621
2193
  }).catch(() => {});
1622
2194
  }
1623
2195
 
2196
+ function onTrustToggle(checked) {
2197
+ trustEnabled = checked;
2198
+ saveSetting('enableTrustSessions', checked);
2199
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2200
+ el.classList.toggle('show', checked);
2201
+ });
2202
+ }
2203
+
1624
2204
  fetch('/settings')
1625
2205
  .then((r) => r.json())
1626
2206
  .then((s) => {
@@ -1633,6 +2213,13 @@ var ui_default = `<!doctype html>
1633
2213
  if (!s.autoStartDaemon && !s.autoStarted) {
1634
2214
  document.getElementById('warnBanner').classList.add('show');
1635
2215
  }
2216
+ trustEnabled = !!s.enableTrustSessions;
2217
+ const trustTog = document.getElementById('trustSessionsToggle');
2218
+ if (trustTog) trustTog.checked = trustEnabled;
2219
+ // Show/hide trust rows on any cards already rendered
2220
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2221
+ el.classList.toggle('show', trustEnabled);
2222
+ });
1636
2223
  })
1637
2224
  .catch(() => {});
1638
2225
 
@@ -1742,7 +2329,7 @@ import http from "http";
1742
2329
  import fs3 from "fs";
1743
2330
  import path3 from "path";
1744
2331
  import os3 from "os";
1745
- import { execSync } from "child_process";
2332
+ import { spawn as spawn2 } from "child_process";
1746
2333
  import { randomUUID } from "crypto";
1747
2334
  import chalk3 from "chalk";
1748
2335
  var DAEMON_PORT2 = 7391;
@@ -1753,6 +2340,33 @@ var DECISIONS_FILE = path3.join(homeDir, ".node9", "decisions.json");
1753
2340
  var GLOBAL_CONFIG_FILE = path3.join(homeDir, ".node9", "config.json");
1754
2341
  var CREDENTIALS_FILE = path3.join(homeDir, ".node9", "credentials.json");
1755
2342
  var AUDIT_LOG_FILE = path3.join(homeDir, ".node9", "audit.log");
2343
+ var TRUST_FILE2 = path3.join(homeDir, ".node9", "trust.json");
2344
+ function atomicWriteSync2(filePath, data, options) {
2345
+ const dir = path3.dirname(filePath);
2346
+ if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
2347
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
2348
+ fs3.writeFileSync(tmpPath, data, options);
2349
+ fs3.renameSync(tmpPath, filePath);
2350
+ }
2351
+ function writeTrustEntry(toolName, durationMs) {
2352
+ try {
2353
+ let trust = { entries: [] };
2354
+ try {
2355
+ if (fs3.existsSync(TRUST_FILE2))
2356
+ trust = JSON.parse(fs3.readFileSync(TRUST_FILE2, "utf-8"));
2357
+ } catch {
2358
+ }
2359
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
2360
+ trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
2361
+ atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
2362
+ } catch {
2363
+ }
2364
+ }
2365
+ var TRUST_DURATIONS = {
2366
+ "30m": 30 * 6e4,
2367
+ "1h": 60 * 6e4,
2368
+ "2h": 2 * 60 * 6e4
2369
+ };
1756
2370
  var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
1757
2371
  function redactArgs(value) {
1758
2372
  if (!value || typeof value !== "object") return value;
@@ -1765,10 +2379,16 @@ function redactArgs(value) {
1765
2379
  }
1766
2380
  function appendAuditLog(data) {
1767
2381
  try {
1768
- const entry = JSON.stringify({ ...data, args: redactArgs(data.args) }) + "\n";
2382
+ const entry = {
2383
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2384
+ tool: data.toolName,
2385
+ args: redactArgs(data.args),
2386
+ decision: data.decision,
2387
+ source: "daemon"
2388
+ };
1769
2389
  const dir = path3.dirname(AUDIT_LOG_FILE);
1770
2390
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
1771
- fs3.appendFileSync(AUDIT_LOG_FILE, entry);
2391
+ fs3.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
1772
2392
  } catch {
1773
2393
  }
1774
2394
  }
@@ -1793,21 +2413,6 @@ function getOrgName() {
1793
2413
  return null;
1794
2414
  }
1795
2415
  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
2416
  function hasStoredSlackKey() {
1812
2417
  return fs3.existsSync(CREDENTIALS_FILE);
1813
2418
  }
@@ -1821,14 +2426,13 @@ function writeGlobalSetting(key, value) {
1821
2426
  }
1822
2427
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
1823
2428
  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 });
2429
+ atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
1827
2430
  }
1828
2431
  var pending = /* @__PURE__ */ new Map();
1829
2432
  var sseClients = /* @__PURE__ */ new Set();
1830
2433
  var abandonTimer = null;
1831
2434
  var daemonServer = null;
2435
+ var hadBrowserClient = false;
1832
2436
  function abandonPending() {
1833
2437
  abandonTimer = null;
1834
2438
  pending.forEach((entry, id) => {
@@ -1864,10 +2468,8 @@ data: ${JSON.stringify(data)}
1864
2468
  }
1865
2469
  function openBrowser(url) {
1866
2470
  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);
2471
+ const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
2472
+ spawn2(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
1871
2473
  } catch {
1872
2474
  }
1873
2475
  }
@@ -1889,11 +2491,9 @@ function readPersistentDecisions() {
1889
2491
  }
1890
2492
  function writePersistentDecision(toolName, decision) {
1891
2493
  try {
1892
- const dir = path3.dirname(DECISIONS_FILE);
1893
- if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
1894
2494
  const decisions = readPersistentDecisions();
1895
2495
  decisions[toolName] = decision;
1896
- fs3.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2496
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
1897
2497
  broadcast("decisions", decisions);
1898
2498
  } catch {
1899
2499
  }
@@ -1903,6 +2503,22 @@ function startDaemon() {
1903
2503
  const internalToken = randomUUID();
1904
2504
  const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
1905
2505
  const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
2506
+ const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
2507
+ let idleTimer;
2508
+ function resetIdleTimer() {
2509
+ if (idleTimer) clearTimeout(idleTimer);
2510
+ idleTimer = setTimeout(() => {
2511
+ if (autoStarted) {
2512
+ try {
2513
+ fs3.unlinkSync(DAEMON_PID_FILE);
2514
+ } catch {
2515
+ }
2516
+ }
2517
+ process.exit(0);
2518
+ }, IDLE_TIMEOUT_MS);
2519
+ idleTimer.unref();
2520
+ }
2521
+ resetIdleTimer();
1906
2522
  const server = http.createServer(async (req, res) => {
1907
2523
  const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
1908
2524
  if (req.method === "GET" && pathname === "/") {
@@ -1919,6 +2535,7 @@ function startDaemon() {
1919
2535
  clearTimeout(abandonTimer);
1920
2536
  abandonTimer = null;
1921
2537
  }
2538
+ hadBrowserClient = true;
1922
2539
  sseClients.add(res);
1923
2540
  res.write(
1924
2541
  `event: init
@@ -1945,12 +2562,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
1945
2562
  return req.on("close", () => {
1946
2563
  sseClients.delete(res);
1947
2564
  if (sseClients.size === 0 && pending.size > 0) {
1948
- abandonTimer = setTimeout(abandonPending, 2e3);
2565
+ abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
1949
2566
  }
1950
2567
  });
1951
2568
  }
1952
2569
  if (req.method === "POST" && pathname === "/check") {
1953
2570
  try {
2571
+ resetIdleTimer();
1954
2572
  const body = await readBody(req);
1955
2573
  if (body.length > 65536) return res.writeHead(413).end();
1956
2574
  const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
@@ -1971,8 +2589,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1971
2589
  appendAuditLog({
1972
2590
  toolName: e.toolName,
1973
2591
  args: e.args,
1974
- decision: "auto-deny",
1975
- timestamp: Date.now()
2592
+ decision: "auto-deny"
1976
2593
  });
1977
2594
  if (e.waiter) e.waiter("deny");
1978
2595
  else e.earlyDecision = "deny";
@@ -1990,7 +2607,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1990
2607
  agent: entry.agent,
1991
2608
  mcpServer: entry.mcpServer
1992
2609
  });
1993
- if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
2610
+ if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
1994
2611
  res.writeHead(200, { "Content-Type": "application/json" });
1995
2612
  return res.end(JSON.stringify({ id }));
1996
2613
  } catch {
@@ -2017,17 +2634,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
2017
2634
  const id = pathname.split("/").pop();
2018
2635
  const entry = pending.get(id);
2019
2636
  if (!entry) return res.writeHead(404).end();
2020
- const { decision, persist } = JSON.parse(await readBody(req));
2021
- if (persist) writePersistentDecision(entry.toolName, decision);
2637
+ const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
2638
+ if (decision === "trust" && trustDuration) {
2639
+ const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
2640
+ writeTrustEntry(entry.toolName, ms);
2641
+ appendAuditLog({
2642
+ toolName: entry.toolName,
2643
+ args: entry.args,
2644
+ decision: `trust:${trustDuration}`
2645
+ });
2646
+ clearTimeout(entry.timer);
2647
+ if (entry.waiter) entry.waiter("allow");
2648
+ else entry.earlyDecision = "allow";
2649
+ pending.delete(id);
2650
+ broadcast("remove", { id });
2651
+ res.writeHead(200);
2652
+ return res.end(JSON.stringify({ ok: true }));
2653
+ }
2654
+ const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
2655
+ if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
2022
2656
  appendAuditLog({
2023
2657
  toolName: entry.toolName,
2024
2658
  args: entry.args,
2025
- decision,
2026
- timestamp: Date.now()
2659
+ decision: resolvedDecision
2027
2660
  });
2028
2661
  clearTimeout(entry.timer);
2029
- if (entry.waiter) entry.waiter(decision);
2030
- else entry.earlyDecision = decision;
2662
+ if (entry.waiter) entry.waiter(resolvedDecision);
2663
+ else entry.earlyDecision = resolvedDecision;
2031
2664
  pending.delete(id);
2032
2665
  broadcast("remove", { id });
2033
2666
  res.writeHead(200);
@@ -2037,7 +2670,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2037
2670
  }
2038
2671
  }
2039
2672
  if (req.method === "GET" && pathname === "/settings") {
2040
- const s = readGlobalSettings();
2673
+ const s = getGlobalSettings();
2041
2674
  res.writeHead(200, { "Content-Type": "application/json" });
2042
2675
  return res.end(JSON.stringify({ ...s, autoStarted }));
2043
2676
  }
@@ -2049,7 +2682,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2049
2682
  if (data.autoStartDaemon !== void 0)
2050
2683
  writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
2051
2684
  if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
2052
- if (data.agentMode !== void 0) writeGlobalSetting("agentMode", data.agentMode);
2685
+ if (data.enableTrustSessions !== void 0)
2686
+ writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
2687
+ if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
2688
+ if (data.enableHookLogDebug !== void 0)
2689
+ writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
2690
+ if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
2053
2691
  res.writeHead(200);
2054
2692
  return res.end(JSON.stringify({ ok: true }));
2055
2693
  } catch {
@@ -2057,7 +2695,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2057
2695
  }
2058
2696
  }
2059
2697
  if (req.method === "GET" && pathname === "/slack-status") {
2060
- const s = readGlobalSettings();
2698
+ const s = getGlobalSettings();
2061
2699
  res.writeHead(200, { "Content-Type": "application/json" });
2062
2700
  return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
2063
2701
  }
@@ -2065,14 +2703,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2065
2703
  if (!validToken(req)) return res.writeHead(403).end();
2066
2704
  try {
2067
2705
  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(
2706
+ atomicWriteSync2(
2071
2707
  CREDENTIALS_FILE,
2072
2708
  JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
2073
2709
  { mode: 384 }
2074
2710
  );
2075
- broadcast("slack-status", { hasKey: true, enabled: readGlobalSettings().slackEnabled });
2711
+ broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
2076
2712
  res.writeHead(200);
2077
2713
  return res.end(JSON.stringify({ ok: true }));
2078
2714
  } catch {
@@ -2085,7 +2721,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2085
2721
  const toolName = decodeURIComponent(pathname.split("/").pop());
2086
2722
  const decisions = readPersistentDecisions();
2087
2723
  delete decisions[toolName];
2088
- fs3.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2724
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2089
2725
  broadcast("decisions", decisions);
2090
2726
  res.writeHead(200);
2091
2727
  return res.end(JSON.stringify({ ok: true }));
@@ -2104,8 +2740,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2104
2740
  appendAuditLog({
2105
2741
  toolName: entry.toolName,
2106
2742
  args: entry.args,
2107
- decision,
2108
- timestamp: Date.now()
2743
+ decision
2109
2744
  });
2110
2745
  clearTimeout(entry.timer);
2111
2746
  if (entry.waiter) entry.waiter(decision);
@@ -2125,10 +2760,28 @@ data: ${JSON.stringify(readPersistentDecisions())}
2125
2760
  res.writeHead(404).end();
2126
2761
  });
2127
2762
  daemonServer = server;
2763
+ server.on("error", (e) => {
2764
+ if (e.code === "EADDRINUSE") {
2765
+ try {
2766
+ if (fs3.existsSync(DAEMON_PID_FILE)) {
2767
+ const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
2768
+ process.kill(pid, 0);
2769
+ return process.exit(0);
2770
+ }
2771
+ } catch {
2772
+ try {
2773
+ fs3.unlinkSync(DAEMON_PID_FILE);
2774
+ } catch {
2775
+ }
2776
+ server.listen(DAEMON_PORT2, DAEMON_HOST2);
2777
+ return;
2778
+ }
2779
+ }
2780
+ console.error(chalk3.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
2781
+ process.exit(1);
2782
+ });
2128
2783
  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(
2784
+ atomicWriteSync2(
2132
2785
  DAEMON_PID_FILE,
2133
2786
  JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
2134
2787
  { mode: 384 }
@@ -2164,17 +2817,97 @@ function daemonStatus() {
2164
2817
  }
2165
2818
 
2166
2819
  // src/cli.ts
2167
- import { spawn, execSync as execSync2 } from "child_process";
2820
+ import { spawn as spawn3, execSync } from "child_process";
2168
2821
  import { parseCommandString } from "execa";
2169
2822
  import { execa } from "execa";
2170
2823
  import chalk4 from "chalk";
2171
2824
  import readline from "readline";
2825
+ import fs5 from "fs";
2826
+ import path5 from "path";
2827
+ import os5 from "os";
2828
+
2829
+ // src/undo.ts
2830
+ import { spawnSync } from "child_process";
2172
2831
  import fs4 from "fs";
2173
2832
  import path4 from "path";
2174
2833
  import os4 from "os";
2834
+ var UNDO_LATEST_PATH = path4.join(os4.homedir(), ".node9", "undo_latest.txt");
2835
+ async function createShadowSnapshot() {
2836
+ try {
2837
+ const cwd = process.cwd();
2838
+ if (!fs4.existsSync(path4.join(cwd, ".git"))) return null;
2839
+ const tempIndex = path4.join(cwd, ".git", `node9_index_${Date.now()}`);
2840
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
2841
+ spawnSync("git", ["add", "-A"], { env });
2842
+ const treeRes = spawnSync("git", ["write-tree"], { env });
2843
+ const treeHash = treeRes.stdout.toString().trim();
2844
+ if (fs4.existsSync(tempIndex)) fs4.unlinkSync(tempIndex);
2845
+ if (!treeHash || treeRes.status !== 0) return null;
2846
+ const commitRes = spawnSync("git", [
2847
+ "commit-tree",
2848
+ treeHash,
2849
+ "-m",
2850
+ `Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
2851
+ ]);
2852
+ const commitHash = commitRes.stdout.toString().trim();
2853
+ if (commitHash && commitRes.status === 0) {
2854
+ const dir = path4.dirname(UNDO_LATEST_PATH);
2855
+ if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
2856
+ fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
2857
+ return commitHash;
2858
+ }
2859
+ } catch (err) {
2860
+ if (process.env.NODE9_DEBUG === "1") {
2861
+ console.error("[Node9 Undo Engine Error]:", err);
2862
+ }
2863
+ }
2864
+ return null;
2865
+ }
2866
+ function applyUndo(hash) {
2867
+ try {
2868
+ const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
2869
+ if (restore.status !== 0) return false;
2870
+ const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash]);
2871
+ const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
2872
+ const tracked = spawnSync("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
2873
+ const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
2874
+ for (const file of [...tracked, ...untracked]) {
2875
+ if (!snapshotFiles.has(file) && fs4.existsSync(file)) {
2876
+ fs4.unlinkSync(file);
2877
+ }
2878
+ }
2879
+ return true;
2880
+ } catch {
2881
+ return false;
2882
+ }
2883
+ }
2884
+ function getLatestSnapshotHash() {
2885
+ if (!fs4.existsSync(UNDO_LATEST_PATH)) return null;
2886
+ return fs4.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
2887
+ }
2888
+
2889
+ // src/cli.ts
2890
+ import { confirm as confirm3 } from "@inquirer/prompts";
2175
2891
  var { version } = JSON.parse(
2176
- fs4.readFileSync(path4.join(__dirname, "../package.json"), "utf-8")
2892
+ fs5.readFileSync(path5.join(__dirname, "../package.json"), "utf-8")
2177
2893
  );
2894
+ function parseDuration(str) {
2895
+ const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
2896
+ if (!m) return null;
2897
+ const n = parseFloat(m[1]);
2898
+ switch ((m[2] ?? "m").toLowerCase()) {
2899
+ case "s":
2900
+ return Math.round(n * 1e3);
2901
+ case "m":
2902
+ return Math.round(n * 6e4);
2903
+ case "h":
2904
+ return Math.round(n * 36e5);
2905
+ case "d":
2906
+ return Math.round(n * 864e5);
2907
+ default:
2908
+ return null;
2909
+ }
2910
+ }
2178
2911
  function sanitize(value) {
2179
2912
  return value.replace(/[\x00-\x1F\x7F]/g, "");
2180
2913
  }
@@ -2182,23 +2915,33 @@ function openBrowserLocal() {
2182
2915
  const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2183
2916
  try {
2184
2917
  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);
2918
+ if (process.platform === "darwin") execSync(`open "${url}"`, opts);
2919
+ else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
2920
+ else execSync(`xdg-open "${url}"`, opts);
2188
2921
  } catch {
2189
2922
  }
2190
2923
  }
2191
2924
  async function autoStartDaemonAndWait() {
2192
2925
  try {
2193
- const child = spawn("node9", ["daemon"], {
2926
+ const child = spawn3("node9", ["daemon"], {
2194
2927
  detached: true,
2195
2928
  stdio: "ignore",
2196
2929
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
2197
2930
  });
2198
2931
  child.unref();
2199
- for (let i = 0; i < 12; i++) {
2932
+ for (let i = 0; i < 20; i++) {
2200
2933
  await new Promise((r) => setTimeout(r, 250));
2201
- if (isDaemonRunning()) return true;
2934
+ if (!isDaemonRunning()) continue;
2935
+ try {
2936
+ const res = await fetch("http://127.0.0.1:7391/settings", {
2937
+ signal: AbortSignal.timeout(500)
2938
+ });
2939
+ if (res.ok) {
2940
+ openBrowserLocal();
2941
+ return true;
2942
+ }
2943
+ } catch {
2944
+ }
2202
2945
  }
2203
2946
  } catch {
2204
2947
  }
@@ -2217,47 +2960,71 @@ async function runProxy(targetCommand) {
2217
2960
  } catch {
2218
2961
  }
2219
2962
  console.log(chalk4.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2220
- const child = spawn(executable, args, {
2963
+ const child = spawn3(executable, args, {
2221
2964
  stdio: ["pipe", "pipe", "inherit"],
2965
+ // We control STDIN and STDOUT
2222
2966
  shell: true,
2223
- env: { ...process.env, FORCE_COLOR: "1", TERM: process.env.TERM || "xterm-256color" }
2967
+ env: { ...process.env, FORCE_COLOR: "1" }
2224
2968
  });
2225
- process.stdin.pipe(child.stdin);
2226
- const childOut = readline.createInterface({ input: child.stdout, terminal: false });
2227
- childOut.on("line", async (line) => {
2969
+ const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
2970
+ agentIn.on("line", async (line) => {
2971
+ let message;
2228
2972
  try {
2229
- const message = JSON.parse(line);
2230
- if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2973
+ message = JSON.parse(line);
2974
+ } catch {
2975
+ child.stdin.write(line + "\n");
2976
+ return;
2977
+ }
2978
+ if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2979
+ agentIn.pause();
2980
+ try {
2231
2981
  const name = message.params?.name || message.params?.tool_name || "unknown";
2232
2982
  const toolArgs = message.params?.arguments || message.params?.tool_input || {};
2233
- const approved = await authorizeAction(sanitize(name), toolArgs);
2234
- if (!approved) {
2983
+ const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
2984
+ agent: "Proxy/MCP"
2985
+ });
2986
+ if (!result.approved) {
2235
2987
  const errorResponse = {
2236
2988
  jsonrpc: "2.0",
2237
2989
  id: message.id,
2238
- error: { code: -32e3, message: "Node9: Action denied." }
2990
+ error: {
2991
+ code: -32e3,
2992
+ message: `Node9: Action denied. ${result.reason || ""}`
2993
+ }
2239
2994
  };
2240
- child.stdin.write(JSON.stringify(errorResponse) + "\n");
2995
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
2241
2996
  return;
2242
2997
  }
2998
+ } catch {
2999
+ const errorResponse = {
3000
+ jsonrpc: "2.0",
3001
+ id: message.id,
3002
+ error: {
3003
+ code: -32e3,
3004
+ message: `Node9: Security engine encountered an error. Action blocked for safety.`
3005
+ }
3006
+ };
3007
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
3008
+ return;
3009
+ } finally {
3010
+ agentIn.resume();
2243
3011
  }
2244
- process.stdout.write(line + "\n");
2245
- } catch {
2246
- process.stdout.write(line + "\n");
2247
3012
  }
3013
+ child.stdin.write(line + "\n");
2248
3014
  });
3015
+ child.stdout.pipe(process.stdout);
2249
3016
  child.on("exit", (code) => process.exit(code || 0));
2250
3017
  }
2251
3018
  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
3019
  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 });
3020
+ const credPath = path5.join(os5.homedir(), ".node9", "credentials.json");
3021
+ if (!fs5.existsSync(path5.dirname(credPath)))
3022
+ fs5.mkdirSync(path5.dirname(credPath), { recursive: true });
2256
3023
  const profileName = options.profile || "default";
2257
3024
  let existingCreds = {};
2258
3025
  try {
2259
- if (fs4.existsSync(credPath)) {
2260
- const raw = JSON.parse(fs4.readFileSync(credPath, "utf-8"));
3026
+ if (fs5.existsSync(credPath)) {
3027
+ const raw = JSON.parse(fs5.readFileSync(credPath, "utf-8"));
2261
3028
  if (raw.apiKey) {
2262
3029
  existingCreds = {
2263
3030
  default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
@@ -2269,40 +3036,38 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
2269
3036
  } catch {
2270
3037
  }
2271
3038
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
2272
- fs4.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3039
+ fs5.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
2273
3040
  if (profileName === "default") {
2274
- const configPath = path4.join(os4.homedir(), ".node9", "config.json");
3041
+ const configPath = path5.join(os5.homedir(), ".node9", "config.json");
2275
3042
  let config = {};
2276
3043
  try {
2277
- if (fs4.existsSync(configPath))
2278
- config = JSON.parse(fs4.readFileSync(configPath, "utf-8"));
3044
+ if (fs5.existsSync(configPath))
3045
+ config = JSON.parse(fs5.readFileSync(configPath, "utf-8"));
2279
3046
  } catch {
2280
3047
  }
2281
3048
  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 });
3049
+ const s = config.settings;
3050
+ const approvers = s.approvers || {
3051
+ native: true,
3052
+ browser: true,
3053
+ cloud: true,
3054
+ terminal: true
3055
+ };
3056
+ approvers.cloud = !options.local;
3057
+ s.approvers = approvers;
3058
+ if (!fs5.existsSync(path5.dirname(configPath)))
3059
+ fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
3060
+ fs5.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
2286
3061
  }
2287
3062
  if (options.profile && profileName !== "default") {
2288
3063
  console.log(chalk4.green(`\u2705 Profile "${profileName}" saved`));
2289
3064
  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
- );
2295
3065
  } else if (options.local) {
2296
3066
  console.log(chalk4.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
2297
3067
  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>`));
2302
3068
  } else {
2303
3069
  console.log(chalk4.green(`\u2705 Logged in \u2014 agent mode`));
2304
3070
  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`));
2306
3071
  }
2307
3072
  });
2308
3073
  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) => {
@@ -2313,16 +3078,23 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
2313
3078
  process.exit(1);
2314
3079
  });
2315
3080
  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) {
3081
+ const configPath = path5.join(os5.homedir(), ".node9", "config.json");
3082
+ if (fs5.existsSync(configPath) && !options.force) {
2318
3083
  console.log(chalk4.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
2319
3084
  console.log(chalk4.gray(` Run with --force to overwrite.`));
2320
3085
  return;
2321
3086
  }
2322
3087
  const defaultConfig = {
2323
3088
  version: "1.0",
2324
- settings: { mode: "standard" },
3089
+ settings: {
3090
+ mode: "standard",
3091
+ autoStartDaemon: true,
3092
+ enableUndo: true,
3093
+ enableHookLogDebug: false,
3094
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
3095
+ },
2325
3096
  policy: {
3097
+ sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
2326
3098
  dangerousWords: DANGEROUS_WORDS,
2327
3099
  ignoredTools: [
2328
3100
  "list_*",
@@ -2332,60 +3104,65 @@ program.command("init").description("Create ~/.node9/config.json with default po
2332
3104
  "read",
2333
3105
  "write",
2334
3106
  "edit",
2335
- "multiedit",
2336
3107
  "glob",
2337
3108
  "grep",
2338
3109
  "ls",
2339
3110
  "notebookread",
2340
3111
  "notebookedit",
2341
- "todoread",
2342
- "todowrite",
2343
3112
  "webfetch",
2344
3113
  "websearch",
2345
3114
  "exitplanmode",
2346
- "askuserquestion"
3115
+ "askuserquestion",
3116
+ "agent",
3117
+ "task*"
2347
3118
  ],
2348
3119
  toolInspection: {
2349
3120
  bash: "command",
2350
3121
  shell: "command",
2351
3122
  run_shell_command: "command",
2352
- "terminal.execute": "command"
3123
+ "terminal.execute": "command",
3124
+ "postgres:query": "sql"
2353
3125
  },
2354
3126
  rules: [
2355
3127
  {
2356
3128
  action: "rm",
2357
- allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"]
3129
+ allowPaths: [
3130
+ "**/node_modules/**",
3131
+ "dist/**",
3132
+ "build/**",
3133
+ ".next/**",
3134
+ "coverage/**",
3135
+ ".cache/**",
3136
+ "tmp/**",
3137
+ "temp/**",
3138
+ ".DS_Store"
3139
+ ]
2358
3140
  }
2359
3141
  ]
2360
3142
  }
2361
3143
  };
2362
- if (!fs4.existsSync(path4.dirname(configPath)))
2363
- fs4.mkdirSync(path4.dirname(configPath), { recursive: true });
2364
- fs4.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
3144
+ if (!fs5.existsSync(path5.dirname(configPath)))
3145
+ fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
3146
+ fs5.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
2365
3147
  console.log(chalk4.green(`\u2705 Global config created: ${configPath}`));
2366
3148
  console.log(chalk4.gray(` Edit this file to add custom tool inspection or security rules.`));
2367
3149
  });
2368
3150
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
2369
3151
  const creds = getCredentials();
2370
3152
  const daemonRunning = isDaemonRunning();
2371
- const settings = getGlobalSettings();
3153
+ const mergedConfig = getConfig();
3154
+ const settings = mergedConfig.settings;
2372
3155
  console.log("");
2373
- if (creds && settings.agentMode) {
3156
+ if (creds && settings.approvers.cloud) {
2374
3157
  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) {
3158
+ } else if (creds && !settings.approvers.cloud) {
2378
3159
  console.log(
2379
3160
  chalk4.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 all decisions stay on this machine")
2380
3161
  );
3162
+ } else {
2381
3163
  console.log(
2382
- chalk4.gray(" No data is sent to the cloud. Local config is the only authority.")
3164
+ chalk4.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 no API key (Local rules only)")
2383
3165
  );
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
3166
  }
2390
3167
  console.log("");
2391
3168
  if (daemonRunning) {
@@ -2394,64 +3171,39 @@ program.command("status").description("Show current Node9 mode, policy source, a
2394
3171
  );
2395
3172
  } else {
2396
3173
  console.log(chalk4.gray(" \u25CB Daemon stopped"));
2397
- console.log(chalk4.gray(" Start: node9 daemon --background"));
2398
3174
  }
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)}`);
3175
+ if (settings.enableUndo) {
2410
3176
  console.log(
2411
- ` All profiles: ${profiles.map((p) => p === activeProfile ? chalk4.green(p) : chalk4.gray(p)).join(chalk4.gray(", "))}`
3177
+ chalk4.magenta(" \u25CF Undo Engine") + chalk4.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
2412
3178
  );
2413
- console.log(chalk4.gray(` Switch: NODE9_PROFILE=<name> claude`));
2414
- }
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
3179
  }
2422
- const keys = Object.keys(decisions);
2423
3180
  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"));
3181
+ const modeLabel = settings.mode === "audit" ? chalk4.blue("audit") : settings.mode === "strict" ? chalk4.red("strict") : chalk4.white("standard");
3182
+ console.log(` Mode: ${modeLabel}`);
3183
+ const projectConfig = path5.join(process.cwd(), "node9.config.json");
3184
+ const globalConfig = path5.join(os5.homedir(), ".node9", "config.json");
3185
+ console.log(
3186
+ ` Local: ${fs5.existsSync(projectConfig) ? chalk4.green("Active (node9.config.json)") : chalk4.gray("Not present")}`
3187
+ );
3188
+ console.log(
3189
+ ` Global: ${fs5.existsSync(globalConfig) ? chalk4.green("Active (~/.node9/config.json)") : chalk4.gray("Not present")}`
3190
+ );
3191
+ if (mergedConfig.policy.sandboxPaths.length > 0) {
3192
+ console.log(
3193
+ ` Sandbox: ${chalk4.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
3194
+ );
2434
3195
  }
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 {
3196
+ const pauseState = checkPause();
3197
+ if (pauseState.paused) {
3198
+ const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
3199
+ console.log("");
3200
+ console.log(
3201
+ chalk4.yellow(` \u23F8 PAUSED until ${expiresAt}`) + chalk4.gray(" \u2014 all tool calls allowed")
3202
+ );
2445
3203
  }
2446
3204
  console.log("");
2447
3205
  });
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(
3206
+ 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
3207
  async (action, options) => {
2456
3208
  const cmd = (action ?? "start").toLowerCase();
2457
3209
  if (cmd === "stop") return stopDaemon();
@@ -2466,7 +3218,7 @@ program.command("daemon").description("Run the local approval server (browser HI
2466
3218
  console.log(chalk4.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2467
3219
  process.exit(0);
2468
3220
  }
2469
- const child = spawn("node9", ["daemon"], { detached: true, stdio: "ignore" });
3221
+ const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
2470
3222
  child.unref();
2471
3223
  for (let i = 0; i < 12; i++) {
2472
3224
  await new Promise((r) => setTimeout(r, 250));
@@ -2475,18 +3227,13 @@ program.command("daemon").description("Run the local approval server (browser HI
2475
3227
  openBrowserLocal();
2476
3228
  console.log(chalk4.green(`
2477
3229
  \u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
2478
- console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2479
3230
  process.exit(0);
2480
3231
  }
2481
3232
  if (options.background) {
2482
- const child = spawn("node9", ["daemon"], { detached: true, stdio: "ignore" });
3233
+ const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
2483
3234
  child.unref();
2484
3235
  console.log(chalk4.green(`
2485
3236
  \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
3237
  process.exit(0);
2491
3238
  }
2492
3239
  startDaemon();
@@ -2496,53 +3243,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2496
3243
  const processPayload = async (raw) => {
2497
3244
  try {
2498
3245
  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}
3246
+ let payload = JSON.parse(raw);
3247
+ try {
3248
+ payload = JSON.parse(raw);
3249
+ } catch (err) {
3250
+ const tempConfig = getConfig();
3251
+ if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
3252
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
3253
+ const errMsg = err instanceof Error ? err.message : String(err);
3254
+ fs5.appendFileSync(
3255
+ logPath,
3256
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
3257
+ RAW: ${raw}
2508
3258
  `
2509
- );
3259
+ );
3260
+ }
3261
+ process.exit(0);
3262
+ return;
3263
+ }
3264
+ if (payload.cwd) {
3265
+ try {
3266
+ process.chdir(payload.cwd);
3267
+ _resetConfigCache();
3268
+ } catch {
3269
+ }
3270
+ }
3271
+ const config = getConfig();
3272
+ if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
3273
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
3274
+ if (!fs5.existsSync(path5.dirname(logPath)))
3275
+ fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
3276
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
3277
+ `);
2510
3278
  }
2511
- const payload = JSON.parse(raw);
2512
3279
  const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
2513
3280
  const toolInput = payload.tool_input ?? payload.args ?? {};
2514
- const agent = payload.tool_name !== void 0 ? "Claude Code" : payload.name !== void 0 ? "Gemini CLI" : "Terminal";
3281
+ 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
3282
  const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
2516
3283
  const mcpServer = mcpMatch?.[1];
2517
3284
  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
- };
3285
+ const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
3286
+ const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
2525
3287
  console.error(chalk4.red(`
2526
3288
  \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
- }
3289
+ console.error(chalk4.gray(` Triggered by: ${blockedByContext}`));
3290
+ if (result2?.changeHint) console.error(chalk4.cyan(` To change: ${result2.changeHint}`));
2537
3291
  console.error("");
3292
+ let aiFeedbackMessage = "";
3293
+ if (isHumanDecision) {
3294
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
3295
+ REASON: ${msg || "No specific reason provided by user."}
3296
+
3297
+ INSTRUCTIONS FOR AI AGENT:
3298
+ - Do NOT retry this exact command immediately.
3299
+ - Explain to the user that you understand they blocked the action.
3300
+ - Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
3301
+ - 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.`;
3302
+ } else {
3303
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
3304
+ REASON: ${msg}
3305
+
3306
+ INSTRUCTIONS FOR AI AGENT:
3307
+ - This command violates the current security configuration.
3308
+ - Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
3309
+ - Pivot to a non-destructive or read-only alternative.
3310
+ - Inform the user which security rule was triggered.`;
3311
+ }
2538
3312
  process.stdout.write(
2539
3313
  JSON.stringify({
2540
3314
  decision: "block",
2541
- reason: msg,
3315
+ reason: aiFeedbackMessage,
3316
+ // This is the core instruction
2542
3317
  hookSpecificOutput: {
2543
3318
  hookEventName: "PreToolUse",
2544
3319
  permissionDecision: "deny",
2545
- permissionDecisionReason: msg
3320
+ permissionDecisionReason: aiFeedbackMessage
2546
3321
  }
2547
3322
  }) + "\n"
2548
3323
  );
@@ -2553,36 +3328,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2553
3328
  return;
2554
3329
  }
2555
3330
  const meta = { agent, mcpServer };
3331
+ const STATE_CHANGING_TOOLS_PRE = [
3332
+ "bash",
3333
+ "shell",
3334
+ "write_file",
3335
+ "edit_file",
3336
+ "replace",
3337
+ "terminal.execute",
3338
+ "str_replace_based_edit_tool",
3339
+ "create_file"
3340
+ ];
3341
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
3342
+ await createShadowSnapshot();
3343
+ }
2556
3344
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
2557
3345
  if (result.approved) {
2558
- if (result.checkedBy) {
3346
+ if (result.checkedBy)
2559
3347
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
2560
3348
  `);
2561
- }
2562
3349
  process.exit(0);
2563
3350
  }
2564
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && getGlobalSettings().autoStartDaemon) {
3351
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
2565
3352
  console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2566
3353
  const daemonReady = await autoStartDaemonAndWait();
2567
3354
  if (daemonReady) {
2568
3355
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
2569
3356
  if (retry.approved) {
2570
- if (retry.checkedBy) {
3357
+ if (retry.checkedBy)
2571
3358
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
2572
3359
  `);
2573
- }
2574
3360
  process.exit(0);
2575
3361
  }
2576
- sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, retry);
3362
+ sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
3363
+ ...retry,
3364
+ blockedByLabel: retry.blockedByLabel
3365
+ });
2577
3366
  return;
2578
3367
  }
2579
3368
  }
2580
- sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, result);
3369
+ sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
3370
+ ...result,
3371
+ blockedByLabel: result.blockedByLabel
3372
+ });
2581
3373
  } catch (err) {
2582
3374
  if (process.env.NODE9_DEBUG === "1") {
2583
- const logPath = path4.join(os4.homedir(), ".node9", "hook-debug.log");
3375
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
2584
3376
  const errMsg = err instanceof Error ? err.message : String(err);
2585
- fs4.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
3377
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
2586
3378
  `);
2587
3379
  }
2588
3380
  process.exit(0);
@@ -2593,48 +3385,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2593
3385
  } else {
2594
3386
  let raw = "";
2595
3387
  let processed = false;
3388
+ let inactivityTimer = null;
2596
3389
  const done = async () => {
2597
3390
  if (processed) return;
2598
3391
  processed = true;
3392
+ if (inactivityTimer) clearTimeout(inactivityTimer);
2599
3393
  if (!raw.trim()) return process.exit(0);
2600
3394
  await processPayload(raw);
2601
3395
  };
2602
3396
  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);
3397
+ process.stdin.on("data", (chunk) => {
3398
+ raw += chunk;
3399
+ if (inactivityTimer) clearTimeout(inactivityTimer);
3400
+ inactivityTimer = setTimeout(() => void done(), 2e3);
3401
+ });
3402
+ process.stdin.on("end", () => {
3403
+ void done();
3404
+ });
3405
+ inactivityTimer = setTimeout(() => void done(), 5e3);
2606
3406
  }
2607
3407
  });
2608
3408
  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) => {
3409
+ const logPayload = async (raw) => {
2610
3410
  try {
2611
3411
  if (!raw || raw.trim() === "") process.exit(0);
2612
3412
  const payload = JSON.parse(raw);
3413
+ const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
3414
+ const rawInput = payload.tool_input ?? payload.args ?? {};
2613
3415
  const entry = {
2614
3416
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2615
- tool: sanitize(payload.tool_name ?? "unknown"),
2616
- input: JSON.parse(redactSecrets(JSON.stringify(payload.tool_input || {})))
3417
+ tool,
3418
+ args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
3419
+ decision: "allowed",
3420
+ source: "post-hook"
2617
3421
  };
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");
3422
+ const logPath = path5.join(os5.homedir(), ".node9", "audit.log");
3423
+ if (!fs5.existsSync(path5.dirname(logPath)))
3424
+ fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
3425
+ fs5.appendFileSync(logPath, JSON.stringify(entry) + "\n");
3426
+ const config = getConfig();
3427
+ const STATE_CHANGING_TOOLS = [
3428
+ "bash",
3429
+ "shell",
3430
+ "write_file",
3431
+ "edit_file",
3432
+ "replace",
3433
+ "terminal.execute"
3434
+ ];
3435
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
3436
+ await createShadowSnapshot();
3437
+ }
2622
3438
  } catch {
2623
3439
  }
2624
3440
  process.exit(0);
2625
3441
  };
2626
3442
  if (data) {
2627
- logPayload(data);
3443
+ await logPayload(data);
2628
3444
  } else {
2629
3445
  let raw = "";
2630
3446
  process.stdin.setEncoding("utf-8");
2631
3447
  process.stdin.on("data", (chunk) => raw += chunk);
2632
- process.stdin.on("end", () => logPayload(raw));
3448
+ process.stdin.on("end", () => {
3449
+ void logPayload(raw);
3450
+ });
2633
3451
  setTimeout(() => {
2634
3452
  if (!raw) process.exit(0);
2635
3453
  }, 500);
2636
3454
  }
2637
3455
  });
3456
+ 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) => {
3457
+ const ms = parseDuration(options.duration);
3458
+ if (ms === null) {
3459
+ console.error(
3460
+ chalk4.red(`
3461
+ \u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
3462
+ `)
3463
+ );
3464
+ process.exit(1);
3465
+ }
3466
+ pauseNode9(ms, options.duration);
3467
+ const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
3468
+ console.log(chalk4.yellow(`
3469
+ \u23F8 Node9 paused until ${expiresAt}`));
3470
+ console.log(chalk4.gray(` All tool calls will be allowed without review.`));
3471
+ console.log(chalk4.gray(` Run "node9 resume" to re-enable early.
3472
+ `));
3473
+ });
3474
+ program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
3475
+ const { paused } = checkPause();
3476
+ if (!paused) {
3477
+ console.log(chalk4.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
3478
+ return;
3479
+ }
3480
+ resumeNode9();
3481
+ console.log(chalk4.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
3482
+ });
2638
3483
  var HOOK_BASED_AGENTS = {
2639
3484
  claude: "claude",
2640
3485
  gemini: "gemini",
@@ -2649,35 +3494,17 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2649
3494
  chalk4.yellow(`
2650
3495
  \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
2651
3496
  );
2652
- 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
3497
  console.error(chalk4.white(`
2669
- For browser approval popups (no API key required):`));
3498
+ "${target}" uses its own hook system. Use:`));
2670
3499
  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")
3500
+ chalk4.green(` node9 addto ${target} `) + chalk4.gray("# one-time setup")
2675
3501
  );
3502
+ console.error(chalk4.green(` ${target} `) + chalk4.gray("# run normally"));
2676
3503
  process.exit(1);
2677
3504
  }
2678
3505
  const fullCommand = commandArgs.join(" ");
2679
3506
  let result = await authorizeHeadless("shell", { command: fullCommand });
2680
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getGlobalSettings().autoStartDaemon) {
3507
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
2681
3508
  console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2682
3509
  const daemonReady = await autoStartDaemonAndWait();
2683
3510
  if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
@@ -2690,21 +3517,6 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2690
3517
  chalk4.red(`
2691
3518
  \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
2692
3519
  );
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
3520
  process.exit(1);
2709
3521
  }
2710
3522
  console.error(chalk4.green("\n\u2705 Approved \u2014 running command...\n"));
@@ -2713,13 +3525,33 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2713
3525
  program.help();
2714
3526
  }
2715
3527
  });
3528
+ program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
3529
+ const hash = getLatestSnapshotHash();
3530
+ if (!hash) {
3531
+ console.log(chalk4.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
3532
+ return;
3533
+ }
3534
+ console.log(chalk4.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
3535
+ console.log(chalk4.white(`Target Snapshot: ${chalk4.gray(hash.slice(0, 7))}`));
3536
+ const proceed = await confirm3({
3537
+ message: "Revert all files to the state before the last AI action?",
3538
+ default: false
3539
+ });
3540
+ if (proceed) {
3541
+ if (applyUndo(hash)) {
3542
+ console.log(chalk4.green("\u2705 Project reverted successfully.\n"));
3543
+ } else {
3544
+ console.error(chalk4.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
3545
+ }
3546
+ }
3547
+ });
2716
3548
  process.on("unhandledRejection", (reason) => {
2717
3549
  const isCheckHook = process.argv[2] === "check";
2718
3550
  if (isCheckHook) {
2719
- if (process.env.NODE9_DEBUG === "1") {
2720
- const logPath = path4.join(os4.homedir(), ".node9", "hook-debug.log");
3551
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
3552
+ const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
2721
3553
  const msg = reason instanceof Error ? reason.message : String(reason);
2722
- fs4.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
3554
+ fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
2723
3555
  `);
2724
3556
  }
2725
3557
  process.exit(0);