@node9/proxy 0.2.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +66 -319
  2. package/dist/cli.js +1417 -608
  3. package/dist/cli.mjs +1417 -608
  4. package/dist/index.js +722 -261
  5. package/dist/index.mjs +722 -261
  6. package/package.json +44 -8
package/dist/cli.js CHANGED
@@ -27,13 +27,254 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var import_commander = require("commander");
28
28
 
29
29
  // src/core.ts
30
- var import_chalk = __toESM(require("chalk"));
30
+ var import_chalk2 = __toESM(require("chalk"));
31
31
  var import_prompts = require("@inquirer/prompts");
32
32
  var import_fs = __toESM(require("fs"));
33
33
  var import_path = __toESM(require("path"));
34
34
  var import_os = __toESM(require("os"));
35
35
  var import_picomatch = __toESM(require("picomatch"));
36
36
  var import_sh_syntax = require("sh-syntax");
37
+
38
+ // src/ui/native.ts
39
+ var import_child_process = require("child_process");
40
+ var import_chalk = __toESM(require("chalk"));
41
+ var isTestEnv = () => {
42
+ 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";
43
+ };
44
+ function smartTruncate(str, maxLen = 500) {
45
+ if (str.length <= maxLen) return str;
46
+ const edge = Math.floor(maxLen / 2) - 3;
47
+ return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
48
+ }
49
+ function formatArgs(args) {
50
+ if (args === null || args === void 0) return "(none)";
51
+ let parsed = args;
52
+ if (typeof args === "string") {
53
+ const trimmed = args.trim();
54
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
55
+ try {
56
+ parsed = JSON.parse(trimmed);
57
+ } catch {
58
+ parsed = args;
59
+ }
60
+ } else {
61
+ return smartTruncate(args, 600);
62
+ }
63
+ }
64
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
65
+ const obj = parsed;
66
+ const codeKeys = [
67
+ "command",
68
+ "cmd",
69
+ "shell_command",
70
+ "bash_command",
71
+ "script",
72
+ "code",
73
+ "input",
74
+ "sql",
75
+ "query",
76
+ "arguments",
77
+ "args",
78
+ "param",
79
+ "params",
80
+ "text"
81
+ ];
82
+ const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
83
+ if (foundKey) {
84
+ const val = obj[foundKey];
85
+ const str = typeof val === "string" ? val : JSON.stringify(val);
86
+ return `[${foundKey.toUpperCase()}]:
87
+ ${smartTruncate(str, 500)}`;
88
+ }
89
+ return Object.entries(obj).slice(0, 5).map(
90
+ ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
91
+ ).join("\n");
92
+ }
93
+ return smartTruncate(JSON.stringify(parsed), 200);
94
+ }
95
+ function sendDesktopNotification(title, body) {
96
+ if (isTestEnv()) return;
97
+ try {
98
+ if (process.platform === "darwin") {
99
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
100
+ (0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
101
+ } else if (process.platform === "linux") {
102
+ (0, import_child_process.spawn)("notify-send", [title, body, "--icon=dialog-warning"], {
103
+ detached: true,
104
+ stdio: "ignore"
105
+ }).unref();
106
+ }
107
+ } catch {
108
+ }
109
+ }
110
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
111
+ if (isTestEnv()) return "deny";
112
+ const formattedArgs = formatArgs(args);
113
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
114
+ let message = "";
115
+ if (locked) message += `\u26A0\uFE0F LOCKED BY ADMIN POLICY
116
+ `;
117
+ message += `Tool: ${toolName}
118
+ `;
119
+ message += `Agent: ${agent || "AI Agent"}
120
+ `;
121
+ message += `Rule: ${explainableLabel || "Security Policy"}
122
+
123
+ `;
124
+ message += `${formattedArgs}`;
125
+ process.stderr.write(import_chalk.default.yellow(`
126
+ \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
127
+ `));
128
+ return new Promise((resolve) => {
129
+ let childProcess = null;
130
+ const onAbort = () => {
131
+ if (childProcess && childProcess.pid) {
132
+ try {
133
+ process.kill(childProcess.pid, "SIGKILL");
134
+ } catch {
135
+ }
136
+ }
137
+ resolve("deny");
138
+ };
139
+ if (signal) {
140
+ if (signal.aborted) return resolve("deny");
141
+ signal.addEventListener("abort", onAbort);
142
+ }
143
+ try {
144
+ if (process.platform === "darwin") {
145
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
146
+ const script = `on run argv
147
+ tell application "System Events"
148
+ activate
149
+ display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
150
+ end tell
151
+ end run`;
152
+ childProcess = (0, import_child_process.spawn)("osascript", ["-e", script, "--", message, title]);
153
+ } else if (process.platform === "linux") {
154
+ const argsList = [
155
+ locked ? "--info" : "--question",
156
+ "--modal",
157
+ "--width=450",
158
+ "--title",
159
+ title,
160
+ "--text",
161
+ message,
162
+ "--ok-label",
163
+ locked ? "Waiting..." : "Allow",
164
+ "--timeout",
165
+ "300"
166
+ ];
167
+ if (!locked) {
168
+ argsList.push("--cancel-label", "Block");
169
+ argsList.push("--extra-button", "Always Allow");
170
+ }
171
+ childProcess = (0, import_child_process.spawn)("zenity", argsList);
172
+ } else if (process.platform === "win32") {
173
+ const b64Msg = Buffer.from(message).toString("base64");
174
+ const b64Title = Buffer.from(title).toString("base64");
175
+ const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
176
+ childProcess = (0, import_child_process.spawn)("powershell", ["-Command", ps]);
177
+ }
178
+ let output = "";
179
+ childProcess?.stdout?.on("data", (d) => output += d.toString());
180
+ childProcess?.on("close", (code) => {
181
+ if (signal) signal.removeEventListener("abort", onAbort);
182
+ if (locked) return resolve("deny");
183
+ if (output.includes("Always Allow")) return resolve("always_allow");
184
+ if (code === 0) return resolve("allow");
185
+ resolve("deny");
186
+ });
187
+ } catch {
188
+ resolve("deny");
189
+ }
190
+ });
191
+ }
192
+
193
+ // src/core.ts
194
+ var PAUSED_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "PAUSED");
195
+ var TRUST_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "trust.json");
196
+ function checkPause() {
197
+ try {
198
+ if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
199
+ const state = JSON.parse(import_fs.default.readFileSync(PAUSED_FILE, "utf-8"));
200
+ if (state.expiry > 0 && Date.now() >= state.expiry) {
201
+ try {
202
+ import_fs.default.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 = import_path.default.dirname(filePath);
214
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
215
+ const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
216
+ import_fs.default.writeFileSync(tmpPath, data, options);
217
+ import_fs.default.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 (import_fs.default.existsSync(PAUSED_FILE)) import_fs.default.unlinkSync(PAUSED_FILE);
226
+ } catch {
227
+ }
228
+ }
229
+ function getActiveTrustSession(toolName) {
230
+ try {
231
+ if (!import_fs.default.existsSync(TRUST_FILE)) return false;
232
+ const trust = JSON.parse(import_fs.default.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
+ import_fs.default.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 (import_fs.default.existsSync(TRUST_FILE)) {
248
+ trust = JSON.parse(import_fs.default.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 = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
272
+ const dir = import_path.default.dirname(logPath);
273
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
274
+ import_fs.default.appendFileSync(logPath, entry + "\n");
275
+ } catch {
276
+ }
277
+ }
37
278
  var DANGEROUS_WORDS = [
38
279
  "delete",
39
280
  "drop",
@@ -51,10 +292,6 @@ var DANGEROUS_WORDS = [
51
292
  function tokenize(toolName) {
52
293
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
53
294
  }
54
- function containsDangerousWord(toolName, dangerousWords) {
55
- const tokens = tokenize(toolName);
56
- return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
57
- }
58
295
  function matchesPattern(text, patterns) {
59
296
  const p = Array.isArray(patterns) ? patterns : [patterns];
60
297
  if (p.length === 0) return false;
@@ -65,9 +302,9 @@ function matchesPattern(text, patterns) {
65
302
  const withoutDotSlash = text.replace(/^\.\//, "");
66
303
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
67
304
  }
68
- function getNestedValue(obj, path5) {
305
+ function getNestedValue(obj, path6) {
69
306
  if (!obj || typeof obj !== "object") return null;
70
- return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
307
+ return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
71
308
  }
72
309
  function extractShellCommand(toolName, args, toolInspection) {
73
310
  const patterns = Object.keys(toolInspection);
@@ -159,8 +396,15 @@ function redactSecrets(text) {
159
396
  return redacted;
160
397
  }
161
398
  var DEFAULT_CONFIG = {
162
- 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
+ },
163
406
  policy: {
407
+ sandboxPaths: [],
164
408
  dangerousWords: DANGEROUS_WORDS,
165
409
  ignoredTools: [
166
410
  "list_*",
@@ -168,34 +412,19 @@ var DEFAULT_CONFIG = {
168
412
  "read_*",
169
413
  "describe_*",
170
414
  "read",
171
- "write",
172
- "edit",
173
- "multiedit",
174
- "glob",
175
415
  "grep",
176
416
  "ls",
177
- "notebookread",
178
- "notebookedit",
179
- "todoread",
180
- "todowrite",
181
- "webfetch",
182
- "websearch",
183
- "exitplanmode",
184
417
  "askuserquestion"
185
418
  ],
186
- toolInspection: {
187
- bash: "command",
188
- run_shell_command: "command",
189
- shell: "command",
190
- "terminal.execute": "command"
191
- },
192
- rules: [
193
- { action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
194
- ]
419
+ toolInspection: { bash: "command", shell: "command" },
420
+ rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
195
421
  },
196
422
  environments: {}
197
423
  };
198
424
  var cachedConfig = null;
425
+ function _resetConfigCache() {
426
+ cachedConfig = null;
427
+ }
199
428
  function getGlobalSettings() {
200
429
  try {
201
430
  const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
@@ -206,18 +435,19 @@ function getGlobalSettings() {
206
435
  mode: settings.mode || "standard",
207
436
  autoStartDaemon: settings.autoStartDaemon !== false,
208
437
  slackEnabled: settings.slackEnabled !== false,
209
- // agentMode defaults to false — user must explicitly opt in via `node9 login`
210
- agentMode: settings.agentMode === true
438
+ enableTrustSessions: settings.enableTrustSessions === true,
439
+ allowGlobalPause: settings.allowGlobalPause !== false
211
440
  };
212
441
  }
213
442
  } catch {
214
443
  }
215
- return { mode: "standard", autoStartDaemon: true, slackEnabled: true, agentMode: false };
216
- }
217
- function hasSlack() {
218
- const creds = getCredentials();
219
- if (!creds?.apiKey) return false;
220
- return getGlobalSettings().slackEnabled;
444
+ return {
445
+ mode: "standard",
446
+ autoStartDaemon: true,
447
+ slackEnabled: true,
448
+ enableTrustSessions: false,
449
+ allowGlobalPause: true
450
+ };
221
451
  }
222
452
  function getInternalToken() {
223
453
  try {
@@ -230,51 +460,83 @@ function getInternalToken() {
230
460
  return null;
231
461
  }
232
462
  }
233
- async function evaluatePolicy(toolName, args) {
463
+ async function evaluatePolicy(toolName, args, agent) {
234
464
  const config = getConfig();
235
- 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 = [];
236
469
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
237
470
  if (shellCommand) {
238
- 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;
239
475
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
240
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) return "review";
241
- for (const action of actions) {
242
- const basename = action.includes("/") ? action.split("/").pop() : action;
243
- const rule = config.policy.rules.find(
244
- (r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
245
- );
246
- if (rule) {
247
- if (paths.length > 0) {
248
- const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
249
- if (anyBlocked) return "review";
250
- const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
251
- if (allAllowed) return "allow";
252
- }
253
- return "review";
254
- }
476
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
477
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
255
478
  }
256
- const isDangerous2 = allTokens.some(
257
- (token) => config.policy.dangerousWords.some((word) => {
258
- const w = word.toLowerCase();
259
- if (token === w) return true;
260
- try {
261
- return new RegExp(`\\b${w}\\b`, "i").test(token);
262
- } catch {
263
- return false;
264
- }
265
- })
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)
266
507
  );
267
- if (isDangerous2) return "review";
268
- if (config.settings.mode === "strict") return "review";
269
- 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
+ }
270
518
  }
271
- const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
272
- 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") {
273
535
  const envConfig = getActiveEnvironment(config);
274
- if (envConfig?.requireApproval === false) return "allow";
275
- return "review";
536
+ if (envConfig?.requireApproval === false) return { decision: "allow" };
537
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
276
538
  }
277
- return "allow";
539
+ return { decision: "allow" };
278
540
  }
279
541
  function isIgnoredTool(toolName) {
280
542
  const config = getConfig();
@@ -305,22 +567,40 @@ function getPersistentDecision(toolName) {
305
567
  }
306
568
  return null;
307
569
  }
308
- async function askDaemon(toolName, args, meta) {
570
+ async function askDaemon(toolName, args, meta, signal) {
309
571
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
310
- const checkRes = await fetch(`${base}/check`, {
311
- method: "POST",
312
- headers: { "Content-Type": "application/json" },
313
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
314
- signal: AbortSignal.timeout(5e3)
315
- });
316
- if (!checkRes.ok) throw new Error("Daemon fail");
317
- const { id } = await checkRes.json();
318
- const waitRes = await fetch(`${base}/wait/${id}`, { signal: AbortSignal.timeout(12e4) });
319
- if (!waitRes.ok) return "deny";
320
- const { decision } = await waitRes.json();
321
- if (decision === "allow") return "allow";
322
- if (decision === "abandoned") return "abandoned";
323
- 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
+ }
324
604
  }
325
605
  async function notifyDaemonViewer(toolName, args, meta) {
326
606
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
@@ -350,176 +630,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
350
630
  });
351
631
  }
352
632
  async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
353
- const { agentMode } = getGlobalSettings();
354
- const cloudEnforced = agentMode && hasSlack();
355
- if (!cloudEnforced) {
356
- if (isIgnoredTool(toolName)) return { approved: true };
357
- const policyDecision = await evaluatePolicy(toolName, args);
358
- 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";
359
673
  const persistent = getPersistentDecision(toolName);
360
- if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
361
- 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") {
362
679
  return {
363
680
  approved: false,
364
- reason: `Node9: "${toolName}" is set to always deny.`,
681
+ reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
365
682
  blockedBy: "persistent-deny",
366
- changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
683
+ blockedByLabel: "Persistent User Rule"
367
684
  };
368
- }
369
- if (cloudEnforced) {
370
- const creds = getCredentials();
371
- const envConfig = getActiveEnvironment(getConfig());
372
- let viewerId = null;
373
- const internalToken = getInternalToken();
374
- if (isDaemonRunning() && internalToken) {
375
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
376
685
  }
377
- const approved = await callNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
378
- if (viewerId && internalToken) {
379
- resolveViaDaemon(viewerId, approved ? "allow" : "deny", internalToken).catch(() => null);
380
- }
381
- return {
382
- approved,
383
- checkedBy: approved ? "cloud" : void 0,
384
- blockedBy: approved ? void 0 : "team-policy",
385
- changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
386
- };
686
+ } else {
687
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
688
+ return { approved: true };
387
689
  }
388
- if (isDaemonRunning()) {
389
- console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
390
- console.error(import_chalk.default.cyan(` Browser UI \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
391
- `));
690
+ let cloudRequestId = null;
691
+ let isRemoteLocked = false;
692
+ const cloudEnforced = approvers.cloud && !!creds?.apiKey;
693
+ if (cloudEnforced) {
392
694
  try {
393
- const daemonDecision = await askDaemon(toolName, args, meta);
394
- if (daemonDecision === "abandoned") {
395
- console.error(import_chalk.default.yellow("\n\u26A0\uFE0F Browser closed without a decision. Falling back..."));
396
- } else {
695
+ const envConfig = getActiveEnvironment(getConfig());
696
+ const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
697
+ if (!initResult.pending) {
397
698
  return {
398
- approved: daemonDecision === "allow",
399
- reason: daemonDecision === "deny" ? `Node9 blocked "${toolName}" \u2014 denied in browser.` : void 0,
400
- checkedBy: daemonDecision === "allow" ? "daemon" : void 0,
401
- blockedBy: daemonDecision === "deny" ? "local-decision" : void 0,
402
- 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)"
403
704
  };
404
705
  }
405
- } 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
+ import_chalk2.default.yellow(`
716
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + import_chalk2.default.dim(`
717
+ Falling back to local rules...
718
+ `)
719
+ );
406
720
  }
407
721
  }
408
- if (allowTerminalFallback && process.stdout.isTTY) {
409
- console.log(import_chalk.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
410
- console.log(`${import_chalk.default.bold("Action:")} ${import_chalk.default.red(toolName)}`);
411
- const argsPreview = JSON.stringify(args, null, 2);
412
- console.log(
413
- `${import_chalk.default.bold("Args:")}
414
- ${import_chalk.default.gray(argsPreview.length > 500 ? argsPreview.slice(0, 500) + "..." : argsPreview)}`
722
+ if (cloudEnforced && cloudRequestId) {
723
+ console.error(
724
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
725
+ );
726
+ console.error(import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.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
+ import_chalk2.default.dim(`
731
+ \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
732
+ `)
415
733
  );
416
- const controller = new AbortController();
417
- const TIMEOUT_MS = 3e4;
418
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
419
- try {
420
- const approved = await (0, import_prompts.confirm)(
421
- { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
422
- { signal: controller.signal }
423
- );
424
- clearTimeout(timer);
425
- return { approved };
426
- } catch {
427
- clearTimeout(timer);
428
- console.error(import_chalk.default.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
429
- return { approved: false };
430
- }
431
734
  }
432
- return {
433
- approved: false,
434
- noApprovalMechanism: true,
435
- reason: `Node9 blocked "${toolName}". No approval mechanism is active.`,
436
- blockedBy: "no-approval-mechanism",
437
- changeHint: `Start the approval daemon: node9 daemon --background
438
- Or connect to your team: node9 login <apiKey>`
439
- };
440
- }
441
- function listCredentialProfiles() {
442
- try {
443
- const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
444
- if (!import_fs.default.existsSync(credPath)) return [];
445
- const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
446
- if (!creds.apiKey) return Object.keys(creds).filter((k) => typeof creds[k] === "object");
447
- } 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
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
796
+ );
797
+ console.error(import_chalk2.default.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(import_chalk2.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
821
+ console.log(`${import_chalk2.default.bold("Action:")} ${import_chalk2.default.red(toolName)}`);
822
+ console.log(`${import_chalk2.default.bold("Flagged By:")} ${import_chalk2.default.yellow(explainableLabel)}`);
823
+ if (isRemoteLocked) {
824
+ console.log(import_chalk2.default.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
+ (0, import_prompts.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
+ };
448
872
  }
449
- 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;
450
913
  }
451
914
  function getConfig() {
452
915
  if (cachedConfig) return cachedConfig;
453
- const projectConfig = tryLoadConfig(import_path.default.join(process.cwd(), "node9.config.json"));
454
- if (projectConfig) {
455
- cachedConfig = buildConfig(projectConfig);
456
- return cachedConfig;
457
- }
458
- const globalConfig = tryLoadConfig(import_path.default.join(import_os.default.homedir(), ".node9", "config.json"));
459
- if (globalConfig) {
460
- cachedConfig = buildConfig(globalConfig);
461
- return cachedConfig;
462
- }
463
- cachedConfig = DEFAULT_CONFIG;
916
+ const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
917
+ const projectPath = import_path.default.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
+ };
464
959
  return cachedConfig;
465
960
  }
466
961
  function tryLoadConfig(filePath) {
467
962
  if (!import_fs.default.existsSync(filePath)) return null;
468
963
  try {
469
- const config = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
470
- validateConfig(config, filePath);
471
- return config;
964
+ return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
472
965
  } catch {
473
966
  return null;
474
967
  }
475
968
  }
476
- function validateConfig(config, path5) {
477
- const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
478
- Object.keys(config).forEach((key) => {
479
- if (!allowedTopLevel.includes(key))
480
- console.warn(import_chalk.default.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path5}`));
481
- });
482
- }
483
- function buildConfig(parsed) {
484
- const p = parsed.policy || {};
485
- const s = parsed.settings || {};
486
- return {
487
- settings: {
488
- mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
489
- autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
490
- },
491
- policy: {
492
- dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
493
- ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
494
- toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
495
- rules: p.rules ?? DEFAULT_CONFIG.policy.rules
496
- },
497
- environments: parsed.environments || {}
498
- };
499
- }
500
969
  function getActiveEnvironment(config) {
501
970
  const env = process.env.NODE_ENV || "development";
502
971
  return config.environments[env] ?? null;
503
972
  }
504
973
  function getCredentials() {
505
974
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
506
- if (process.env.NODE9_API_KEY)
975
+ if (process.env.NODE9_API_KEY) {
507
976
  return {
508
977
  apiKey: process.env.NODE9_API_KEY,
509
978
  apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
510
979
  };
511
- try {
512
- const projectConfigPath = import_path.default.join(process.cwd(), "node9.config.json");
513
- if (import_fs.default.existsSync(projectConfigPath)) {
514
- const projectConfig = JSON.parse(import_fs.default.readFileSync(projectConfigPath, "utf-8"));
515
- if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
516
- return {
517
- apiKey: projectConfig.apiKey,
518
- apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
519
- };
520
- }
521
- }
522
- } catch {
523
980
  }
524
981
  try {
525
982
  const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
@@ -544,14 +1001,32 @@ function getCredentials() {
544
1001
  }
545
1002
  return null;
546
1003
  }
547
- async function authorizeAction(toolName, args) {
548
- const result = await authorizeHeadless(toolName, args, true);
549
- 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: import_os.default.hostname(),
1018
+ cwd: process.cwd(),
1019
+ platform: import_os.default.platform()
1020
+ }
1021
+ }),
1022
+ signal: controller.signal
1023
+ }).catch(() => {
1024
+ });
550
1025
  }
551
- 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);
552
1029
  try {
553
- const controller = new AbortController();
554
- const timeout = setTimeout(() => controller.abort(), 35e3);
555
1030
  const response = await fetch(creds.apiUrl, {
556
1031
  method: "POST",
557
1032
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
@@ -569,46 +1044,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
569
1044
  }),
570
1045
  signal: controller.signal
571
1046
  });
1047
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1048
+ return await response.json();
1049
+ } finally {
572
1050
  clearTimeout(timeout);
573
- if (!response.ok) throw new Error("API fail");
574
- const data = await response.json();
575
- if (!data.pending) return data.approved;
576
- if (!data.requestId) return false;
577
- const statusUrl = `${creds.apiUrl}/status/${data.requestId}`;
578
- console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
579
- if (isDaemonRunning()) {
580
- console.error(
581
- import_chalk.default.cyan(" Browser UI \u2192 ") + import_chalk.default.bold(`http://${DAEMON_HOST}:${DAEMON_PORT}/`)
582
- );
583
- }
584
- console.error(import_chalk.default.cyan(" Dashboard \u2192 ") + import_chalk.default.bold("Mission Control > Flows"));
585
- console.error(import_chalk.default.gray(" Agent is paused. Approve or deny to continue.\n"));
586
- const POLL_INTERVAL_MS = 3e3;
587
- const POLL_DEADLINE = Date.now() + 5 * 60 * 1e3;
588
- while (Date.now() < POLL_DEADLINE) {
589
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
590
- try {
591
- const statusRes = await fetch(statusUrl, {
592
- headers: { Authorization: `Bearer ${creds.apiKey}` },
593
- signal: AbortSignal.timeout(5e3)
594
- });
595
- if (!statusRes.ok) continue;
596
- const { status } = await statusRes.json();
597
- if (status === "APPROVED") {
598
- console.error(import_chalk.default.green("\u2705 Approved \u2014 continuing.\n"));
599
- return true;
600
- }
601
- if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
602
- console.error(import_chalk.default.red("\u274C Denied \u2014 action blocked.\n"));
603
- return false;
604
- }
605
- } 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(import_chalk2.default.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(import_chalk2.default.red("\u274C Denied via Cloud.\n"));
1076
+ return { approved: false, reason };
606
1077
  }
1078
+ } catch {
607
1079
  }
608
- console.error(import_chalk.default.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
609
- 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);
610
1095
  } catch {
611
- return false;
612
1096
  }
613
1097
  }
614
1098
 
@@ -616,11 +1100,11 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
616
1100
  var import_fs2 = __toESM(require("fs"));
617
1101
  var import_path2 = __toESM(require("path"));
618
1102
  var import_os2 = __toESM(require("os"));
619
- var import_chalk2 = __toESM(require("chalk"));
1103
+ var import_chalk3 = __toESM(require("chalk"));
620
1104
  var import_prompts2 = require("@inquirer/prompts");
621
1105
  function printDaemonTip() {
622
1106
  console.log(
623
- import_chalk2.default.cyan("\n \u{1F4A1} Enable browser approvals (no API key needed):") + import_chalk2.default.green(" node9 daemon --background")
1107
+ import_chalk3.default.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + import_chalk3.default.white("\n To view your history or manage persistent rules, run:") + import_chalk3.default.green("\n node9 daemon --openui")
624
1108
  );
625
1109
  }
626
1110
  function fullPathCommand(subcommand) {
@@ -661,7 +1145,7 @@ async function setupClaude() {
661
1145
  matcher: ".*",
662
1146
  hooks: [{ type: "command", command: fullPathCommand("check"), timeout: 60 }]
663
1147
  });
664
- console.log(import_chalk2.default.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
1148
+ console.log(import_chalk3.default.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
665
1149
  anythingChanged = true;
666
1150
  }
667
1151
  const hasPostHook = settings.hooks.PostToolUse?.some(
@@ -671,9 +1155,9 @@ async function setupClaude() {
671
1155
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
672
1156
  settings.hooks.PostToolUse.push({
673
1157
  matcher: ".*",
674
- hooks: [{ type: "command", command: fullPathCommand("log") }]
1158
+ hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
675
1159
  });
676
- console.log(import_chalk2.default.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
1160
+ console.log(import_chalk3.default.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
677
1161
  anythingChanged = true;
678
1162
  }
679
1163
  if (anythingChanged) {
@@ -687,10 +1171,10 @@ async function setupClaude() {
687
1171
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
688
1172
  }
689
1173
  if (serversToWrap.length > 0) {
690
- console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
691
- console.log(import_chalk2.default.white(` ${mcpPath}`));
1174
+ console.log(import_chalk3.default.bold("The following existing entries will be modified:\n"));
1175
+ console.log(import_chalk3.default.white(` ${mcpPath}`));
692
1176
  for (const { name, originalCmd } of serversToWrap) {
693
- console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1177
+ console.log(import_chalk3.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
694
1178
  }
695
1179
  console.log("");
696
1180
  const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
@@ -700,22 +1184,22 @@ async function setupClaude() {
700
1184
  }
701
1185
  claudeConfig.mcpServers = servers;
702
1186
  writeJson(mcpPath, claudeConfig);
703
- console.log(import_chalk2.default.green(`
1187
+ console.log(import_chalk3.default.green(`
704
1188
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
705
1189
  anythingChanged = true;
706
1190
  } else {
707
- console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
1191
+ console.log(import_chalk3.default.yellow(" Skipped MCP server wrapping."));
708
1192
  }
709
1193
  console.log("");
710
1194
  }
711
1195
  if (!anythingChanged && serversToWrap.length === 0) {
712
- console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
1196
+ console.log(import_chalk3.default.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
713
1197
  printDaemonTip();
714
1198
  return;
715
1199
  }
716
1200
  if (anythingChanged) {
717
- console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
718
- console.log(import_chalk2.default.gray(" Restart Claude Code for changes to take effect."));
1201
+ console.log(import_chalk3.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
1202
+ console.log(import_chalk3.default.gray(" Restart Claude Code for changes to take effect."));
719
1203
  printDaemonTip();
720
1204
  }
721
1205
  }
@@ -735,10 +1219,15 @@ async function setupGemini() {
735
1219
  settings.hooks.BeforeTool.push({
736
1220
  matcher: ".*",
737
1221
  hooks: [
738
- { 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
+ }
739
1228
  ]
740
1229
  });
741
- console.log(import_chalk2.default.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
1230
+ console.log(import_chalk3.default.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
742
1231
  anythingChanged = true;
743
1232
  }
744
1233
  const hasAfterHook = Array.isArray(settings.hooks.AfterTool) && settings.hooks.AfterTool.some(
@@ -751,7 +1240,7 @@ async function setupGemini() {
751
1240
  matcher: ".*",
752
1241
  hooks: [{ name: "node9-log", type: "command", command: fullPathCommand("log") }]
753
1242
  });
754
- console.log(import_chalk2.default.green(" \u2705 AfterTool hook added \u2192 node9 log"));
1243
+ console.log(import_chalk3.default.green(" \u2705 AfterTool hook added \u2192 node9 log"));
755
1244
  anythingChanged = true;
756
1245
  }
757
1246
  if (anythingChanged) {
@@ -765,10 +1254,10 @@ async function setupGemini() {
765
1254
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
766
1255
  }
767
1256
  if (serversToWrap.length > 0) {
768
- console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
769
- console.log(import_chalk2.default.white(` ${settingsPath} (mcpServers)`));
1257
+ console.log(import_chalk3.default.bold("The following existing entries will be modified:\n"));
1258
+ console.log(import_chalk3.default.white(` ${settingsPath} (mcpServers)`));
770
1259
  for (const { name, originalCmd } of serversToWrap) {
771
- console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1260
+ console.log(import_chalk3.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
772
1261
  }
773
1262
  console.log("");
774
1263
  const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
@@ -778,22 +1267,22 @@ async function setupGemini() {
778
1267
  }
779
1268
  settings.mcpServers = servers;
780
1269
  writeJson(settingsPath, settings);
781
- console.log(import_chalk2.default.green(`
1270
+ console.log(import_chalk3.default.green(`
782
1271
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
783
1272
  anythingChanged = true;
784
1273
  } else {
785
- console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
1274
+ console.log(import_chalk3.default.yellow(" Skipped MCP server wrapping."));
786
1275
  }
787
1276
  console.log("");
788
1277
  }
789
1278
  if (!anythingChanged && serversToWrap.length === 0) {
790
- console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
1279
+ console.log(import_chalk3.default.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
791
1280
  printDaemonTip();
792
1281
  return;
793
1282
  }
794
1283
  if (anythingChanged) {
795
- console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
796
- console.log(import_chalk2.default.gray(" Restart Gemini CLI for changes to take effect."));
1284
+ console.log(import_chalk3.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
1285
+ console.log(import_chalk3.default.gray(" Restart Gemini CLI for changes to take effect."));
797
1286
  printDaemonTip();
798
1287
  }
799
1288
  }
@@ -812,7 +1301,7 @@ async function setupCursor() {
812
1301
  if (!hasPreHook) {
813
1302
  if (!hooksFile.hooks.preToolUse) hooksFile.hooks.preToolUse = [];
814
1303
  hooksFile.hooks.preToolUse.push({ command: fullPathCommand("check") });
815
- console.log(import_chalk2.default.green(" \u2705 preToolUse hook added \u2192 node9 check"));
1304
+ console.log(import_chalk3.default.green(" \u2705 preToolUse hook added \u2192 node9 check"));
816
1305
  anythingChanged = true;
817
1306
  }
818
1307
  const hasPostHook = hooksFile.hooks.postToolUse?.some(
@@ -821,7 +1310,7 @@ async function setupCursor() {
821
1310
  if (!hasPostHook) {
822
1311
  if (!hooksFile.hooks.postToolUse) hooksFile.hooks.postToolUse = [];
823
1312
  hooksFile.hooks.postToolUse.push({ command: fullPathCommand("log") });
824
- console.log(import_chalk2.default.green(" \u2705 postToolUse hook added \u2192 node9 log"));
1313
+ console.log(import_chalk3.default.green(" \u2705 postToolUse hook added \u2192 node9 log"));
825
1314
  anythingChanged = true;
826
1315
  }
827
1316
  if (anythingChanged) {
@@ -835,10 +1324,10 @@ async function setupCursor() {
835
1324
  serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
836
1325
  }
837
1326
  if (serversToWrap.length > 0) {
838
- console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
839
- console.log(import_chalk2.default.white(` ${mcpPath}`));
1327
+ console.log(import_chalk3.default.bold("The following existing entries will be modified:\n"));
1328
+ console.log(import_chalk3.default.white(` ${mcpPath}`));
840
1329
  for (const { name, originalCmd } of serversToWrap) {
841
- console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
1330
+ console.log(import_chalk3.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
842
1331
  }
843
1332
  console.log("");
844
1333
  const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
@@ -848,22 +1337,22 @@ async function setupCursor() {
848
1337
  }
849
1338
  mcpConfig.mcpServers = servers;
850
1339
  writeJson(mcpPath, mcpConfig);
851
- console.log(import_chalk2.default.green(`
1340
+ console.log(import_chalk3.default.green(`
852
1341
  \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
853
1342
  anythingChanged = true;
854
1343
  } else {
855
- console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
1344
+ console.log(import_chalk3.default.yellow(" Skipped MCP server wrapping."));
856
1345
  }
857
1346
  console.log("");
858
1347
  }
859
1348
  if (!anythingChanged && serversToWrap.length === 0) {
860
- console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
1349
+ console.log(import_chalk3.default.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
861
1350
  printDaemonTip();
862
1351
  return;
863
1352
  }
864
1353
  if (anythingChanged) {
865
- console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
866
- console.log(import_chalk2.default.gray(" Restart Cursor for changes to take effect."));
1354
+ console.log(import_chalk3.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
1355
+ console.log(import_chalk3.default.gray(" Restart Cursor for changes to take effect."));
867
1356
  printDaemonTip();
868
1357
  }
869
1358
  }
@@ -1132,6 +1621,27 @@ var ui_default = `<!doctype html>
1132
1621
  font-size: 12px;
1133
1622
  font-weight: 500;
1134
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
+ }
1135
1645
  button:hover:not(:disabled) {
1136
1646
  filter: brightness(1.15);
1137
1647
  transform: translateY(-1px);
@@ -1435,15 +1945,31 @@ var ui_default = `<!doctype html>
1435
1945
  <span class="slider"></span>
1436
1946
  </label>
1437
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>
1438
1964
  </div>
1439
1965
 
1440
1966
  <div class="panel">
1441
- <div class="panel-title">\u{1F4AC} Slack Approvals</div>
1967
+ <div class="panel-title">\u{1F4AC} Cloud Approvals</div>
1442
1968
  <div class="setting-row">
1443
1969
  <div class="setting-text">
1444
- <div class="setting-label">Enable Slack</div>
1970
+ <div class="setting-label">Enable Cloud</div>
1445
1971
  <div class="setting-desc">
1446
- Use Slack as the approval authority when a key is saved.
1972
+ Use Cloud/Slack as the approval authority when a key is saved.
1447
1973
  </div>
1448
1974
  </div>
1449
1975
  <label class="toggle">
@@ -1497,6 +2023,7 @@ var ui_default = `<!doctype html>
1497
2023
  const requests = new Set();
1498
2024
  let orgName = null;
1499
2025
  let autoDenyMs = 120000;
2026
+ let trustEnabled = false;
1500
2027
 
1501
2028
  function highlightSyntax(code) {
1502
2029
  if (typeof code !== 'string') return esc(code);
@@ -1549,6 +2076,21 @@ var ui_default = `<!doctype html>
1549
2076
  }, 200);
1550
2077
  }
1551
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
+
1552
2094
  function addCard(req) {
1553
2095
  if (requests.has(req.id)) return;
1554
2096
  requests.add(req.id);
@@ -1568,6 +2110,7 @@ var ui_default = `<!doctype html>
1568
2110
  card.id = 'c-' + req.id;
1569
2111
  const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
1570
2112
  const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
2113
+ const dis = isSlack ? 'disabled' : '';
1571
2114
  card.innerHTML = \`
1572
2115
  <div class="source-row">
1573
2116
  <span class="agent-badge">\${agentLabel}</span>
@@ -1577,11 +2120,15 @@ var ui_default = `<!doctype html>
1577
2120
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
1578
2121
  <span class="label">Input Payload</span>
1579
2122
  <pre>\${cmd}</pre>
1580
- <div class="actions">
1581
- <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${isSlack ? 'disabled' : ''}>Approve Execution</button>
1582
- <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${isSlack ? 'disabled' : ''}>Block Action</button>
1583
- <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${isSlack ? 'disabled' : ''}>Always Allow</button>
1584
- <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>
1585
2132
  </div>
1586
2133
  \`;
1587
2134
  list.appendChild(card);
@@ -1613,8 +2160,10 @@ var ui_default = `<!doctype html>
1613
2160
  autoDenyMs = data.autoDenyMs;
1614
2161
  if (orgName) {
1615
2162
  const b = document.getElementById('cloudBadge');
1616
- b.innerText = orgName;
1617
- b.classList.add('online');
2163
+ if (b) {
2164
+ b.innerText = orgName;
2165
+ b.classList.add('online');
2166
+ }
1618
2167
  }
1619
2168
  data.requests.forEach(addCard);
1620
2169
  });
@@ -1644,6 +2193,14 @@ var ui_default = `<!doctype html>
1644
2193
  }).catch(() => {});
1645
2194
  }
1646
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
+
1647
2204
  fetch('/settings')
1648
2205
  .then((r) => r.json())
1649
2206
  .then((s) => {
@@ -1656,6 +2213,13 @@ var ui_default = `<!doctype html>
1656
2213
  if (!s.autoStartDaemon && !s.autoStarted) {
1657
2214
  document.getElementById('warnBanner').classList.add('show');
1658
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
+ });
1659
2223
  })
1660
2224
  .catch(() => {});
1661
2225
 
@@ -1765,9 +2329,9 @@ var import_http = __toESM(require("http"));
1765
2329
  var import_fs3 = __toESM(require("fs"));
1766
2330
  var import_path3 = __toESM(require("path"));
1767
2331
  var import_os3 = __toESM(require("os"));
1768
- var import_child_process = require("child_process");
2332
+ var import_child_process2 = require("child_process");
1769
2333
  var import_crypto = require("crypto");
1770
- var import_chalk3 = __toESM(require("chalk"));
2334
+ var import_chalk4 = __toESM(require("chalk"));
1771
2335
  var DAEMON_PORT2 = 7391;
1772
2336
  var DAEMON_HOST2 = "127.0.0.1";
1773
2337
  var homeDir = import_os3.default.homedir();
@@ -1776,6 +2340,33 @@ var DECISIONS_FILE = import_path3.default.join(homeDir, ".node9", "decisions.jso
1776
2340
  var GLOBAL_CONFIG_FILE = import_path3.default.join(homeDir, ".node9", "config.json");
1777
2341
  var CREDENTIALS_FILE = import_path3.default.join(homeDir, ".node9", "credentials.json");
1778
2342
  var AUDIT_LOG_FILE = import_path3.default.join(homeDir, ".node9", "audit.log");
2343
+ var TRUST_FILE2 = import_path3.default.join(homeDir, ".node9", "trust.json");
2344
+ function atomicWriteSync2(filePath, data, options) {
2345
+ const dir = import_path3.default.dirname(filePath);
2346
+ if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
2347
+ const tmpPath = `${filePath}.${(0, import_crypto.randomUUID)()}.tmp`;
2348
+ import_fs3.default.writeFileSync(tmpPath, data, options);
2349
+ import_fs3.default.renameSync(tmpPath, filePath);
2350
+ }
2351
+ function writeTrustEntry(toolName, durationMs) {
2352
+ try {
2353
+ let trust = { entries: [] };
2354
+ try {
2355
+ if (import_fs3.default.existsSync(TRUST_FILE2))
2356
+ trust = JSON.parse(import_fs3.default.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
+ };
1779
2370
  var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
1780
2371
  function redactArgs(value) {
1781
2372
  if (!value || typeof value !== "object") return value;
@@ -1788,10 +2379,16 @@ function redactArgs(value) {
1788
2379
  }
1789
2380
  function appendAuditLog(data) {
1790
2381
  try {
1791
- 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
+ };
1792
2389
  const dir = import_path3.default.dirname(AUDIT_LOG_FILE);
1793
2390
  if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1794
- import_fs3.default.appendFileSync(AUDIT_LOG_FILE, entry);
2391
+ import_fs3.default.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
1795
2392
  } catch {
1796
2393
  }
1797
2394
  }
@@ -1816,21 +2413,6 @@ function getOrgName() {
1816
2413
  return null;
1817
2414
  }
1818
2415
  var autoStarted = process.env.NODE9_AUTO_STARTED === "1";
1819
- function readGlobalSettings() {
1820
- try {
1821
- if (import_fs3.default.existsSync(GLOBAL_CONFIG_FILE)) {
1822
- const config = JSON.parse(import_fs3.default.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
1823
- const s = config?.settings ?? {};
1824
- return {
1825
- autoStartDaemon: s.autoStartDaemon !== false,
1826
- slackEnabled: s.slackEnabled !== false,
1827
- agentMode: s.agentMode === true
1828
- };
1829
- }
1830
- } catch {
1831
- }
1832
- return { autoStartDaemon: true, slackEnabled: true, agentMode: false };
1833
- }
1834
2416
  function hasStoredSlackKey() {
1835
2417
  return import_fs3.default.existsSync(CREDENTIALS_FILE);
1836
2418
  }
@@ -1844,14 +2426,13 @@ function writeGlobalSetting(key, value) {
1844
2426
  }
1845
2427
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
1846
2428
  config.settings[key] = value;
1847
- const dir = import_path3.default.dirname(GLOBAL_CONFIG_FILE);
1848
- if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1849
- import_fs3.default.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
2429
+ atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
1850
2430
  }
1851
2431
  var pending = /* @__PURE__ */ new Map();
1852
2432
  var sseClients = /* @__PURE__ */ new Set();
1853
2433
  var abandonTimer = null;
1854
2434
  var daemonServer = null;
2435
+ var hadBrowserClient = false;
1855
2436
  function abandonPending() {
1856
2437
  abandonTimer = null;
1857
2438
  pending.forEach((entry, id) => {
@@ -1887,10 +2468,8 @@ data: ${JSON.stringify(data)}
1887
2468
  }
1888
2469
  function openBrowser(url) {
1889
2470
  try {
1890
- const opts = { stdio: "ignore" };
1891
- if (process.platform === "darwin") (0, import_child_process.execSync)(`open "${url}"`, opts);
1892
- else if (process.platform === "win32") (0, import_child_process.execSync)(`cmd /c start "" "${url}"`, opts);
1893
- else (0, import_child_process.execSync)(`xdg-open "${url}"`, opts);
2471
+ const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
2472
+ (0, import_child_process2.spawn)(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
1894
2473
  } catch {
1895
2474
  }
1896
2475
  }
@@ -1912,11 +2491,9 @@ function readPersistentDecisions() {
1912
2491
  }
1913
2492
  function writePersistentDecision(toolName, decision) {
1914
2493
  try {
1915
- const dir = import_path3.default.dirname(DECISIONS_FILE);
1916
- if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1917
2494
  const decisions = readPersistentDecisions();
1918
2495
  decisions[toolName] = decision;
1919
- import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2496
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
1920
2497
  broadcast("decisions", decisions);
1921
2498
  } catch {
1922
2499
  }
@@ -1926,6 +2503,22 @@ function startDaemon() {
1926
2503
  const internalToken = (0, import_crypto.randomUUID)();
1927
2504
  const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
1928
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
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
2514
+ } catch {
2515
+ }
2516
+ }
2517
+ process.exit(0);
2518
+ }, IDLE_TIMEOUT_MS);
2519
+ idleTimer.unref();
2520
+ }
2521
+ resetIdleTimer();
1929
2522
  const server = import_http.default.createServer(async (req, res) => {
1930
2523
  const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
1931
2524
  if (req.method === "GET" && pathname === "/") {
@@ -1942,6 +2535,7 @@ function startDaemon() {
1942
2535
  clearTimeout(abandonTimer);
1943
2536
  abandonTimer = null;
1944
2537
  }
2538
+ hadBrowserClient = true;
1945
2539
  sseClients.add(res);
1946
2540
  res.write(
1947
2541
  `event: init
@@ -1968,12 +2562,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
1968
2562
  return req.on("close", () => {
1969
2563
  sseClients.delete(res);
1970
2564
  if (sseClients.size === 0 && pending.size > 0) {
1971
- abandonTimer = setTimeout(abandonPending, 2e3);
2565
+ abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
1972
2566
  }
1973
2567
  });
1974
2568
  }
1975
2569
  if (req.method === "POST" && pathname === "/check") {
1976
2570
  try {
2571
+ resetIdleTimer();
1977
2572
  const body = await readBody(req);
1978
2573
  if (body.length > 65536) return res.writeHead(413).end();
1979
2574
  const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
@@ -1994,8 +2589,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1994
2589
  appendAuditLog({
1995
2590
  toolName: e.toolName,
1996
2591
  args: e.args,
1997
- decision: "auto-deny",
1998
- timestamp: Date.now()
2592
+ decision: "auto-deny"
1999
2593
  });
2000
2594
  if (e.waiter) e.waiter("deny");
2001
2595
  else e.earlyDecision = "deny";
@@ -2013,7 +2607,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2013
2607
  agent: entry.agent,
2014
2608
  mcpServer: entry.mcpServer
2015
2609
  });
2016
- 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}/`);
2017
2611
  res.writeHead(200, { "Content-Type": "application/json" });
2018
2612
  return res.end(JSON.stringify({ id }));
2019
2613
  } catch {
@@ -2040,17 +2634,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
2040
2634
  const id = pathname.split("/").pop();
2041
2635
  const entry = pending.get(id);
2042
2636
  if (!entry) return res.writeHead(404).end();
2043
- const { decision, persist } = JSON.parse(await readBody(req));
2044
- 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);
2045
2656
  appendAuditLog({
2046
2657
  toolName: entry.toolName,
2047
2658
  args: entry.args,
2048
- decision,
2049
- timestamp: Date.now()
2659
+ decision: resolvedDecision
2050
2660
  });
2051
2661
  clearTimeout(entry.timer);
2052
- if (entry.waiter) entry.waiter(decision);
2053
- else entry.earlyDecision = decision;
2662
+ if (entry.waiter) entry.waiter(resolvedDecision);
2663
+ else entry.earlyDecision = resolvedDecision;
2054
2664
  pending.delete(id);
2055
2665
  broadcast("remove", { id });
2056
2666
  res.writeHead(200);
@@ -2060,7 +2670,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2060
2670
  }
2061
2671
  }
2062
2672
  if (req.method === "GET" && pathname === "/settings") {
2063
- const s = readGlobalSettings();
2673
+ const s = getGlobalSettings();
2064
2674
  res.writeHead(200, { "Content-Type": "application/json" });
2065
2675
  return res.end(JSON.stringify({ ...s, autoStarted }));
2066
2676
  }
@@ -2072,7 +2682,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2072
2682
  if (data.autoStartDaemon !== void 0)
2073
2683
  writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
2074
2684
  if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
2075
- 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);
2076
2691
  res.writeHead(200);
2077
2692
  return res.end(JSON.stringify({ ok: true }));
2078
2693
  } catch {
@@ -2080,7 +2695,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2080
2695
  }
2081
2696
  }
2082
2697
  if (req.method === "GET" && pathname === "/slack-status") {
2083
- const s = readGlobalSettings();
2698
+ const s = getGlobalSettings();
2084
2699
  res.writeHead(200, { "Content-Type": "application/json" });
2085
2700
  return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
2086
2701
  }
@@ -2088,14 +2703,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2088
2703
  if (!validToken(req)) return res.writeHead(403).end();
2089
2704
  try {
2090
2705
  const { apiKey } = JSON.parse(await readBody(req));
2091
- if (!import_fs3.default.existsSync(import_path3.default.dirname(CREDENTIALS_FILE)))
2092
- import_fs3.default.mkdirSync(import_path3.default.dirname(CREDENTIALS_FILE), { recursive: true });
2093
- import_fs3.default.writeFileSync(
2706
+ atomicWriteSync2(
2094
2707
  CREDENTIALS_FILE,
2095
2708
  JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
2096
2709
  { mode: 384 }
2097
2710
  );
2098
- broadcast("slack-status", { hasKey: true, enabled: readGlobalSettings().slackEnabled });
2711
+ broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
2099
2712
  res.writeHead(200);
2100
2713
  return res.end(JSON.stringify({ ok: true }));
2101
2714
  } catch {
@@ -2108,7 +2721,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2108
2721
  const toolName = decodeURIComponent(pathname.split("/").pop());
2109
2722
  const decisions = readPersistentDecisions();
2110
2723
  delete decisions[toolName];
2111
- import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2724
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2112
2725
  broadcast("decisions", decisions);
2113
2726
  res.writeHead(200);
2114
2727
  return res.end(JSON.stringify({ ok: true }));
@@ -2127,8 +2740,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2127
2740
  appendAuditLog({
2128
2741
  toolName: entry.toolName,
2129
2742
  args: entry.args,
2130
- decision,
2131
- timestamp: Date.now()
2743
+ decision
2132
2744
  });
2133
2745
  clearTimeout(entry.timer);
2134
2746
  if (entry.waiter) entry.waiter(decision);
@@ -2148,25 +2760,43 @@ data: ${JSON.stringify(readPersistentDecisions())}
2148
2760
  res.writeHead(404).end();
2149
2761
  });
2150
2762
  daemonServer = server;
2763
+ server.on("error", (e) => {
2764
+ if (e.code === "EADDRINUSE") {
2765
+ try {
2766
+ if (import_fs3.default.existsSync(DAEMON_PID_FILE)) {
2767
+ const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2768
+ process.kill(pid, 0);
2769
+ return process.exit(0);
2770
+ }
2771
+ } catch {
2772
+ try {
2773
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
2774
+ } catch {
2775
+ }
2776
+ server.listen(DAEMON_PORT2, DAEMON_HOST2);
2777
+ return;
2778
+ }
2779
+ }
2780
+ console.error(import_chalk4.default.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
2781
+ process.exit(1);
2782
+ });
2151
2783
  server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
2152
- if (!import_fs3.default.existsSync(import_path3.default.dirname(DAEMON_PID_FILE)))
2153
- import_fs3.default.mkdirSync(import_path3.default.dirname(DAEMON_PID_FILE), { recursive: true });
2154
- import_fs3.default.writeFileSync(
2784
+ atomicWriteSync2(
2155
2785
  DAEMON_PID_FILE,
2156
2786
  JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
2157
2787
  { mode: 384 }
2158
2788
  );
2159
- console.log(import_chalk3.default.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
2789
+ console.log(import_chalk4.default.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
2160
2790
  });
2161
2791
  }
2162
2792
  function stopDaemon() {
2163
- if (!import_fs3.default.existsSync(DAEMON_PID_FILE)) return console.log(import_chalk3.default.yellow("Not running."));
2793
+ if (!import_fs3.default.existsSync(DAEMON_PID_FILE)) return console.log(import_chalk4.default.yellow("Not running."));
2164
2794
  try {
2165
2795
  const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2166
2796
  process.kill(pid, "SIGTERM");
2167
- console.log(import_chalk3.default.green("\u2705 Stopped."));
2797
+ console.log(import_chalk4.default.green("\u2705 Stopped."));
2168
2798
  } catch {
2169
- console.log(import_chalk3.default.gray("Cleaned up stale PID file."));
2799
+ console.log(import_chalk4.default.gray("Cleaned up stale PID file."));
2170
2800
  } finally {
2171
2801
  try {
2172
2802
  import_fs3.default.unlinkSync(DAEMON_PID_FILE);
@@ -2176,28 +2806,108 @@ function stopDaemon() {
2176
2806
  }
2177
2807
  function daemonStatus() {
2178
2808
  if (!import_fs3.default.existsSync(DAEMON_PID_FILE))
2179
- return console.log(import_chalk3.default.yellow("Node9 daemon: not running"));
2809
+ return console.log(import_chalk4.default.yellow("Node9 daemon: not running"));
2180
2810
  try {
2181
2811
  const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2182
2812
  process.kill(pid, 0);
2183
- console.log(import_chalk3.default.green("Node9 daemon: running"));
2813
+ console.log(import_chalk4.default.green("Node9 daemon: running"));
2184
2814
  } catch {
2185
- console.log(import_chalk3.default.yellow("Node9 daemon: not running (stale PID)"));
2815
+ console.log(import_chalk4.default.yellow("Node9 daemon: not running (stale PID)"));
2186
2816
  }
2187
2817
  }
2188
2818
 
2189
2819
  // src/cli.ts
2190
- var import_child_process2 = require("child_process");
2820
+ var import_child_process4 = require("child_process");
2191
2821
  var import_execa = require("execa");
2192
2822
  var import_execa2 = require("execa");
2193
- var import_chalk4 = __toESM(require("chalk"));
2823
+ var import_chalk5 = __toESM(require("chalk"));
2194
2824
  var import_readline = __toESM(require("readline"));
2825
+ var import_fs5 = __toESM(require("fs"));
2826
+ var import_path5 = __toESM(require("path"));
2827
+ var import_os5 = __toESM(require("os"));
2828
+
2829
+ // src/undo.ts
2830
+ var import_child_process3 = require("child_process");
2195
2831
  var import_fs4 = __toESM(require("fs"));
2196
2832
  var import_path4 = __toESM(require("path"));
2197
2833
  var import_os4 = __toESM(require("os"));
2834
+ var UNDO_LATEST_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
2835
+ async function createShadowSnapshot() {
2836
+ try {
2837
+ const cwd = process.cwd();
2838
+ if (!import_fs4.default.existsSync(import_path4.default.join(cwd, ".git"))) return null;
2839
+ const tempIndex = import_path4.default.join(cwd, ".git", `node9_index_${Date.now()}`);
2840
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
2841
+ (0, import_child_process3.spawnSync)("git", ["add", "-A"], { env });
2842
+ const treeRes = (0, import_child_process3.spawnSync)("git", ["write-tree"], { env });
2843
+ const treeHash = treeRes.stdout.toString().trim();
2844
+ if (import_fs4.default.existsSync(tempIndex)) import_fs4.default.unlinkSync(tempIndex);
2845
+ if (!treeHash || treeRes.status !== 0) return null;
2846
+ const commitRes = (0, import_child_process3.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 = import_path4.default.dirname(UNDO_LATEST_PATH);
2855
+ if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
2856
+ import_fs4.default.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 = (0, import_child_process3.spawnSync)("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
2869
+ if (restore.status !== 0) return false;
2870
+ const lsTree = (0, import_child_process3.spawnSync)("git", ["ls-tree", "-r", "--name-only", hash]);
2871
+ const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
2872
+ const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
2873
+ const untracked = (0, import_child_process3.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) && import_fs4.default.existsSync(file)) {
2876
+ import_fs4.default.unlinkSync(file);
2877
+ }
2878
+ }
2879
+ return true;
2880
+ } catch {
2881
+ return false;
2882
+ }
2883
+ }
2884
+ function getLatestSnapshotHash() {
2885
+ if (!import_fs4.default.existsSync(UNDO_LATEST_PATH)) return null;
2886
+ return import_fs4.default.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
2887
+ }
2888
+
2889
+ // src/cli.ts
2890
+ var import_prompts3 = require("@inquirer/prompts");
2198
2891
  var { version } = JSON.parse(
2199
- import_fs4.default.readFileSync(import_path4.default.join(__dirname, "../package.json"), "utf-8")
2892
+ import_fs5.default.readFileSync(import_path5.default.join(__dirname, "../package.json"), "utf-8")
2200
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
+ }
2201
2911
  function sanitize(value) {
2202
2912
  return value.replace(/[\x00-\x1F\x7F]/g, "");
2203
2913
  }
@@ -2205,23 +2915,33 @@ function openBrowserLocal() {
2205
2915
  const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2206
2916
  try {
2207
2917
  const opts = { stdio: "ignore" };
2208
- if (process.platform === "darwin") (0, import_child_process2.execSync)(`open "${url}"`, opts);
2209
- else if (process.platform === "win32") (0, import_child_process2.execSync)(`cmd /c start "" "${url}"`, opts);
2210
- else (0, import_child_process2.execSync)(`xdg-open "${url}"`, opts);
2918
+ if (process.platform === "darwin") (0, import_child_process4.execSync)(`open "${url}"`, opts);
2919
+ else if (process.platform === "win32") (0, import_child_process4.execSync)(`cmd /c start "" "${url}"`, opts);
2920
+ else (0, import_child_process4.execSync)(`xdg-open "${url}"`, opts);
2211
2921
  } catch {
2212
2922
  }
2213
2923
  }
2214
2924
  async function autoStartDaemonAndWait() {
2215
2925
  try {
2216
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], {
2926
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], {
2217
2927
  detached: true,
2218
2928
  stdio: "ignore",
2219
2929
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
2220
2930
  });
2221
2931
  child.unref();
2222
- for (let i = 0; i < 12; i++) {
2932
+ for (let i = 0; i < 20; i++) {
2223
2933
  await new Promise((r) => setTimeout(r, 250));
2224
- 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
+ }
2225
2945
  }
2226
2946
  } catch {
2227
2947
  }
@@ -2239,48 +2959,72 @@ async function runProxy(targetCommand) {
2239
2959
  if (stdout) executable = stdout.trim();
2240
2960
  } catch {
2241
2961
  }
2242
- console.log(import_chalk4.default.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2243
- const child = (0, import_child_process2.spawn)(executable, args, {
2962
+ console.log(import_chalk5.default.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2963
+ const child = (0, import_child_process4.spawn)(executable, args, {
2244
2964
  stdio: ["pipe", "pipe", "inherit"],
2965
+ // We control STDIN and STDOUT
2245
2966
  shell: true,
2246
- env: { ...process.env, FORCE_COLOR: "1", TERM: process.env.TERM || "xterm-256color" }
2967
+ env: { ...process.env, FORCE_COLOR: "1" }
2247
2968
  });
2248
- process.stdin.pipe(child.stdin);
2249
- const childOut = import_readline.default.createInterface({ input: child.stdout, terminal: false });
2250
- childOut.on("line", async (line) => {
2969
+ const agentIn = import_readline.default.createInterface({ input: process.stdin, terminal: false });
2970
+ agentIn.on("line", async (line) => {
2971
+ let message;
2251
2972
  try {
2252
- const message = JSON.parse(line);
2253
- 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 {
2254
2981
  const name = message.params?.name || message.params?.tool_name || "unknown";
2255
2982
  const toolArgs = message.params?.arguments || message.params?.tool_input || {};
2256
- const approved = await authorizeAction(sanitize(name), toolArgs);
2257
- if (!approved) {
2983
+ const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
2984
+ agent: "Proxy/MCP"
2985
+ });
2986
+ if (!result.approved) {
2258
2987
  const errorResponse = {
2259
2988
  jsonrpc: "2.0",
2260
2989
  id: message.id,
2261
- error: { code: -32e3, message: "Node9: Action denied." }
2990
+ error: {
2991
+ code: -32e3,
2992
+ message: `Node9: Action denied. ${result.reason || ""}`
2993
+ }
2262
2994
  };
2263
- child.stdin.write(JSON.stringify(errorResponse) + "\n");
2995
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
2264
2996
  return;
2265
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();
2266
3011
  }
2267
- process.stdout.write(line + "\n");
2268
- } catch {
2269
- process.stdout.write(line + "\n");
2270
3012
  }
3013
+ child.stdin.write(line + "\n");
2271
3014
  });
3015
+ child.stdout.pipe(process.stdout);
2272
3016
  child.on("exit", (code) => process.exit(code || 0));
2273
3017
  }
2274
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) => {
2275
3019
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
2276
- const credPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "credentials.json");
2277
- if (!import_fs4.default.existsSync(import_path4.default.dirname(credPath)))
2278
- import_fs4.default.mkdirSync(import_path4.default.dirname(credPath), { recursive: true });
3020
+ const credPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "credentials.json");
3021
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(credPath)))
3022
+ import_fs5.default.mkdirSync(import_path5.default.dirname(credPath), { recursive: true });
2279
3023
  const profileName = options.profile || "default";
2280
3024
  let existingCreds = {};
2281
3025
  try {
2282
- if (import_fs4.default.existsSync(credPath)) {
2283
- const raw = JSON.parse(import_fs4.default.readFileSync(credPath, "utf-8"));
3026
+ if (import_fs5.default.existsSync(credPath)) {
3027
+ const raw = JSON.parse(import_fs5.default.readFileSync(credPath, "utf-8"));
2284
3028
  if (raw.apiKey) {
2285
3029
  existingCreds = {
2286
3030
  default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
@@ -2292,60 +3036,65 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
2292
3036
  } catch {
2293
3037
  }
2294
3038
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
2295
- import_fs4.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3039
+ import_fs5.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
2296
3040
  if (profileName === "default") {
2297
- const configPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
3041
+ const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
2298
3042
  let config = {};
2299
3043
  try {
2300
- if (import_fs4.default.existsSync(configPath))
2301
- config = JSON.parse(import_fs4.default.readFileSync(configPath, "utf-8"));
3044
+ if (import_fs5.default.existsSync(configPath))
3045
+ config = JSON.parse(import_fs5.default.readFileSync(configPath, "utf-8"));
2302
3046
  } catch {
2303
3047
  }
2304
3048
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
2305
- config.settings.agentMode = !options.local;
2306
- if (!import_fs4.default.existsSync(import_path4.default.dirname(configPath)))
2307
- import_fs4.default.mkdirSync(import_path4.default.dirname(configPath), { recursive: true });
2308
- import_fs4.default.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 (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
3059
+ import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
3060
+ import_fs5.default.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
2309
3061
  }
2310
3062
  if (options.profile && profileName !== "default") {
2311
- console.log(import_chalk4.default.green(`\u2705 Profile "${profileName}" saved`));
2312
- console.log(import_chalk4.default.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
2313
- console.log(
2314
- import_chalk4.default.gray(
2315
- ` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
2316
- )
2317
- );
3063
+ console.log(import_chalk5.default.green(`\u2705 Profile "${profileName}" saved`));
3064
+ console.log(import_chalk5.default.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
2318
3065
  } else if (options.local) {
2319
- console.log(import_chalk4.default.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
2320
- console.log(import_chalk4.default.gray(` All decisions stay on this machine.`));
2321
- console.log(
2322
- import_chalk4.default.gray(` No data is sent to the cloud. Local config is the only authority.`)
2323
- );
2324
- console.log(import_chalk4.default.gray(` To enable cloud enforcement: node9 login <apiKey>`));
3066
+ console.log(import_chalk5.default.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
3067
+ console.log(import_chalk5.default.gray(` All decisions stay on this machine.`));
2325
3068
  } else {
2326
- console.log(import_chalk4.default.green(`\u2705 Logged in \u2014 agent mode`));
2327
- console.log(import_chalk4.default.gray(` Team policy enforced for all calls via Node9 cloud.`));
2328
- console.log(import_chalk4.default.gray(` To keep local control only: node9 login <apiKey> --local`));
3069
+ console.log(import_chalk5.default.green(`\u2705 Logged in \u2014 agent mode`));
3070
+ console.log(import_chalk5.default.gray(` Team policy enforced for all calls via Node9 cloud.`));
2329
3071
  }
2330
3072
  });
2331
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) => {
2332
3074
  if (target === "gemini") return await setupGemini();
2333
3075
  if (target === "claude") return await setupClaude();
2334
3076
  if (target === "cursor") return await setupCursor();
2335
- console.error(import_chalk4.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
3077
+ console.error(import_chalk5.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
2336
3078
  process.exit(1);
2337
3079
  });
2338
3080
  program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").action((options) => {
2339
- const configPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
2340
- if (import_fs4.default.existsSync(configPath) && !options.force) {
2341
- console.log(import_chalk4.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
2342
- console.log(import_chalk4.default.gray(` Run with --force to overwrite.`));
3081
+ const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
3082
+ if (import_fs5.default.existsSync(configPath) && !options.force) {
3083
+ console.log(import_chalk5.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
3084
+ console.log(import_chalk5.default.gray(` Run with --force to overwrite.`));
2343
3085
  return;
2344
3086
  }
2345
3087
  const defaultConfig = {
2346
3088
  version: "1.0",
2347
- 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
+ },
2348
3096
  policy: {
3097
+ sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
2349
3098
  dangerousWords: DANGEROUS_WORDS,
2350
3099
  ignoredTools: [
2351
3100
  "list_*",
@@ -2355,161 +3104,136 @@ program.command("init").description("Create ~/.node9/config.json with default po
2355
3104
  "read",
2356
3105
  "write",
2357
3106
  "edit",
2358
- "multiedit",
2359
3107
  "glob",
2360
3108
  "grep",
2361
3109
  "ls",
2362
3110
  "notebookread",
2363
3111
  "notebookedit",
2364
- "todoread",
2365
- "todowrite",
2366
3112
  "webfetch",
2367
3113
  "websearch",
2368
3114
  "exitplanmode",
2369
- "askuserquestion"
3115
+ "askuserquestion",
3116
+ "agent",
3117
+ "task*"
2370
3118
  ],
2371
3119
  toolInspection: {
2372
3120
  bash: "command",
2373
3121
  shell: "command",
2374
3122
  run_shell_command: "command",
2375
- "terminal.execute": "command"
3123
+ "terminal.execute": "command",
3124
+ "postgres:query": "sql"
2376
3125
  },
2377
3126
  rules: [
2378
3127
  {
2379
3128
  action: "rm",
2380
- 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
+ ]
2381
3140
  }
2382
3141
  ]
2383
3142
  }
2384
3143
  };
2385
- if (!import_fs4.default.existsSync(import_path4.default.dirname(configPath)))
2386
- import_fs4.default.mkdirSync(import_path4.default.dirname(configPath), { recursive: true });
2387
- import_fs4.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
2388
- console.log(import_chalk4.default.green(`\u2705 Global config created: ${configPath}`));
2389
- console.log(import_chalk4.default.gray(` Edit this file to add custom tool inspection or security rules.`));
3144
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
3145
+ import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
3146
+ import_fs5.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
3147
+ console.log(import_chalk5.default.green(`\u2705 Global config created: ${configPath}`));
3148
+ console.log(import_chalk5.default.gray(` Edit this file to add custom tool inspection or security rules.`));
2390
3149
  });
2391
3150
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
2392
3151
  const creds = getCredentials();
2393
3152
  const daemonRunning = isDaemonRunning();
2394
- const settings = getGlobalSettings();
3153
+ const mergedConfig = getConfig();
3154
+ const settings = mergedConfig.settings;
2395
3155
  console.log("");
2396
- if (creds && settings.agentMode) {
2397
- console.log(import_chalk4.default.green(" \u25CF Agent mode") + import_chalk4.default.gray(" \u2014 cloud team policy enforced"));
2398
- console.log(import_chalk4.default.gray(" All calls \u2192 Node9 cloud \u2192 Policy Studio rules apply"));
2399
- console.log(import_chalk4.default.gray(" Switch to local control: node9 login <apiKey> --local"));
2400
- } else if (creds && !settings.agentMode) {
3156
+ if (creds && settings.approvers.cloud) {
3157
+ console.log(import_chalk5.default.green(" \u25CF Agent mode") + import_chalk5.default.gray(" \u2014 cloud team policy enforced"));
3158
+ } else if (creds && !settings.approvers.cloud) {
2401
3159
  console.log(
2402
- import_chalk4.default.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 all decisions stay on this machine")
3160
+ import_chalk5.default.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + import_chalk5.default.gray(" \u2014 all decisions stay on this machine")
2403
3161
  );
3162
+ } else {
2404
3163
  console.log(
2405
- import_chalk4.default.gray(" No data is sent to the cloud. Local config is the only authority.")
3164
+ import_chalk5.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk5.default.gray(" \u2014 no API key (Local rules only)")
2406
3165
  );
2407
- console.log(import_chalk4.default.gray(" Enable cloud enforcement: node9 login <apiKey>"));
2408
- } else {
2409
- console.log(import_chalk4.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 no API key"));
2410
- console.log(import_chalk4.default.gray(" All decisions stay on this machine."));
2411
- console.log(import_chalk4.default.gray(" Connect to your team: node9 login <apiKey>"));
2412
3166
  }
2413
3167
  console.log("");
2414
3168
  if (daemonRunning) {
2415
3169
  console.log(
2416
- import_chalk4.default.green(" \u25CF Daemon running") + import_chalk4.default.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
3170
+ import_chalk5.default.green(" \u25CF Daemon running") + import_chalk5.default.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
2417
3171
  );
2418
3172
  } else {
2419
- console.log(import_chalk4.default.gray(" \u25CB Daemon stopped"));
2420
- console.log(import_chalk4.default.gray(" Start: node9 daemon --background"));
3173
+ console.log(import_chalk5.default.gray(" \u25CB Daemon stopped"));
2421
3174
  }
2422
- console.log("");
2423
- console.log(` Mode: ${import_chalk4.default.white(settings.mode)}`);
2424
- const projectConfig = import_path4.default.join(process.cwd(), "node9.config.json");
2425
- const globalConfig = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
2426
- const configSource = import_fs4.default.existsSync(projectConfig) ? projectConfig : import_fs4.default.existsSync(globalConfig) ? globalConfig : import_chalk4.default.gray("none (built-in defaults)");
2427
- console.log(` Config: ${import_chalk4.default.gray(configSource)}`);
2428
- const profiles = listCredentialProfiles();
2429
- if (profiles.length > 1) {
2430
- const activeProfile = process.env.NODE9_PROFILE || "default";
2431
- console.log("");
2432
- console.log(` Active profile: ${import_chalk4.default.white(activeProfile)}`);
3175
+ if (settings.enableUndo) {
2433
3176
  console.log(
2434
- ` All profiles: ${profiles.map((p) => p === activeProfile ? import_chalk4.default.green(p) : import_chalk4.default.gray(p)).join(import_chalk4.default.gray(", "))}`
3177
+ import_chalk5.default.magenta(" \u25CF Undo Engine") + import_chalk5.default.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
2435
3178
  );
2436
- console.log(import_chalk4.default.gray(` Switch: NODE9_PROFILE=<name> claude`));
2437
- }
2438
- const decisionsFile = import_path4.default.join(import_os4.default.homedir(), ".node9", "decisions.json");
2439
- let decisions = {};
2440
- try {
2441
- if (import_fs4.default.existsSync(decisionsFile))
2442
- decisions = JSON.parse(import_fs4.default.readFileSync(decisionsFile, "utf-8"));
2443
- } catch {
2444
3179
  }
2445
- const keys = Object.keys(decisions);
2446
3180
  console.log("");
2447
- if (keys.length > 0) {
2448
- console.log(` Persistent decisions (${keys.length}):`);
2449
- keys.forEach((tool) => {
2450
- const d = decisions[tool];
2451
- const badge = d === "allow" ? import_chalk4.default.green("allow") : import_chalk4.default.red("deny");
2452
- console.log(` ${import_chalk4.default.gray("\xB7")} ${tool.padEnd(35)} ${badge}`);
2453
- });
2454
- console.log(import_chalk4.default.gray("\n Manage: node9 daemon --openui \u2192 Decisions tab"));
2455
- } else {
2456
- console.log(import_chalk4.default.gray(" No persistent decisions set"));
3181
+ const modeLabel = settings.mode === "audit" ? import_chalk5.default.blue("audit") : settings.mode === "strict" ? import_chalk5.default.red("strict") : import_chalk5.default.white("standard");
3182
+ console.log(` Mode: ${modeLabel}`);
3183
+ const projectConfig = import_path5.default.join(process.cwd(), "node9.config.json");
3184
+ const globalConfig = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
3185
+ console.log(
3186
+ ` Local: ${import_fs5.default.existsSync(projectConfig) ? import_chalk5.default.green("Active (node9.config.json)") : import_chalk5.default.gray("Not present")}`
3187
+ );
3188
+ console.log(
3189
+ ` Global: ${import_fs5.default.existsSync(globalConfig) ? import_chalk5.default.green("Active (~/.node9/config.json)") : import_chalk5.default.gray("Not present")}`
3190
+ );
3191
+ if (mergedConfig.policy.sandboxPaths.length > 0) {
3192
+ console.log(
3193
+ ` Sandbox: ${import_chalk5.default.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
3194
+ );
2457
3195
  }
2458
- const auditLogPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "audit.log");
2459
- try {
2460
- if (import_fs4.default.existsSync(auditLogPath)) {
2461
- const lines = import_fs4.default.readFileSync(auditLogPath, "utf-8").split("\n").filter((l) => l.trim().length > 0);
2462
- console.log("");
2463
- console.log(
2464
- ` \u{1F4CB} Local Audit Log: ` + import_chalk4.default.white(`${lines.length} agent action${lines.length !== 1 ? "s" : ""} recorded`) + import_chalk4.default.gray(` (cat ~/.node9/audit.log to view)`)
2465
- );
2466
- }
2467
- } 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
+ import_chalk5.default.yellow(` \u23F8 PAUSED until ${expiresAt}`) + import_chalk5.default.gray(" \u2014 all tool calls allowed")
3202
+ );
2468
3203
  }
2469
3204
  console.log("");
2470
3205
  });
2471
- program.command("daemon").description("Run the local approval server (browser HITL for free tier)").addHelpText(
2472
- "after",
2473
- "\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"
2474
- ).argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option(
2475
- "-o, --openui",
2476
- "Start in background and open browser (or just open browser if already running)"
2477
- ).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(
2478
3207
  async (action, options) => {
2479
3208
  const cmd = (action ?? "start").toLowerCase();
2480
3209
  if (cmd === "stop") return stopDaemon();
2481
3210
  if (cmd === "status") return daemonStatus();
2482
3211
  if (cmd !== "start" && action !== void 0) {
2483
- console.error(import_chalk4.default.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
3212
+ console.error(import_chalk5.default.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
2484
3213
  process.exit(1);
2485
3214
  }
2486
3215
  if (options.openui) {
2487
3216
  if (isDaemonRunning()) {
2488
3217
  openBrowserLocal();
2489
- console.log(import_chalk4.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
3218
+ console.log(import_chalk5.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2490
3219
  process.exit(0);
2491
3220
  }
2492
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
3221
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2493
3222
  child.unref();
2494
3223
  for (let i = 0; i < 12; i++) {
2495
3224
  await new Promise((r) => setTimeout(r, 250));
2496
3225
  if (isDaemonRunning()) break;
2497
3226
  }
2498
3227
  openBrowserLocal();
2499
- console.log(import_chalk4.default.green(`
3228
+ console.log(import_chalk5.default.green(`
2500
3229
  \u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
2501
- console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2502
3230
  process.exit(0);
2503
3231
  }
2504
3232
  if (options.background) {
2505
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
3233
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2506
3234
  child.unref();
2507
- console.log(import_chalk4.default.green(`
3235
+ console.log(import_chalk5.default.green(`
2508
3236
  \u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
2509
- console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2510
- console.log(import_chalk4.default.gray(` node9 daemon status \u2014 check if running`));
2511
- console.log(import_chalk4.default.gray(` node9 daemon stop \u2014 stop it
2512
- `));
2513
3237
  process.exit(0);
2514
3238
  }
2515
3239
  startDaemon();
@@ -2519,53 +3243,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2519
3243
  const processPayload = async (raw) => {
2520
3244
  try {
2521
3245
  if (!raw || raw.trim() === "") process.exit(0);
2522
- if (process.env.NODE9_DEBUG === "1") {
2523
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
2524
- if (!import_fs4.default.existsSync(import_path4.default.dirname(logPath)))
2525
- import_fs4.default.mkdirSync(import_path4.default.dirname(logPath), { recursive: true });
2526
- import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
2527
- `);
2528
- import_fs4.default.appendFileSync(
2529
- logPath,
2530
- `[${(/* @__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 = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
3253
+ const errMsg = err instanceof Error ? err.message : String(err);
3254
+ import_fs5.default.appendFileSync(
3255
+ logPath,
3256
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
3257
+ RAW: ${raw}
2531
3258
  `
2532
- );
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 = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
3274
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
3275
+ import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
3276
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
3277
+ `);
2533
3278
  }
2534
- const payload = JSON.parse(raw);
2535
3279
  const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
2536
3280
  const toolInput = payload.tool_input ?? payload.args ?? {};
2537
- 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";
2538
3282
  const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
2539
3283
  const mcpServer = mcpMatch?.[1];
2540
3284
  const sendBlock = (msg, result2) => {
2541
- const BLOCKED_BY_LABELS = {
2542
- "team-policy": "team policy (set by your admin)",
2543
- "persistent-deny": "you set this tool to always deny",
2544
- "local-config": "your local config (dangerousWords / rules)",
2545
- "local-decision": "you denied it in the browser",
2546
- "no-approval-mechanism": "no approval method is configured"
2547
- };
2548
- console.error(import_chalk4.default.red(`
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");
3287
+ console.error(import_chalk5.default.red(`
2549
3288
  \u{1F6D1} Node9 blocked "${toolName}"`));
2550
- if (result2?.blockedBy) {
2551
- console.error(
2552
- import_chalk4.default.gray(
2553
- ` Blocked by: ${BLOCKED_BY_LABELS[result2.blockedBy] ?? result2.blockedBy}`
2554
- )
2555
- );
2556
- }
2557
- if (result2?.changeHint) {
2558
- console.error(import_chalk4.default.cyan(` To change: ${result2.changeHint}`));
2559
- }
3289
+ console.error(import_chalk5.default.gray(` Triggered by: ${blockedByContext}`));
3290
+ if (result2?.changeHint) console.error(import_chalk5.default.cyan(` To change: ${result2.changeHint}`));
2560
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
+ }
2561
3312
  process.stdout.write(
2562
3313
  JSON.stringify({
2563
3314
  decision: "block",
2564
- reason: msg,
3315
+ reason: aiFeedbackMessage,
3316
+ // This is the core instruction
2565
3317
  hookSpecificOutput: {
2566
3318
  hookEventName: "PreToolUse",
2567
3319
  permissionDecision: "deny",
2568
- permissionDecisionReason: msg
3320
+ permissionDecisionReason: aiFeedbackMessage
2569
3321
  }
2570
3322
  }) + "\n"
2571
3323
  );
@@ -2576,36 +3328,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2576
3328
  return;
2577
3329
  }
2578
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
+ }
2579
3344
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
2580
3345
  if (result.approved) {
2581
- if (result.checkedBy) {
3346
+ if (result.checkedBy)
2582
3347
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
2583
3348
  `);
2584
- }
2585
3349
  process.exit(0);
2586
3350
  }
2587
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && getGlobalSettings().autoStartDaemon) {
2588
- console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
3351
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
3352
+ console.error(import_chalk5.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2589
3353
  const daemonReady = await autoStartDaemonAndWait();
2590
3354
  if (daemonReady) {
2591
3355
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
2592
3356
  if (retry.approved) {
2593
- if (retry.checkedBy) {
3357
+ if (retry.checkedBy)
2594
3358
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
2595
3359
  `);
2596
- }
2597
3360
  process.exit(0);
2598
3361
  }
2599
- sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, retry);
3362
+ sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
3363
+ ...retry,
3364
+ blockedByLabel: retry.blockedByLabel
3365
+ });
2600
3366
  return;
2601
3367
  }
2602
3368
  }
2603
- sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, result);
3369
+ sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
3370
+ ...result,
3371
+ blockedByLabel: result.blockedByLabel
3372
+ });
2604
3373
  } catch (err) {
2605
3374
  if (process.env.NODE9_DEBUG === "1") {
2606
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
3375
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
2607
3376
  const errMsg = err instanceof Error ? err.message : String(err);
2608
- import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
3377
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
2609
3378
  `);
2610
3379
  }
2611
3380
  process.exit(0);
@@ -2616,48 +3385,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2616
3385
  } else {
2617
3386
  let raw = "";
2618
3387
  let processed = false;
3388
+ let inactivityTimer = null;
2619
3389
  const done = async () => {
2620
3390
  if (processed) return;
2621
3391
  processed = true;
3392
+ if (inactivityTimer) clearTimeout(inactivityTimer);
2622
3393
  if (!raw.trim()) return process.exit(0);
2623
3394
  await processPayload(raw);
2624
3395
  };
2625
3396
  process.stdin.setEncoding("utf-8");
2626
- process.stdin.on("data", (chunk) => raw += chunk);
2627
- process.stdin.on("end", () => void done());
2628
- 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);
2629
3406
  }
2630
3407
  });
2631
3408
  program.command("log").description("PostToolUse hook \u2014 records executed tool calls").argument("[data]", "JSON string of the tool call").action(async (data) => {
2632
- const logPayload = (raw) => {
3409
+ const logPayload = async (raw) => {
2633
3410
  try {
2634
3411
  if (!raw || raw.trim() === "") process.exit(0);
2635
3412
  const payload = JSON.parse(raw);
3413
+ const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
3414
+ const rawInput = payload.tool_input ?? payload.args ?? {};
2636
3415
  const entry = {
2637
3416
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2638
- tool: sanitize(payload.tool_name ?? "unknown"),
2639
- 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"
2640
3421
  };
2641
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "audit.log");
2642
- if (!import_fs4.default.existsSync(import_path4.default.dirname(logPath)))
2643
- import_fs4.default.mkdirSync(import_path4.default.dirname(logPath), { recursive: true });
2644
- import_fs4.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
3422
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
3423
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
3424
+ import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
3425
+ import_fs5.default.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
+ }
2645
3438
  } catch {
2646
3439
  }
2647
3440
  process.exit(0);
2648
3441
  };
2649
3442
  if (data) {
2650
- logPayload(data);
3443
+ await logPayload(data);
2651
3444
  } else {
2652
3445
  let raw = "";
2653
3446
  process.stdin.setEncoding("utf-8");
2654
3447
  process.stdin.on("data", (chunk) => raw += chunk);
2655
- process.stdin.on("end", () => logPayload(raw));
3448
+ process.stdin.on("end", () => {
3449
+ void logPayload(raw);
3450
+ });
2656
3451
  setTimeout(() => {
2657
3452
  if (!raw) process.exit(0);
2658
3453
  }, 500);
2659
3454
  }
2660
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
+ import_chalk5.default.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(import_chalk5.default.yellow(`
3469
+ \u23F8 Node9 paused until ${expiresAt}`));
3470
+ console.log(import_chalk5.default.gray(` All tool calls will be allowed without review.`));
3471
+ console.log(import_chalk5.default.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(import_chalk5.default.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
3478
+ return;
3479
+ }
3480
+ resumeNode9();
3481
+ console.log(import_chalk5.default.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
3482
+ });
2661
3483
  var HOOK_BASED_AGENTS = {
2662
3484
  claude: "claude",
2663
3485
  gemini: "gemini",
@@ -2669,39 +3491,21 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2669
3491
  if (HOOK_BASED_AGENTS[firstArg] !== void 0) {
2670
3492
  const target = HOOK_BASED_AGENTS[firstArg];
2671
3493
  console.error(
2672
- import_chalk4.default.yellow(`
3494
+ import_chalk5.default.yellow(`
2673
3495
  \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
2674
3496
  );
3497
+ console.error(import_chalk5.default.white(`
3498
+ "${target}" uses its own hook system. Use:`));
2675
3499
  console.error(
2676
- import_chalk4.default.white(`
2677
- "${target}" is an interactive terminal app \u2014 it needs a real`)
2678
- );
2679
- console.error(
2680
- import_chalk4.default.white(` TTY and communicates via its own hook system, not JSON-RPC.
2681
- `)
2682
- );
2683
- console.error(import_chalk4.default.bold(` Use the hook-based integration instead:
2684
- `));
2685
- console.error(
2686
- import_chalk4.default.green(` node9 addto ${target} `) + import_chalk4.default.gray("# one-time setup")
2687
- );
2688
- console.error(
2689
- import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# run normally \u2014 Node9 hooks fire automatically")
2690
- );
2691
- console.error(import_chalk4.default.white(`
2692
- For browser approval popups (no API key required):`));
2693
- console.error(
2694
- import_chalk4.default.green(` node9 daemon --background`) + import_chalk4.default.gray("# start (no second terminal needed)")
2695
- );
2696
- console.error(
2697
- import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# Node9 will open browser on dangerous actions\n")
3500
+ import_chalk5.default.green(` node9 addto ${target} `) + import_chalk5.default.gray("# one-time setup")
2698
3501
  );
3502
+ console.error(import_chalk5.default.green(` ${target} `) + import_chalk5.default.gray("# run normally"));
2699
3503
  process.exit(1);
2700
3504
  }
2701
3505
  const fullCommand = commandArgs.join(" ");
2702
3506
  let result = await authorizeHeadless("shell", { command: fullCommand });
2703
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getGlobalSettings().autoStartDaemon) {
2704
- console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
3507
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
3508
+ console.error(import_chalk5.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2705
3509
  const daemonReady = await autoStartDaemonAndWait();
2706
3510
  if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
2707
3511
  }
@@ -2710,39 +3514,44 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2710
3514
  }
2711
3515
  if (!result.approved) {
2712
3516
  console.error(
2713
- import_chalk4.default.red(`
3517
+ import_chalk5.default.red(`
2714
3518
  \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
2715
3519
  );
2716
- if (result.blockedBy) {
2717
- const BLOCKED_BY_LABELS = {
2718
- "team-policy": "Team policy (Node9 cloud)",
2719
- "persistent-deny": "Persistent deny rule",
2720
- "local-config": "Local config",
2721
- "local-decision": "Browser UI decision",
2722
- "no-approval-mechanism": "No approval mechanism available"
2723
- };
2724
- console.error(
2725
- import_chalk4.default.gray(` Blocked by: ${BLOCKED_BY_LABELS[result.blockedBy] ?? result.blockedBy}`)
2726
- );
2727
- }
2728
- if (result.changeHint) {
2729
- console.error(import_chalk4.default.cyan(` To change: ${result.changeHint}`));
2730
- }
2731
3520
  process.exit(1);
2732
3521
  }
2733
- console.error(import_chalk4.default.green("\n\u2705 Approved \u2014 running command...\n"));
3522
+ console.error(import_chalk5.default.green("\n\u2705 Approved \u2014 running command...\n"));
2734
3523
  await runProxy(fullCommand);
2735
3524
  } else {
2736
3525
  program.help();
2737
3526
  }
2738
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(import_chalk5.default.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
3532
+ return;
3533
+ }
3534
+ console.log(import_chalk5.default.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
3535
+ console.log(import_chalk5.default.white(`Target Snapshot: ${import_chalk5.default.gray(hash.slice(0, 7))}`));
3536
+ const proceed = await (0, import_prompts3.confirm)({
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(import_chalk5.default.green("\u2705 Project reverted successfully.\n"));
3543
+ } else {
3544
+ console.error(import_chalk5.default.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
3545
+ }
3546
+ }
3547
+ });
2739
3548
  process.on("unhandledRejection", (reason) => {
2740
3549
  const isCheckHook = process.argv[2] === "check";
2741
3550
  if (isCheckHook) {
2742
- if (process.env.NODE9_DEBUG === "1") {
2743
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
3551
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
3552
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
2744
3553
  const msg = reason instanceof Error ? reason.message : String(reason);
2745
- import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
3554
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
2746
3555
  `);
2747
3556
  }
2748
3557
  process.exit(0);