@node9/proxy 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +66 -319
  2. package/dist/cli.js +1372 -540
  3. package/dist/cli.mjs +1372 -540
  4. package/dist/index.js +744 -260
  5. package/dist/index.mjs +744 -260
  6. package/package.json +15 -8
package/dist/cli.js CHANGED
@@ -34,6 +34,270 @@ 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 isTestEnv = () => {
41
+ 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";
42
+ };
43
+ function sendDesktopNotification(title, body) {
44
+ if (isTestEnv()) return;
45
+ try {
46
+ const safeTitle = title.replace(/"/g, '\\"');
47
+ const safeBody = body.replace(/"/g, '\\"');
48
+ if (process.platform === "darwin") {
49
+ const script = `display notification "${safeBody}" with title "${safeTitle}"`;
50
+ (0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
51
+ } else if (process.platform === "linux") {
52
+ (0, import_child_process.spawn)("notify-send", [safeTitle, safeBody, "--icon=dialog-warning"], {
53
+ detached: true,
54
+ stdio: "ignore"
55
+ }).unref();
56
+ }
57
+ } catch {
58
+ }
59
+ }
60
+ function formatArgs(args) {
61
+ if (args === null || args === void 0) return "(none)";
62
+ if (typeof args !== "object" || Array.isArray(args)) {
63
+ const str = typeof args === "string" ? args : JSON.stringify(args);
64
+ return str.length > 200 ? str.slice(0, 200) + "\u2026" : str;
65
+ }
66
+ const entries = Object.entries(args).filter(
67
+ ([, v]) => v !== null && v !== void 0 && v !== ""
68
+ );
69
+ if (entries.length === 0) return "(none)";
70
+ const MAX_FIELDS = 5;
71
+ const MAX_VALUE_LEN = 120;
72
+ const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
73
+ const str = typeof val === "string" ? val : JSON.stringify(val);
74
+ const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
75
+ return ` ${key}: ${truncated}`;
76
+ });
77
+ if (entries.length > MAX_FIELDS) {
78
+ lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
79
+ }
80
+ return lines.join("\n");
81
+ }
82
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
83
+ if (isTestEnv()) return "deny";
84
+ if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
85
+ console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
86
+ console.log(`[DEBUG Native] isTestEnv check:`, {
87
+ VITEST: process.env.VITEST,
88
+ NODE_ENV: process.env.NODE_ENV,
89
+ CI: process.env.CI,
90
+ isTest: isTestEnv()
91
+ });
92
+ }
93
+ const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
94
+ let message = "";
95
+ if (locked) {
96
+ message += `\u26A1 Awaiting remote approval via Slack. Local override is disabled.
97
+ `;
98
+ message += `\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
99
+ `;
100
+ }
101
+ message += `Tool: ${toolName}
102
+ `;
103
+ message += `Agent: ${agent || "AI Agent"}
104
+ `;
105
+ if (explainableLabel) {
106
+ message += `Reason: ${explainableLabel}
107
+ `;
108
+ }
109
+ message += `
110
+ Arguments:
111
+ ${formatArgs(args)}`;
112
+ if (!locked) {
113
+ message += `
114
+
115
+ Enter = Allow | Click "Block" to deny`;
116
+ }
117
+ const safeMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "'");
118
+ const safeTitle = title.replace(/"/g, '\\"');
119
+ return new Promise((resolve) => {
120
+ let childProcess = null;
121
+ const onAbort = () => {
122
+ if (childProcess) {
123
+ try {
124
+ process.kill(childProcess.pid, "SIGKILL");
125
+ } catch {
126
+ }
127
+ }
128
+ resolve("deny");
129
+ };
130
+ if (signal) {
131
+ if (signal.aborted) return resolve("deny");
132
+ signal.addEventListener("abort", onAbort);
133
+ }
134
+ const cleanup = () => {
135
+ if (signal) signal.removeEventListener("abort", onAbort);
136
+ };
137
+ try {
138
+ if (process.platform === "darwin") {
139
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
140
+ const script = `
141
+ tell application "System Events"
142
+ activate
143
+ display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
144
+ end tell`;
145
+ childProcess = (0, import_child_process.spawn)("osascript", ["-e", script]);
146
+ let output = "";
147
+ childProcess.stdout?.on("data", (d) => output += d.toString());
148
+ childProcess.on("close", (code) => {
149
+ cleanup();
150
+ if (locked) return resolve("deny");
151
+ if (code === 0) {
152
+ if (output.includes("Always Allow")) return resolve("always_allow");
153
+ if (output.includes("Allow")) return resolve("allow");
154
+ }
155
+ resolve("deny");
156
+ });
157
+ } else if (process.platform === "linux") {
158
+ const argsList = locked ? [
159
+ "--info",
160
+ "--title",
161
+ title,
162
+ "--text",
163
+ safeMessage,
164
+ "--ok-label",
165
+ "Waiting for Slack\u2026",
166
+ "--timeout",
167
+ "300"
168
+ ] : [
169
+ "--question",
170
+ "--title",
171
+ title,
172
+ "--text",
173
+ safeMessage,
174
+ "--ok-label",
175
+ "Allow",
176
+ "--cancel-label",
177
+ "Block",
178
+ "--extra-button",
179
+ "Always Allow",
180
+ "--timeout",
181
+ "300"
182
+ ];
183
+ childProcess = (0, import_child_process.spawn)("zenity", argsList);
184
+ let output = "";
185
+ childProcess.stdout?.on("data", (d) => output += d.toString());
186
+ childProcess.on("close", (code) => {
187
+ cleanup();
188
+ if (locked) return resolve("deny");
189
+ if (output.trim() === "Always Allow") return resolve("always_allow");
190
+ if (code === 0) return resolve("allow");
191
+ resolve("deny");
192
+ });
193
+ } else if (process.platform === "win32") {
194
+ const buttonType = locked ? "OK" : "YesNo";
195
+ const ps = `
196
+ Add-Type -AssemblyName PresentationFramework;
197
+ $res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
198
+ if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
199
+ childProcess = (0, import_child_process.spawn)("powershell", ["-Command", ps]);
200
+ childProcess.on("close", (code) => {
201
+ cleanup();
202
+ if (locked) return resolve("deny");
203
+ resolve(code === 0 ? "allow" : "deny");
204
+ });
205
+ } else {
206
+ cleanup();
207
+ resolve("deny");
208
+ }
209
+ } catch {
210
+ cleanup();
211
+ resolve("deny");
212
+ }
213
+ });
214
+ }
215
+
216
+ // src/core.ts
217
+ var PAUSED_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "PAUSED");
218
+ var TRUST_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "trust.json");
219
+ function checkPause() {
220
+ try {
221
+ if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
222
+ const state = JSON.parse(import_fs.default.readFileSync(PAUSED_FILE, "utf-8"));
223
+ if (state.expiry > 0 && Date.now() >= state.expiry) {
224
+ try {
225
+ import_fs.default.unlinkSync(PAUSED_FILE);
226
+ } catch {
227
+ }
228
+ return { paused: false };
229
+ }
230
+ return { paused: true, expiresAt: state.expiry, duration: state.duration };
231
+ } catch {
232
+ return { paused: false };
233
+ }
234
+ }
235
+ function atomicWriteSync(filePath, data, options) {
236
+ const dir = import_path.default.dirname(filePath);
237
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
238
+ const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
239
+ import_fs.default.writeFileSync(tmpPath, data, options);
240
+ import_fs.default.renameSync(tmpPath, filePath);
241
+ }
242
+ function pauseNode9(durationMs, durationStr) {
243
+ const state = { expiry: Date.now() + durationMs, duration: durationStr };
244
+ atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
245
+ }
246
+ function resumeNode9() {
247
+ try {
248
+ if (import_fs.default.existsSync(PAUSED_FILE)) import_fs.default.unlinkSync(PAUSED_FILE);
249
+ } catch {
250
+ }
251
+ }
252
+ function getActiveTrustSession(toolName) {
253
+ try {
254
+ if (!import_fs.default.existsSync(TRUST_FILE)) return false;
255
+ const trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
256
+ const now = Date.now();
257
+ const active = trust.entries.filter((e) => e.expiry > now);
258
+ if (active.length !== trust.entries.length) {
259
+ import_fs.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
260
+ }
261
+ return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+ function writeTrustSession(toolName, durationMs) {
267
+ try {
268
+ let trust = { entries: [] };
269
+ try {
270
+ if (import_fs.default.existsSync(TRUST_FILE)) {
271
+ trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
272
+ }
273
+ } catch {
274
+ }
275
+ const now = Date.now();
276
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
277
+ trust.entries.push({ tool: toolName, expiry: now + durationMs });
278
+ atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
279
+ } catch (err) {
280
+ if (process.env.NODE9_DEBUG === "1") {
281
+ console.error("[Node9 Trust Error]:", err);
282
+ }
283
+ }
284
+ }
285
+ function appendAuditModeEntry(toolName, args) {
286
+ try {
287
+ const entry = JSON.stringify({
288
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
289
+ tool: toolName,
290
+ args,
291
+ decision: "would-have-blocked",
292
+ source: "audit-mode"
293
+ });
294
+ const logPath = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
295
+ const dir = import_path.default.dirname(logPath);
296
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
297
+ import_fs.default.appendFileSync(logPath, entry + "\n");
298
+ } catch {
299
+ }
300
+ }
37
301
  var DANGEROUS_WORDS = [
38
302
  "delete",
39
303
  "drop",
@@ -51,10 +315,6 @@ var DANGEROUS_WORDS = [
51
315
  function tokenize(toolName) {
52
316
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
53
317
  }
54
- function containsDangerousWord(toolName, dangerousWords) {
55
- const tokens = tokenize(toolName);
56
- return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
57
- }
58
318
  function matchesPattern(text, patterns) {
59
319
  const p = Array.isArray(patterns) ? patterns : [patterns];
60
320
  if (p.length === 0) return false;
@@ -65,9 +325,9 @@ function matchesPattern(text, patterns) {
65
325
  const withoutDotSlash = text.replace(/^\.\//, "");
66
326
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
67
327
  }
68
- function getNestedValue(obj, path5) {
328
+ function getNestedValue(obj, path6) {
69
329
  if (!obj || typeof obj !== "object") return null;
70
- return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
330
+ return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
71
331
  }
72
332
  function extractShellCommand(toolName, args, toolInspection) {
73
333
  const patterns = Object.keys(toolInspection);
@@ -159,8 +419,15 @@ function redactSecrets(text) {
159
419
  return redacted;
160
420
  }
161
421
  var DEFAULT_CONFIG = {
162
- settings: { mode: "standard" },
422
+ settings: {
423
+ mode: "standard",
424
+ autoStartDaemon: true,
425
+ enableUndo: false,
426
+ enableHookLogDebug: false,
427
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
428
+ },
163
429
  policy: {
430
+ sandboxPaths: [],
164
431
  dangerousWords: DANGEROUS_WORDS,
165
432
  ignoredTools: [
166
433
  "list_*",
@@ -168,34 +435,19 @@ var DEFAULT_CONFIG = {
168
435
  "read_*",
169
436
  "describe_*",
170
437
  "read",
171
- "write",
172
- "edit",
173
- "multiedit",
174
- "glob",
175
438
  "grep",
176
439
  "ls",
177
- "notebookread",
178
- "notebookedit",
179
- "todoread",
180
- "todowrite",
181
- "webfetch",
182
- "websearch",
183
- "exitplanmode",
184
440
  "askuserquestion"
185
441
  ],
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
- ]
442
+ toolInspection: { bash: "command", shell: "command" },
443
+ rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
195
444
  },
196
445
  environments: {}
197
446
  };
198
447
  var cachedConfig = null;
448
+ function _resetConfigCache() {
449
+ cachedConfig = null;
450
+ }
199
451
  function getGlobalSettings() {
200
452
  try {
201
453
  const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
@@ -206,18 +458,19 @@ function getGlobalSettings() {
206
458
  mode: settings.mode || "standard",
207
459
  autoStartDaemon: settings.autoStartDaemon !== false,
208
460
  slackEnabled: settings.slackEnabled !== false,
209
- // agentMode defaults to false — user must explicitly opt in via `node9 login`
210
- agentMode: settings.agentMode === true
461
+ enableTrustSessions: settings.enableTrustSessions === true,
462
+ allowGlobalPause: settings.allowGlobalPause !== false
211
463
  };
212
464
  }
213
465
  } catch {
214
466
  }
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;
467
+ return {
468
+ mode: "standard",
469
+ autoStartDaemon: true,
470
+ slackEnabled: true,
471
+ enableTrustSessions: false,
472
+ allowGlobalPause: true
473
+ };
221
474
  }
222
475
  function getInternalToken() {
223
476
  try {
@@ -230,51 +483,83 @@ function getInternalToken() {
230
483
  return null;
231
484
  }
232
485
  }
233
- async function evaluatePolicy(toolName, args) {
486
+ async function evaluatePolicy(toolName, args, agent) {
234
487
  const config = getConfig();
235
- if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
488
+ if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
489
+ let allTokens = [];
490
+ let actionTokens = [];
491
+ let pathTokens = [];
236
492
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
237
493
  if (shellCommand) {
238
- const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
494
+ const analyzed = await analyzeShellCommand(shellCommand);
495
+ allTokens = analyzed.allTokens;
496
+ actionTokens = analyzed.actions;
497
+ pathTokens = analyzed.paths;
239
498
  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
- }
499
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
500
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
255
501
  }
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
- })
502
+ } else {
503
+ allTokens = tokenize(toolName);
504
+ actionTokens = [toolName];
505
+ }
506
+ const isManual = agent === "Terminal";
507
+ if (isManual) {
508
+ const NUCLEAR_COMMANDS = [
509
+ "drop",
510
+ "destroy",
511
+ "purge",
512
+ "rmdir",
513
+ "format",
514
+ "truncate",
515
+ "alter",
516
+ "grant",
517
+ "revoke",
518
+ "docker"
519
+ ];
520
+ const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
521
+ if (!hasNuclear) return { decision: "allow" };
522
+ }
523
+ if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
524
+ const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
525
+ if (allInSandbox) return { decision: "allow" };
526
+ }
527
+ for (const action of actionTokens) {
528
+ const rule = config.policy.rules.find(
529
+ (r) => r.action === action || matchesPattern(action, r.action)
266
530
  );
267
- if (isDangerous2) return "review";
268
- if (config.settings.mode === "strict") return "review";
269
- return "allow";
531
+ if (rule) {
532
+ if (pathTokens.length > 0) {
533
+ const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
534
+ if (anyBlocked)
535
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
536
+ const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
537
+ if (allAllowed) return { decision: "allow" };
538
+ }
539
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
540
+ }
270
541
  }
271
- const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
272
- if (isDangerous || config.settings.mode === "strict") {
542
+ const isDangerous = allTokens.some(
543
+ (token) => config.policy.dangerousWords.some((word) => {
544
+ const w = word.toLowerCase();
545
+ if (token === w) return true;
546
+ try {
547
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
548
+ } catch {
549
+ return false;
550
+ }
551
+ })
552
+ );
553
+ if (isDangerous) {
554
+ const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
555
+ return { decision: "review", blockedByLabel: label };
556
+ }
557
+ if (config.settings.mode === "strict") {
273
558
  const envConfig = getActiveEnvironment(config);
274
- if (envConfig?.requireApproval === false) return "allow";
275
- return "review";
559
+ if (envConfig?.requireApproval === false) return { decision: "allow" };
560
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
276
561
  }
277
- return "allow";
562
+ return { decision: "allow" };
278
563
  }
279
564
  function isIgnoredTool(toolName) {
280
565
  const config = getConfig();
@@ -305,22 +590,40 @@ function getPersistentDecision(toolName) {
305
590
  }
306
591
  return null;
307
592
  }
308
- async function askDaemon(toolName, args, meta) {
593
+ async function askDaemon(toolName, args, meta, signal) {
309
594
  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";
595
+ const checkCtrl = new AbortController();
596
+ const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
597
+ const onAbort = () => checkCtrl.abort();
598
+ if (signal) signal.addEventListener("abort", onAbort);
599
+ try {
600
+ const checkRes = await fetch(`${base}/check`, {
601
+ method: "POST",
602
+ headers: { "Content-Type": "application/json" },
603
+ body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
604
+ signal: checkCtrl.signal
605
+ });
606
+ if (!checkRes.ok) throw new Error("Daemon fail");
607
+ const { id } = await checkRes.json();
608
+ const waitCtrl = new AbortController();
609
+ const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
610
+ const onWaitAbort = () => waitCtrl.abort();
611
+ if (signal) signal.addEventListener("abort", onWaitAbort);
612
+ try {
613
+ const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
614
+ if (!waitRes.ok) return "deny";
615
+ const { decision } = await waitRes.json();
616
+ if (decision === "allow") return "allow";
617
+ if (decision === "abandoned") return "abandoned";
618
+ return "deny";
619
+ } finally {
620
+ clearTimeout(waitTimer);
621
+ if (signal) signal.removeEventListener("abort", onWaitAbort);
622
+ }
623
+ } finally {
624
+ clearTimeout(checkTimer);
625
+ if (signal) signal.removeEventListener("abort", onAbort);
626
+ }
324
627
  }
325
628
  async function notifyDaemonViewer(toolName, args, meta) {
326
629
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
@@ -350,176 +653,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
350
653
  });
351
654
  }
352
655
  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" };
656
+ if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
657
+ const pauseState = checkPause();
658
+ if (pauseState.paused) return { approved: true, checkedBy: "paused" };
659
+ const creds = getCredentials();
660
+ const config = getConfig();
661
+ const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
662
+ const approvers = {
663
+ ...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
664
+ };
665
+ if (isTestEnv2) {
666
+ approvers.native = false;
667
+ approvers.browser = false;
668
+ approvers.terminal = false;
669
+ }
670
+ const isManual = meta?.agent === "Terminal";
671
+ let explainableLabel = "Local Config";
672
+ if (config.settings.mode === "audit") {
673
+ if (!isIgnoredTool(toolName)) {
674
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
675
+ if (policyResult.decision === "review") {
676
+ appendAuditModeEntry(toolName, args);
677
+ sendDesktopNotification(
678
+ "Node9 Audit Mode",
679
+ `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
680
+ );
681
+ }
682
+ }
683
+ return { approved: true, checkedBy: "audit" };
684
+ }
685
+ if (!isIgnoredTool(toolName)) {
686
+ if (getActiveTrustSession(toolName)) {
687
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
688
+ return { approved: true, checkedBy: "trust" };
689
+ }
690
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
691
+ if (policyResult.decision === "allow") {
692
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
693
+ return { approved: true, checkedBy: "local-policy" };
694
+ }
695
+ explainableLabel = policyResult.blockedByLabel || "Local Config";
359
696
  const persistent = getPersistentDecision(toolName);
360
- if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
361
- if (persistent === "deny")
697
+ if (persistent === "allow") {
698
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
699
+ return { approved: true, checkedBy: "persistent" };
700
+ }
701
+ if (persistent === "deny") {
362
702
  return {
363
703
  approved: false,
364
- reason: `Node9: "${toolName}" is set to always deny.`,
704
+ reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
365
705
  blockedBy: "persistent-deny",
366
- changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
706
+ blockedByLabel: "Persistent User Rule"
367
707
  };
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
708
  }
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
- };
709
+ } else {
710
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
711
+ return { approved: true };
387
712
  }
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
- `));
713
+ let cloudRequestId = null;
714
+ let isRemoteLocked = false;
715
+ const cloudEnforced = approvers.cloud && !!creds?.apiKey;
716
+ if (cloudEnforced) {
392
717
  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 {
718
+ const envConfig = getActiveEnvironment(getConfig());
719
+ const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
720
+ if (!initResult.pending) {
397
721
  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
722
+ approved: !!initResult.approved,
723
+ reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
724
+ checkedBy: initResult.approved ? "cloud" : void 0,
725
+ blockedBy: initResult.approved ? void 0 : "team-policy",
726
+ blockedByLabel: "Organization Policy (SaaS)"
403
727
  };
404
728
  }
405
- } catch {
729
+ cloudRequestId = initResult.requestId || null;
730
+ isRemoteLocked = !!initResult.remoteApprovalOnly;
731
+ explainableLabel = "Organization Policy (SaaS)";
732
+ } catch (err) {
733
+ const error = err;
734
+ const isAuthError = error.message.includes("401") || error.message.includes("403");
735
+ const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
736
+ 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;
737
+ console.error(
738
+ import_chalk.default.yellow(`
739
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + import_chalk.default.dim(`
740
+ Falling back to local rules...
741
+ `)
742
+ );
406
743
  }
407
744
  }
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)}`
745
+ if (cloudEnforced && cloudRequestId) {
746
+ console.error(
747
+ import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
748
+ );
749
+ console.error(import_chalk.default.cyan(" Dashboard \u2192 ") + import_chalk.default.bold("Mission Control > Activity Feed\n"));
750
+ } else if (!cloudEnforced) {
751
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
752
+ console.error(
753
+ import_chalk.default.dim(`
754
+ \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
755
+ `)
415
756
  );
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
757
  }
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 {
758
+ const abortController = new AbortController();
759
+ const { signal } = abortController;
760
+ const racePromises = [];
761
+ let viewerId = null;
762
+ const internalToken = getInternalToken();
763
+ if (cloudEnforced && cloudRequestId) {
764
+ racePromises.push(
765
+ (async () => {
766
+ try {
767
+ if (isDaemonRunning() && internalToken) {
768
+ viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
769
+ }
770
+ const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
771
+ return {
772
+ approved: cloudResult.approved,
773
+ reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
774
+ checkedBy: cloudResult.approved ? "cloud" : void 0,
775
+ blockedBy: cloudResult.approved ? void 0 : "team-policy",
776
+ blockedByLabel: "Organization Policy (SaaS)"
777
+ };
778
+ } catch (err) {
779
+ const error = err;
780
+ if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
781
+ throw err;
782
+ }
783
+ })()
784
+ );
785
+ }
786
+ if (approvers.native && !isManual) {
787
+ racePromises.push(
788
+ (async () => {
789
+ const decision = await askNativePopup(
790
+ toolName,
791
+ args,
792
+ meta?.agent,
793
+ explainableLabel,
794
+ isRemoteLocked,
795
+ signal
796
+ );
797
+ if (decision === "always_allow") {
798
+ writeTrustSession(toolName, 36e5);
799
+ return { approved: true, checkedBy: "trust" };
800
+ }
801
+ const isApproved = decision === "allow";
802
+ return {
803
+ approved: isApproved,
804
+ reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
805
+ checkedBy: isApproved ? "daemon" : void 0,
806
+ blockedBy: isApproved ? void 0 : "local-decision",
807
+ blockedByLabel: "User Decision (Native)"
808
+ };
809
+ })()
810
+ );
811
+ }
812
+ if (approvers.browser && isDaemonRunning()) {
813
+ racePromises.push(
814
+ (async () => {
815
+ try {
816
+ if (!approvers.native && !cloudEnforced) {
817
+ console.error(
818
+ import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
819
+ );
820
+ console.error(import_chalk.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
821
+ `));
822
+ }
823
+ const daemonDecision = await askDaemon(toolName, args, meta, signal);
824
+ if (daemonDecision === "abandoned") throw new Error("Abandoned");
825
+ const isApproved = daemonDecision === "allow";
826
+ return {
827
+ approved: isApproved,
828
+ reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
829
+ checkedBy: isApproved ? "daemon" : void 0,
830
+ blockedBy: isApproved ? void 0 : "local-decision",
831
+ blockedByLabel: "User Decision (Browser)"
832
+ };
833
+ } catch (err) {
834
+ throw err;
835
+ }
836
+ })()
837
+ );
838
+ }
839
+ if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
840
+ racePromises.push(
841
+ (async () => {
842
+ try {
843
+ console.log(import_chalk.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
844
+ console.log(`${import_chalk.default.bold("Action:")} ${import_chalk.default.red(toolName)}`);
845
+ console.log(`${import_chalk.default.bold("Flagged By:")} ${import_chalk.default.yellow(explainableLabel)}`);
846
+ if (isRemoteLocked) {
847
+ console.log(import_chalk.default.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
848
+ `));
849
+ await new Promise((_, reject) => {
850
+ signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
851
+ });
852
+ }
853
+ const TIMEOUT_MS = 6e4;
854
+ let timer;
855
+ const result = await new Promise((resolve, reject) => {
856
+ timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
857
+ (0, import_prompts.confirm)(
858
+ { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
859
+ { signal }
860
+ ).then(resolve).catch(reject);
861
+ });
862
+ clearTimeout(timer);
863
+ return {
864
+ approved: result,
865
+ reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
866
+ checkedBy: result ? "terminal" : void 0,
867
+ blockedBy: result ? void 0 : "local-decision",
868
+ blockedByLabel: "User Decision (Terminal)"
869
+ };
870
+ } catch (err) {
871
+ const error = err;
872
+ if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
873
+ throw err;
874
+ if (error.message === "Terminal Timeout") {
875
+ return {
876
+ approved: false,
877
+ reason: "The terminal prompt timed out without a human response.",
878
+ blockedBy: "local-decision"
879
+ };
880
+ }
881
+ throw err;
882
+ }
883
+ })()
884
+ );
885
+ }
886
+ if (racePromises.length === 0) {
887
+ return {
888
+ approved: false,
889
+ noApprovalMechanism: true,
890
+ reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
891
+ REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
892
+ blockedBy: "no-approval-mechanism",
893
+ blockedByLabel: explainableLabel
894
+ };
895
+ }
896
+ const finalResult = await new Promise((resolve) => {
897
+ let resolved = false;
898
+ let failures = 0;
899
+ const total = racePromises.length;
900
+ const finish = (res) => {
901
+ if (!resolved) {
902
+ resolved = true;
903
+ abortController.abort();
904
+ if (viewerId && internalToken) {
905
+ resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
906
+ () => null
907
+ );
908
+ }
909
+ resolve(res);
910
+ }
911
+ };
912
+ for (const p of racePromises) {
913
+ p.then(finish).catch((err) => {
914
+ if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
915
+ return;
916
+ if (err.message === "Abandoned") {
917
+ finish({
918
+ approved: false,
919
+ reason: "Browser dashboard closed without making a decision.",
920
+ blockedBy: "local-decision",
921
+ blockedByLabel: "Browser Dashboard (Abandoned)"
922
+ });
923
+ return;
924
+ }
925
+ failures++;
926
+ if (failures === total && !resolved) {
927
+ finish({ approved: false, reason: "All approval channels failed or disconnected." });
928
+ }
929
+ });
930
+ }
931
+ });
932
+ if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
933
+ await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
448
934
  }
449
- return [];
935
+ return finalResult;
450
936
  }
451
937
  function getConfig() {
452
938
  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;
939
+ const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
940
+ const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
941
+ const globalConfig = tryLoadConfig(globalPath);
942
+ const projectConfig = tryLoadConfig(projectPath);
943
+ const mergedSettings = {
944
+ ...DEFAULT_CONFIG.settings,
945
+ approvers: { ...DEFAULT_CONFIG.settings.approvers }
946
+ };
947
+ const mergedPolicy = {
948
+ sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
949
+ dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
950
+ ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
951
+ toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
952
+ rules: [...DEFAULT_CONFIG.policy.rules]
953
+ };
954
+ const applyLayer = (source) => {
955
+ if (!source) return;
956
+ const s = source.settings || {};
957
+ const p = source.policy || {};
958
+ if (s.mode !== void 0) mergedSettings.mode = s.mode;
959
+ if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
960
+ if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
961
+ if (s.enableHookLogDebug !== void 0)
962
+ mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
963
+ if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
964
+ if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
965
+ if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
966
+ if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
967
+ if (p.toolInspection)
968
+ mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
969
+ if (p.rules) mergedPolicy.rules.push(...p.rules);
970
+ };
971
+ applyLayer(globalConfig);
972
+ applyLayer(projectConfig);
973
+ if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
974
+ mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
975
+ mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
976
+ mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
977
+ cachedConfig = {
978
+ settings: mergedSettings,
979
+ policy: mergedPolicy,
980
+ environments: {}
981
+ };
464
982
  return cachedConfig;
465
983
  }
466
984
  function tryLoadConfig(filePath) {
467
985
  if (!import_fs.default.existsSync(filePath)) return null;
468
986
  try {
469
- const config = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
470
- validateConfig(config, filePath);
471
- return config;
987
+ return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
472
988
  } catch {
473
989
  return null;
474
990
  }
475
991
  }
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
992
  function getActiveEnvironment(config) {
501
993
  const env = process.env.NODE_ENV || "development";
502
994
  return config.environments[env] ?? null;
503
995
  }
504
996
  function getCredentials() {
505
997
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
506
- if (process.env.NODE9_API_KEY)
998
+ if (process.env.NODE9_API_KEY) {
507
999
  return {
508
1000
  apiKey: process.env.NODE9_API_KEY,
509
1001
  apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
510
1002
  };
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
1003
  }
524
1004
  try {
525
1005
  const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
@@ -544,14 +1024,32 @@ function getCredentials() {
544
1024
  }
545
1025
  return null;
546
1026
  }
547
- async function authorizeAction(toolName, args) {
548
- const result = await authorizeHeadless(toolName, args, true);
549
- return result.approved;
1027
+ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1028
+ const controller = new AbortController();
1029
+ setTimeout(() => controller.abort(), 5e3);
1030
+ fetch(`${creds.apiUrl}/audit`, {
1031
+ method: "POST",
1032
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1033
+ body: JSON.stringify({
1034
+ toolName,
1035
+ args,
1036
+ checkedBy,
1037
+ context: {
1038
+ agent: meta?.agent,
1039
+ mcpServer: meta?.mcpServer,
1040
+ hostname: import_os.default.hostname(),
1041
+ cwd: process.cwd(),
1042
+ platform: import_os.default.platform()
1043
+ }
1044
+ }),
1045
+ signal: controller.signal
1046
+ }).catch(() => {
1047
+ });
550
1048
  }
551
- async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
1049
+ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
1050
+ const controller = new AbortController();
1051
+ const timeout = setTimeout(() => controller.abort(), 1e4);
552
1052
  try {
553
- const controller = new AbortController();
554
- const timeout = setTimeout(() => controller.abort(), 35e3);
555
1053
  const response = await fetch(creds.apiUrl, {
556
1054
  method: "POST",
557
1055
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
@@ -569,46 +1067,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
569
1067
  }),
570
1068
  signal: controller.signal
571
1069
  });
1070
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1071
+ return await response.json();
1072
+ } finally {
572
1073
  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 {
1074
+ }
1075
+ }
1076
+ async function pollNode9SaaS(requestId, creds, signal) {
1077
+ const statusUrl = `${creds.apiUrl}/status/${requestId}`;
1078
+ const POLL_INTERVAL_MS = 1e3;
1079
+ const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
1080
+ while (Date.now() < POLL_DEADLINE) {
1081
+ if (signal.aborted) throw new Error("Aborted");
1082
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1083
+ try {
1084
+ const pollCtrl = new AbortController();
1085
+ const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
1086
+ const statusRes = await fetch(statusUrl, {
1087
+ headers: { Authorization: `Bearer ${creds.apiKey}` },
1088
+ signal: pollCtrl.signal
1089
+ });
1090
+ clearTimeout(pollTimer);
1091
+ if (!statusRes.ok) continue;
1092
+ const { status, reason } = await statusRes.json();
1093
+ if (status === "APPROVED") {
1094
+ console.error(import_chalk.default.green("\u2705 Approved via Cloud.\n"));
1095
+ return { approved: true, reason };
1096
+ }
1097
+ if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
1098
+ console.error(import_chalk.default.red("\u274C Denied via Cloud.\n"));
1099
+ return { approved: false, reason };
606
1100
  }
1101
+ } catch {
607
1102
  }
608
- console.error(import_chalk.default.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
609
- return false;
1103
+ }
1104
+ return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
1105
+ }
1106
+ async function resolveNode9SaaS(requestId, creds, approved) {
1107
+ try {
1108
+ const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
1109
+ const ctrl = new AbortController();
1110
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
1111
+ await fetch(resolveUrl, {
1112
+ method: "PATCH",
1113
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1114
+ body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
1115
+ signal: ctrl.signal
1116
+ });
1117
+ clearTimeout(timer);
610
1118
  } catch {
611
- return false;
612
1119
  }
613
1120
  }
614
1121
 
@@ -620,7 +1127,7 @@ var import_chalk2 = __toESM(require("chalk"));
620
1127
  var import_prompts2 = require("@inquirer/prompts");
621
1128
  function printDaemonTip() {
622
1129
  console.log(
623
- import_chalk2.default.cyan("\n \u{1F4A1} Enable browser approvals (no API key needed):") + import_chalk2.default.green(" node9 daemon --background")
1130
+ import_chalk2.default.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + import_chalk2.default.white("\n To view your history or manage persistent rules, run:") + import_chalk2.default.green("\n node9 daemon --openui")
624
1131
  );
625
1132
  }
626
1133
  function fullPathCommand(subcommand) {
@@ -671,7 +1178,7 @@ async function setupClaude() {
671
1178
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
672
1179
  settings.hooks.PostToolUse.push({
673
1180
  matcher: ".*",
674
- hooks: [{ type: "command", command: fullPathCommand("log") }]
1181
+ hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
675
1182
  });
676
1183
  console.log(import_chalk2.default.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
677
1184
  anythingChanged = true;
@@ -735,7 +1242,12 @@ async function setupGemini() {
735
1242
  settings.hooks.BeforeTool.push({
736
1243
  matcher: ".*",
737
1244
  hooks: [
738
- { name: "node9-check", type: "command", command: fullPathCommand("check"), timeout: 6e4 }
1245
+ {
1246
+ name: "node9-check",
1247
+ type: "command",
1248
+ command: fullPathCommand("check"),
1249
+ timeout: 6e5
1250
+ }
739
1251
  ]
740
1252
  });
741
1253
  console.log(import_chalk2.default.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
@@ -1132,6 +1644,27 @@ var ui_default = `<!doctype html>
1132
1644
  font-size: 12px;
1133
1645
  font-weight: 500;
1134
1646
  }
1647
+ .btn-trust {
1648
+ background: rgba(240, 136, 62, 0.1);
1649
+ border: 1px solid rgba(240, 136, 62, 0.35);
1650
+ color: var(--primary);
1651
+ font-size: 12px;
1652
+ font-weight: 600;
1653
+ }
1654
+ .btn-trust:hover:not(:disabled) {
1655
+ background: rgba(240, 136, 62, 0.2);
1656
+ filter: none;
1657
+ transform: translateY(-1px);
1658
+ }
1659
+ .trust-row {
1660
+ display: none;
1661
+ grid-column: span 2;
1662
+ grid-template-columns: 1fr 1fr;
1663
+ gap: 8px;
1664
+ }
1665
+ .trust-row.show {
1666
+ display: grid;
1667
+ }
1135
1668
  button:hover:not(:disabled) {
1136
1669
  filter: brightness(1.15);
1137
1670
  transform: translateY(-1px);
@@ -1435,15 +1968,31 @@ var ui_default = `<!doctype html>
1435
1968
  <span class="slider"></span>
1436
1969
  </label>
1437
1970
  </div>
1971
+ <div class="setting-row">
1972
+ <div class="setting-text">
1973
+ <div class="setting-label">Trust Sessions</div>
1974
+ <div class="setting-desc">
1975
+ Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
1976
+ </div>
1977
+ </div>
1978
+ <label class="toggle">
1979
+ <input
1980
+ type="checkbox"
1981
+ id="trustSessionsToggle"
1982
+ onchange="onTrustToggle(this.checked)"
1983
+ />
1984
+ <span class="slider"></span>
1985
+ </label>
1986
+ </div>
1438
1987
  </div>
1439
1988
 
1440
1989
  <div class="panel">
1441
- <div class="panel-title">\u{1F4AC} Slack Approvals</div>
1990
+ <div class="panel-title">\u{1F4AC} Cloud Approvals</div>
1442
1991
  <div class="setting-row">
1443
1992
  <div class="setting-text">
1444
- <div class="setting-label">Enable Slack</div>
1993
+ <div class="setting-label">Enable Cloud</div>
1445
1994
  <div class="setting-desc">
1446
- Use Slack as the approval authority when a key is saved.
1995
+ Use Cloud/Slack as the approval authority when a key is saved.
1447
1996
  </div>
1448
1997
  </div>
1449
1998
  <label class="toggle">
@@ -1497,6 +2046,7 @@ var ui_default = `<!doctype html>
1497
2046
  const requests = new Set();
1498
2047
  let orgName = null;
1499
2048
  let autoDenyMs = 120000;
2049
+ let trustEnabled = false;
1500
2050
 
1501
2051
  function highlightSyntax(code) {
1502
2052
  if (typeof code !== 'string') return esc(code);
@@ -1549,6 +2099,21 @@ var ui_default = `<!doctype html>
1549
2099
  }, 200);
1550
2100
  }
1551
2101
 
2102
+ function sendTrust(id, duration) {
2103
+ const card = document.getElementById('c-' + id);
2104
+ if (card) card.style.opacity = '0.5';
2105
+ fetch('/decision/' + id, {
2106
+ method: 'POST',
2107
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
2108
+ body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
2109
+ });
2110
+ setTimeout(() => {
2111
+ card?.remove();
2112
+ requests.delete(id);
2113
+ refresh();
2114
+ }, 200);
2115
+ }
2116
+
1552
2117
  function addCard(req) {
1553
2118
  if (requests.has(req.id)) return;
1554
2119
  requests.add(req.id);
@@ -1568,6 +2133,7 @@ var ui_default = `<!doctype html>
1568
2133
  card.id = 'c-' + req.id;
1569
2134
  const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
1570
2135
  const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
2136
+ const dis = isSlack ? 'disabled' : '';
1571
2137
  card.innerHTML = \`
1572
2138
  <div class="source-row">
1573
2139
  <span class="agent-badge">\${agentLabel}</span>
@@ -1577,11 +2143,15 @@ var ui_default = `<!doctype html>
1577
2143
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
1578
2144
  <span class="label">Input Payload</span>
1579
2145
  <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>
2146
+ <div class="actions" id="act-\${req.id}">
2147
+ <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
2148
+ <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
2149
+ <div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
2150
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
2151
+ <button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
2152
+ </div>
2153
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
2154
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
1585
2155
  </div>
1586
2156
  \`;
1587
2157
  list.appendChild(card);
@@ -1613,8 +2183,10 @@ var ui_default = `<!doctype html>
1613
2183
  autoDenyMs = data.autoDenyMs;
1614
2184
  if (orgName) {
1615
2185
  const b = document.getElementById('cloudBadge');
1616
- b.innerText = orgName;
1617
- b.classList.add('online');
2186
+ if (b) {
2187
+ b.innerText = orgName;
2188
+ b.classList.add('online');
2189
+ }
1618
2190
  }
1619
2191
  data.requests.forEach(addCard);
1620
2192
  });
@@ -1644,6 +2216,14 @@ var ui_default = `<!doctype html>
1644
2216
  }).catch(() => {});
1645
2217
  }
1646
2218
 
2219
+ function onTrustToggle(checked) {
2220
+ trustEnabled = checked;
2221
+ saveSetting('enableTrustSessions', checked);
2222
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2223
+ el.classList.toggle('show', checked);
2224
+ });
2225
+ }
2226
+
1647
2227
  fetch('/settings')
1648
2228
  .then((r) => r.json())
1649
2229
  .then((s) => {
@@ -1656,6 +2236,13 @@ var ui_default = `<!doctype html>
1656
2236
  if (!s.autoStartDaemon && !s.autoStarted) {
1657
2237
  document.getElementById('warnBanner').classList.add('show');
1658
2238
  }
2239
+ trustEnabled = !!s.enableTrustSessions;
2240
+ const trustTog = document.getElementById('trustSessionsToggle');
2241
+ if (trustTog) trustTog.checked = trustEnabled;
2242
+ // Show/hide trust rows on any cards already rendered
2243
+ document.querySelectorAll('[id^="tr-"]').forEach((el) => {
2244
+ el.classList.toggle('show', trustEnabled);
2245
+ });
1659
2246
  })
1660
2247
  .catch(() => {});
1661
2248
 
@@ -1765,7 +2352,7 @@ var import_http = __toESM(require("http"));
1765
2352
  var import_fs3 = __toESM(require("fs"));
1766
2353
  var import_path3 = __toESM(require("path"));
1767
2354
  var import_os3 = __toESM(require("os"));
1768
- var import_child_process = require("child_process");
2355
+ var import_child_process2 = require("child_process");
1769
2356
  var import_crypto = require("crypto");
1770
2357
  var import_chalk3 = __toESM(require("chalk"));
1771
2358
  var DAEMON_PORT2 = 7391;
@@ -1776,6 +2363,33 @@ var DECISIONS_FILE = import_path3.default.join(homeDir, ".node9", "decisions.jso
1776
2363
  var GLOBAL_CONFIG_FILE = import_path3.default.join(homeDir, ".node9", "config.json");
1777
2364
  var CREDENTIALS_FILE = import_path3.default.join(homeDir, ".node9", "credentials.json");
1778
2365
  var AUDIT_LOG_FILE = import_path3.default.join(homeDir, ".node9", "audit.log");
2366
+ var TRUST_FILE2 = import_path3.default.join(homeDir, ".node9", "trust.json");
2367
+ function atomicWriteSync2(filePath, data, options) {
2368
+ const dir = import_path3.default.dirname(filePath);
2369
+ if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
2370
+ const tmpPath = `${filePath}.${(0, import_crypto.randomUUID)()}.tmp`;
2371
+ import_fs3.default.writeFileSync(tmpPath, data, options);
2372
+ import_fs3.default.renameSync(tmpPath, filePath);
2373
+ }
2374
+ function writeTrustEntry(toolName, durationMs) {
2375
+ try {
2376
+ let trust = { entries: [] };
2377
+ try {
2378
+ if (import_fs3.default.existsSync(TRUST_FILE2))
2379
+ trust = JSON.parse(import_fs3.default.readFileSync(TRUST_FILE2, "utf-8"));
2380
+ } catch {
2381
+ }
2382
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
2383
+ trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
2384
+ atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
2385
+ } catch {
2386
+ }
2387
+ }
2388
+ var TRUST_DURATIONS = {
2389
+ "30m": 30 * 6e4,
2390
+ "1h": 60 * 6e4,
2391
+ "2h": 2 * 60 * 6e4
2392
+ };
1779
2393
  var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
1780
2394
  function redactArgs(value) {
1781
2395
  if (!value || typeof value !== "object") return value;
@@ -1788,10 +2402,16 @@ function redactArgs(value) {
1788
2402
  }
1789
2403
  function appendAuditLog(data) {
1790
2404
  try {
1791
- const entry = JSON.stringify({ ...data, args: redactArgs(data.args) }) + "\n";
2405
+ const entry = {
2406
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2407
+ tool: data.toolName,
2408
+ args: redactArgs(data.args),
2409
+ decision: data.decision,
2410
+ source: "daemon"
2411
+ };
1792
2412
  const dir = import_path3.default.dirname(AUDIT_LOG_FILE);
1793
2413
  if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1794
- import_fs3.default.appendFileSync(AUDIT_LOG_FILE, entry);
2414
+ import_fs3.default.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
1795
2415
  } catch {
1796
2416
  }
1797
2417
  }
@@ -1816,21 +2436,6 @@ function getOrgName() {
1816
2436
  return null;
1817
2437
  }
1818
2438
  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
2439
  function hasStoredSlackKey() {
1835
2440
  return import_fs3.default.existsSync(CREDENTIALS_FILE);
1836
2441
  }
@@ -1844,14 +2449,13 @@ function writeGlobalSetting(key, value) {
1844
2449
  }
1845
2450
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
1846
2451
  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 });
2452
+ atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
1850
2453
  }
1851
2454
  var pending = /* @__PURE__ */ new Map();
1852
2455
  var sseClients = /* @__PURE__ */ new Set();
1853
2456
  var abandonTimer = null;
1854
2457
  var daemonServer = null;
2458
+ var hadBrowserClient = false;
1855
2459
  function abandonPending() {
1856
2460
  abandonTimer = null;
1857
2461
  pending.forEach((entry, id) => {
@@ -1887,10 +2491,8 @@ data: ${JSON.stringify(data)}
1887
2491
  }
1888
2492
  function openBrowser(url) {
1889
2493
  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);
2494
+ const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
2495
+ (0, import_child_process2.spawn)(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
1894
2496
  } catch {
1895
2497
  }
1896
2498
  }
@@ -1912,11 +2514,9 @@ function readPersistentDecisions() {
1912
2514
  }
1913
2515
  function writePersistentDecision(toolName, decision) {
1914
2516
  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
2517
  const decisions = readPersistentDecisions();
1918
2518
  decisions[toolName] = decision;
1919
- import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2519
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
1920
2520
  broadcast("decisions", decisions);
1921
2521
  } catch {
1922
2522
  }
@@ -1926,6 +2526,22 @@ function startDaemon() {
1926
2526
  const internalToken = (0, import_crypto.randomUUID)();
1927
2527
  const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
1928
2528
  const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
2529
+ const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
2530
+ let idleTimer;
2531
+ function resetIdleTimer() {
2532
+ if (idleTimer) clearTimeout(idleTimer);
2533
+ idleTimer = setTimeout(() => {
2534
+ if (autoStarted) {
2535
+ try {
2536
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
2537
+ } catch {
2538
+ }
2539
+ }
2540
+ process.exit(0);
2541
+ }, IDLE_TIMEOUT_MS);
2542
+ idleTimer.unref();
2543
+ }
2544
+ resetIdleTimer();
1929
2545
  const server = import_http.default.createServer(async (req, res) => {
1930
2546
  const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
1931
2547
  if (req.method === "GET" && pathname === "/") {
@@ -1942,6 +2558,7 @@ function startDaemon() {
1942
2558
  clearTimeout(abandonTimer);
1943
2559
  abandonTimer = null;
1944
2560
  }
2561
+ hadBrowserClient = true;
1945
2562
  sseClients.add(res);
1946
2563
  res.write(
1947
2564
  `event: init
@@ -1968,12 +2585,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
1968
2585
  return req.on("close", () => {
1969
2586
  sseClients.delete(res);
1970
2587
  if (sseClients.size === 0 && pending.size > 0) {
1971
- abandonTimer = setTimeout(abandonPending, 2e3);
2588
+ abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
1972
2589
  }
1973
2590
  });
1974
2591
  }
1975
2592
  if (req.method === "POST" && pathname === "/check") {
1976
2593
  try {
2594
+ resetIdleTimer();
1977
2595
  const body = await readBody(req);
1978
2596
  if (body.length > 65536) return res.writeHead(413).end();
1979
2597
  const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
@@ -1994,8 +2612,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
1994
2612
  appendAuditLog({
1995
2613
  toolName: e.toolName,
1996
2614
  args: e.args,
1997
- decision: "auto-deny",
1998
- timestamp: Date.now()
2615
+ decision: "auto-deny"
1999
2616
  });
2000
2617
  if (e.waiter) e.waiter("deny");
2001
2618
  else e.earlyDecision = "deny";
@@ -2013,7 +2630,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2013
2630
  agent: entry.agent,
2014
2631
  mcpServer: entry.mcpServer
2015
2632
  });
2016
- if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
2633
+ if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
2017
2634
  res.writeHead(200, { "Content-Type": "application/json" });
2018
2635
  return res.end(JSON.stringify({ id }));
2019
2636
  } catch {
@@ -2040,17 +2657,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
2040
2657
  const id = pathname.split("/").pop();
2041
2658
  const entry = pending.get(id);
2042
2659
  if (!entry) return res.writeHead(404).end();
2043
- const { decision, persist } = JSON.parse(await readBody(req));
2044
- if (persist) writePersistentDecision(entry.toolName, decision);
2660
+ const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
2661
+ if (decision === "trust" && trustDuration) {
2662
+ const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
2663
+ writeTrustEntry(entry.toolName, ms);
2664
+ appendAuditLog({
2665
+ toolName: entry.toolName,
2666
+ args: entry.args,
2667
+ decision: `trust:${trustDuration}`
2668
+ });
2669
+ clearTimeout(entry.timer);
2670
+ if (entry.waiter) entry.waiter("allow");
2671
+ else entry.earlyDecision = "allow";
2672
+ pending.delete(id);
2673
+ broadcast("remove", { id });
2674
+ res.writeHead(200);
2675
+ return res.end(JSON.stringify({ ok: true }));
2676
+ }
2677
+ const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
2678
+ if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
2045
2679
  appendAuditLog({
2046
2680
  toolName: entry.toolName,
2047
2681
  args: entry.args,
2048
- decision,
2049
- timestamp: Date.now()
2682
+ decision: resolvedDecision
2050
2683
  });
2051
2684
  clearTimeout(entry.timer);
2052
- if (entry.waiter) entry.waiter(decision);
2053
- else entry.earlyDecision = decision;
2685
+ if (entry.waiter) entry.waiter(resolvedDecision);
2686
+ else entry.earlyDecision = resolvedDecision;
2054
2687
  pending.delete(id);
2055
2688
  broadcast("remove", { id });
2056
2689
  res.writeHead(200);
@@ -2060,7 +2693,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2060
2693
  }
2061
2694
  }
2062
2695
  if (req.method === "GET" && pathname === "/settings") {
2063
- const s = readGlobalSettings();
2696
+ const s = getGlobalSettings();
2064
2697
  res.writeHead(200, { "Content-Type": "application/json" });
2065
2698
  return res.end(JSON.stringify({ ...s, autoStarted }));
2066
2699
  }
@@ -2072,7 +2705,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2072
2705
  if (data.autoStartDaemon !== void 0)
2073
2706
  writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
2074
2707
  if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
2075
- if (data.agentMode !== void 0) writeGlobalSetting("agentMode", data.agentMode);
2708
+ if (data.enableTrustSessions !== void 0)
2709
+ writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
2710
+ if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
2711
+ if (data.enableHookLogDebug !== void 0)
2712
+ writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
2713
+ if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
2076
2714
  res.writeHead(200);
2077
2715
  return res.end(JSON.stringify({ ok: true }));
2078
2716
  } catch {
@@ -2080,7 +2718,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2080
2718
  }
2081
2719
  }
2082
2720
  if (req.method === "GET" && pathname === "/slack-status") {
2083
- const s = readGlobalSettings();
2721
+ const s = getGlobalSettings();
2084
2722
  res.writeHead(200, { "Content-Type": "application/json" });
2085
2723
  return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
2086
2724
  }
@@ -2088,14 +2726,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
2088
2726
  if (!validToken(req)) return res.writeHead(403).end();
2089
2727
  try {
2090
2728
  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(
2729
+ atomicWriteSync2(
2094
2730
  CREDENTIALS_FILE,
2095
2731
  JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
2096
2732
  { mode: 384 }
2097
2733
  );
2098
- broadcast("slack-status", { hasKey: true, enabled: readGlobalSettings().slackEnabled });
2734
+ broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
2099
2735
  res.writeHead(200);
2100
2736
  return res.end(JSON.stringify({ ok: true }));
2101
2737
  } catch {
@@ -2108,7 +2744,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2108
2744
  const toolName = decodeURIComponent(pathname.split("/").pop());
2109
2745
  const decisions = readPersistentDecisions();
2110
2746
  delete decisions[toolName];
2111
- import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2747
+ atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2112
2748
  broadcast("decisions", decisions);
2113
2749
  res.writeHead(200);
2114
2750
  return res.end(JSON.stringify({ ok: true }));
@@ -2127,8 +2763,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
2127
2763
  appendAuditLog({
2128
2764
  toolName: entry.toolName,
2129
2765
  args: entry.args,
2130
- decision,
2131
- timestamp: Date.now()
2766
+ decision
2132
2767
  });
2133
2768
  clearTimeout(entry.timer);
2134
2769
  if (entry.waiter) entry.waiter(decision);
@@ -2148,10 +2783,28 @@ data: ${JSON.stringify(readPersistentDecisions())}
2148
2783
  res.writeHead(404).end();
2149
2784
  });
2150
2785
  daemonServer = server;
2786
+ server.on("error", (e) => {
2787
+ if (e.code === "EADDRINUSE") {
2788
+ try {
2789
+ if (import_fs3.default.existsSync(DAEMON_PID_FILE)) {
2790
+ const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2791
+ process.kill(pid, 0);
2792
+ return process.exit(0);
2793
+ }
2794
+ } catch {
2795
+ try {
2796
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
2797
+ } catch {
2798
+ }
2799
+ server.listen(DAEMON_PORT2, DAEMON_HOST2);
2800
+ return;
2801
+ }
2802
+ }
2803
+ console.error(import_chalk3.default.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
2804
+ process.exit(1);
2805
+ });
2151
2806
  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(
2807
+ atomicWriteSync2(
2155
2808
  DAEMON_PID_FILE,
2156
2809
  JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
2157
2810
  { mode: 384 }
@@ -2187,17 +2840,97 @@ function daemonStatus() {
2187
2840
  }
2188
2841
 
2189
2842
  // src/cli.ts
2190
- var import_child_process2 = require("child_process");
2843
+ var import_child_process4 = require("child_process");
2191
2844
  var import_execa = require("execa");
2192
2845
  var import_execa2 = require("execa");
2193
2846
  var import_chalk4 = __toESM(require("chalk"));
2194
2847
  var import_readline = __toESM(require("readline"));
2848
+ var import_fs5 = __toESM(require("fs"));
2849
+ var import_path5 = __toESM(require("path"));
2850
+ var import_os5 = __toESM(require("os"));
2851
+
2852
+ // src/undo.ts
2853
+ var import_child_process3 = require("child_process");
2195
2854
  var import_fs4 = __toESM(require("fs"));
2196
2855
  var import_path4 = __toESM(require("path"));
2197
2856
  var import_os4 = __toESM(require("os"));
2857
+ var UNDO_LATEST_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
2858
+ async function createShadowSnapshot() {
2859
+ try {
2860
+ const cwd = process.cwd();
2861
+ if (!import_fs4.default.existsSync(import_path4.default.join(cwd, ".git"))) return null;
2862
+ const tempIndex = import_path4.default.join(cwd, ".git", `node9_index_${Date.now()}`);
2863
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
2864
+ (0, import_child_process3.spawnSync)("git", ["add", "-A"], { env });
2865
+ const treeRes = (0, import_child_process3.spawnSync)("git", ["write-tree"], { env });
2866
+ const treeHash = treeRes.stdout.toString().trim();
2867
+ if (import_fs4.default.existsSync(tempIndex)) import_fs4.default.unlinkSync(tempIndex);
2868
+ if (!treeHash || treeRes.status !== 0) return null;
2869
+ const commitRes = (0, import_child_process3.spawnSync)("git", [
2870
+ "commit-tree",
2871
+ treeHash,
2872
+ "-m",
2873
+ `Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
2874
+ ]);
2875
+ const commitHash = commitRes.stdout.toString().trim();
2876
+ if (commitHash && commitRes.status === 0) {
2877
+ const dir = import_path4.default.dirname(UNDO_LATEST_PATH);
2878
+ if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
2879
+ import_fs4.default.writeFileSync(UNDO_LATEST_PATH, commitHash);
2880
+ return commitHash;
2881
+ }
2882
+ } catch (err) {
2883
+ if (process.env.NODE9_DEBUG === "1") {
2884
+ console.error("[Node9 Undo Engine Error]:", err);
2885
+ }
2886
+ }
2887
+ return null;
2888
+ }
2889
+ function applyUndo(hash) {
2890
+ try {
2891
+ const restore = (0, import_child_process3.spawnSync)("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
2892
+ if (restore.status !== 0) return false;
2893
+ const lsTree = (0, import_child_process3.spawnSync)("git", ["ls-tree", "-r", "--name-only", hash]);
2894
+ const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
2895
+ const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
2896
+ const untracked = (0, import_child_process3.spawnSync)("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
2897
+ for (const file of [...tracked, ...untracked]) {
2898
+ if (!snapshotFiles.has(file) && import_fs4.default.existsSync(file)) {
2899
+ import_fs4.default.unlinkSync(file);
2900
+ }
2901
+ }
2902
+ return true;
2903
+ } catch {
2904
+ return false;
2905
+ }
2906
+ }
2907
+ function getLatestSnapshotHash() {
2908
+ if (!import_fs4.default.existsSync(UNDO_LATEST_PATH)) return null;
2909
+ return import_fs4.default.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
2910
+ }
2911
+
2912
+ // src/cli.ts
2913
+ var import_prompts3 = require("@inquirer/prompts");
2198
2914
  var { version } = JSON.parse(
2199
- import_fs4.default.readFileSync(import_path4.default.join(__dirname, "../package.json"), "utf-8")
2915
+ import_fs5.default.readFileSync(import_path5.default.join(__dirname, "../package.json"), "utf-8")
2200
2916
  );
2917
+ function parseDuration(str) {
2918
+ const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
2919
+ if (!m) return null;
2920
+ const n = parseFloat(m[1]);
2921
+ switch ((m[2] ?? "m").toLowerCase()) {
2922
+ case "s":
2923
+ return Math.round(n * 1e3);
2924
+ case "m":
2925
+ return Math.round(n * 6e4);
2926
+ case "h":
2927
+ return Math.round(n * 36e5);
2928
+ case "d":
2929
+ return Math.round(n * 864e5);
2930
+ default:
2931
+ return null;
2932
+ }
2933
+ }
2201
2934
  function sanitize(value) {
2202
2935
  return value.replace(/[\x00-\x1F\x7F]/g, "");
2203
2936
  }
@@ -2205,23 +2938,33 @@ function openBrowserLocal() {
2205
2938
  const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2206
2939
  try {
2207
2940
  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);
2941
+ if (process.platform === "darwin") (0, import_child_process4.execSync)(`open "${url}"`, opts);
2942
+ else if (process.platform === "win32") (0, import_child_process4.execSync)(`cmd /c start "" "${url}"`, opts);
2943
+ else (0, import_child_process4.execSync)(`xdg-open "${url}"`, opts);
2211
2944
  } catch {
2212
2945
  }
2213
2946
  }
2214
2947
  async function autoStartDaemonAndWait() {
2215
2948
  try {
2216
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], {
2949
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], {
2217
2950
  detached: true,
2218
2951
  stdio: "ignore",
2219
2952
  env: { ...process.env, NODE9_AUTO_STARTED: "1" }
2220
2953
  });
2221
2954
  child.unref();
2222
- for (let i = 0; i < 12; i++) {
2955
+ for (let i = 0; i < 20; i++) {
2223
2956
  await new Promise((r) => setTimeout(r, 250));
2224
- if (isDaemonRunning()) return true;
2957
+ if (!isDaemonRunning()) continue;
2958
+ try {
2959
+ const res = await fetch("http://127.0.0.1:7391/settings", {
2960
+ signal: AbortSignal.timeout(500)
2961
+ });
2962
+ if (res.ok) {
2963
+ openBrowserLocal();
2964
+ return true;
2965
+ }
2966
+ } catch {
2967
+ }
2225
2968
  }
2226
2969
  } catch {
2227
2970
  }
@@ -2240,47 +2983,71 @@ async function runProxy(targetCommand) {
2240
2983
  } catch {
2241
2984
  }
2242
2985
  console.log(import_chalk4.default.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2243
- const child = (0, import_child_process2.spawn)(executable, args, {
2986
+ const child = (0, import_child_process4.spawn)(executable, args, {
2244
2987
  stdio: ["pipe", "pipe", "inherit"],
2988
+ // We control STDIN and STDOUT
2245
2989
  shell: true,
2246
- env: { ...process.env, FORCE_COLOR: "1", TERM: process.env.TERM || "xterm-256color" }
2990
+ env: { ...process.env, FORCE_COLOR: "1" }
2247
2991
  });
2248
- process.stdin.pipe(child.stdin);
2249
- const childOut = import_readline.default.createInterface({ input: child.stdout, terminal: false });
2250
- childOut.on("line", async (line) => {
2992
+ const agentIn = import_readline.default.createInterface({ input: process.stdin, terminal: false });
2993
+ agentIn.on("line", async (line) => {
2994
+ let message;
2251
2995
  try {
2252
- const message = JSON.parse(line);
2253
- if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2996
+ message = JSON.parse(line);
2997
+ } catch {
2998
+ child.stdin.write(line + "\n");
2999
+ return;
3000
+ }
3001
+ if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
3002
+ agentIn.pause();
3003
+ try {
2254
3004
  const name = message.params?.name || message.params?.tool_name || "unknown";
2255
3005
  const toolArgs = message.params?.arguments || message.params?.tool_input || {};
2256
- const approved = await authorizeAction(sanitize(name), toolArgs);
2257
- if (!approved) {
3006
+ const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
3007
+ agent: "Proxy/MCP"
3008
+ });
3009
+ if (!result.approved) {
2258
3010
  const errorResponse = {
2259
3011
  jsonrpc: "2.0",
2260
3012
  id: message.id,
2261
- error: { code: -32e3, message: "Node9: Action denied." }
3013
+ error: {
3014
+ code: -32e3,
3015
+ message: `Node9: Action denied. ${result.reason || ""}`
3016
+ }
2262
3017
  };
2263
- child.stdin.write(JSON.stringify(errorResponse) + "\n");
3018
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
2264
3019
  return;
2265
3020
  }
3021
+ } catch {
3022
+ const errorResponse = {
3023
+ jsonrpc: "2.0",
3024
+ id: message.id,
3025
+ error: {
3026
+ code: -32e3,
3027
+ message: `Node9: Security engine encountered an error. Action blocked for safety.`
3028
+ }
3029
+ };
3030
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
3031
+ return;
3032
+ } finally {
3033
+ agentIn.resume();
2266
3034
  }
2267
- process.stdout.write(line + "\n");
2268
- } catch {
2269
- process.stdout.write(line + "\n");
2270
3035
  }
3036
+ child.stdin.write(line + "\n");
2271
3037
  });
3038
+ child.stdout.pipe(process.stdout);
2272
3039
  child.on("exit", (code) => process.exit(code || 0));
2273
3040
  }
2274
3041
  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
3042
  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 });
3043
+ const credPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "credentials.json");
3044
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(credPath)))
3045
+ import_fs5.default.mkdirSync(import_path5.default.dirname(credPath), { recursive: true });
2279
3046
  const profileName = options.profile || "default";
2280
3047
  let existingCreds = {};
2281
3048
  try {
2282
- if (import_fs4.default.existsSync(credPath)) {
2283
- const raw = JSON.parse(import_fs4.default.readFileSync(credPath, "utf-8"));
3049
+ if (import_fs5.default.existsSync(credPath)) {
3050
+ const raw = JSON.parse(import_fs5.default.readFileSync(credPath, "utf-8"));
2284
3051
  if (raw.apiKey) {
2285
3052
  existingCreds = {
2286
3053
  default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
@@ -2292,40 +3059,38 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
2292
3059
  } catch {
2293
3060
  }
2294
3061
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
2295
- import_fs4.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3062
+ import_fs5.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
2296
3063
  if (profileName === "default") {
2297
- const configPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
3064
+ const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
2298
3065
  let config = {};
2299
3066
  try {
2300
- if (import_fs4.default.existsSync(configPath))
2301
- config = JSON.parse(import_fs4.default.readFileSync(configPath, "utf-8"));
3067
+ if (import_fs5.default.existsSync(configPath))
3068
+ config = JSON.parse(import_fs5.default.readFileSync(configPath, "utf-8"));
2302
3069
  } catch {
2303
3070
  }
2304
3071
  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 });
3072
+ const s = config.settings;
3073
+ const approvers = s.approvers || {
3074
+ native: true,
3075
+ browser: true,
3076
+ cloud: true,
3077
+ terminal: true
3078
+ };
3079
+ approvers.cloud = !options.local;
3080
+ s.approvers = approvers;
3081
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
3082
+ import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
3083
+ import_fs5.default.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
2309
3084
  }
2310
3085
  if (options.profile && profileName !== "default") {
2311
3086
  console.log(import_chalk4.default.green(`\u2705 Profile "${profileName}" saved`));
2312
3087
  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
- );
2318
3088
  } else if (options.local) {
2319
3089
  console.log(import_chalk4.default.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
2320
3090
  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>`));
2325
3091
  } else {
2326
3092
  console.log(import_chalk4.default.green(`\u2705 Logged in \u2014 agent mode`));
2327
3093
  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`));
2329
3094
  }
2330
3095
  });
2331
3096
  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) => {
@@ -2336,16 +3101,23 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
2336
3101
  process.exit(1);
2337
3102
  });
2338
3103
  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) {
3104
+ const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
3105
+ if (import_fs5.default.existsSync(configPath) && !options.force) {
2341
3106
  console.log(import_chalk4.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
2342
3107
  console.log(import_chalk4.default.gray(` Run with --force to overwrite.`));
2343
3108
  return;
2344
3109
  }
2345
3110
  const defaultConfig = {
2346
3111
  version: "1.0",
2347
- settings: { mode: "standard" },
3112
+ settings: {
3113
+ mode: "standard",
3114
+ autoStartDaemon: true,
3115
+ enableUndo: true,
3116
+ enableHookLogDebug: false,
3117
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
3118
+ },
2348
3119
  policy: {
3120
+ sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
2349
3121
  dangerousWords: DANGEROUS_WORDS,
2350
3122
  ignoredTools: [
2351
3123
  "list_*",
@@ -2355,60 +3127,65 @@ program.command("init").description("Create ~/.node9/config.json with default po
2355
3127
  "read",
2356
3128
  "write",
2357
3129
  "edit",
2358
- "multiedit",
2359
3130
  "glob",
2360
3131
  "grep",
2361
3132
  "ls",
2362
3133
  "notebookread",
2363
3134
  "notebookedit",
2364
- "todoread",
2365
- "todowrite",
2366
3135
  "webfetch",
2367
3136
  "websearch",
2368
3137
  "exitplanmode",
2369
- "askuserquestion"
3138
+ "askuserquestion",
3139
+ "agent",
3140
+ "task*"
2370
3141
  ],
2371
3142
  toolInspection: {
2372
3143
  bash: "command",
2373
3144
  shell: "command",
2374
3145
  run_shell_command: "command",
2375
- "terminal.execute": "command"
3146
+ "terminal.execute": "command",
3147
+ "postgres:query": "sql"
2376
3148
  },
2377
3149
  rules: [
2378
3150
  {
2379
3151
  action: "rm",
2380
- allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"]
3152
+ allowPaths: [
3153
+ "**/node_modules/**",
3154
+ "dist/**",
3155
+ "build/**",
3156
+ ".next/**",
3157
+ "coverage/**",
3158
+ ".cache/**",
3159
+ "tmp/**",
3160
+ "temp/**",
3161
+ ".DS_Store"
3162
+ ]
2381
3163
  }
2382
3164
  ]
2383
3165
  }
2384
3166
  };
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));
3167
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
3168
+ import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
3169
+ import_fs5.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
2388
3170
  console.log(import_chalk4.default.green(`\u2705 Global config created: ${configPath}`));
2389
3171
  console.log(import_chalk4.default.gray(` Edit this file to add custom tool inspection or security rules.`));
2390
3172
  });
2391
3173
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
2392
3174
  const creds = getCredentials();
2393
3175
  const daemonRunning = isDaemonRunning();
2394
- const settings = getGlobalSettings();
3176
+ const mergedConfig = getConfig();
3177
+ const settings = mergedConfig.settings;
2395
3178
  console.log("");
2396
- if (creds && settings.agentMode) {
3179
+ if (creds && settings.approvers.cloud) {
2397
3180
  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) {
3181
+ } else if (creds && !settings.approvers.cloud) {
2401
3182
  console.log(
2402
3183
  import_chalk4.default.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 all decisions stay on this machine")
2403
3184
  );
3185
+ } else {
2404
3186
  console.log(
2405
- import_chalk4.default.gray(" No data is sent to the cloud. Local config is the only authority.")
3187
+ import_chalk4.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 no API key (Local rules only)")
2406
3188
  );
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
3189
  }
2413
3190
  console.log("");
2414
3191
  if (daemonRunning) {
@@ -2417,64 +3194,39 @@ program.command("status").description("Show current Node9 mode, policy source, a
2417
3194
  );
2418
3195
  } else {
2419
3196
  console.log(import_chalk4.default.gray(" \u25CB Daemon stopped"));
2420
- console.log(import_chalk4.default.gray(" Start: node9 daemon --background"));
2421
3197
  }
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)}`);
3198
+ if (settings.enableUndo) {
2433
3199
  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(", "))}`
3200
+ import_chalk4.default.magenta(" \u25CF Undo Engine") + import_chalk4.default.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
2435
3201
  );
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
3202
  }
2445
- const keys = Object.keys(decisions);
2446
3203
  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"));
3204
+ const modeLabel = settings.mode === "audit" ? import_chalk4.default.blue("audit") : settings.mode === "strict" ? import_chalk4.default.red("strict") : import_chalk4.default.white("standard");
3205
+ console.log(` Mode: ${modeLabel}`);
3206
+ const projectConfig = import_path5.default.join(process.cwd(), "node9.config.json");
3207
+ const globalConfig = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
3208
+ console.log(
3209
+ ` Local: ${import_fs5.default.existsSync(projectConfig) ? import_chalk4.default.green("Active (node9.config.json)") : import_chalk4.default.gray("Not present")}`
3210
+ );
3211
+ console.log(
3212
+ ` Global: ${import_fs5.default.existsSync(globalConfig) ? import_chalk4.default.green("Active (~/.node9/config.json)") : import_chalk4.default.gray("Not present")}`
3213
+ );
3214
+ if (mergedConfig.policy.sandboxPaths.length > 0) {
3215
+ console.log(
3216
+ ` Sandbox: ${import_chalk4.default.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
3217
+ );
2457
3218
  }
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 {
3219
+ const pauseState = checkPause();
3220
+ if (pauseState.paused) {
3221
+ const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
3222
+ console.log("");
3223
+ console.log(
3224
+ import_chalk4.default.yellow(` \u23F8 PAUSED until ${expiresAt}`) + import_chalk4.default.gray(" \u2014 all tool calls allowed")
3225
+ );
2468
3226
  }
2469
3227
  console.log("");
2470
3228
  });
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(
3229
+ 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
3230
  async (action, options) => {
2479
3231
  const cmd = (action ?? "start").toLowerCase();
2480
3232
  if (cmd === "stop") return stopDaemon();
@@ -2489,7 +3241,7 @@ program.command("daemon").description("Run the local approval server (browser HI
2489
3241
  console.log(import_chalk4.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2490
3242
  process.exit(0);
2491
3243
  }
2492
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
3244
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2493
3245
  child.unref();
2494
3246
  for (let i = 0; i < 12; i++) {
2495
3247
  await new Promise((r) => setTimeout(r, 250));
@@ -2498,18 +3250,13 @@ program.command("daemon").description("Run the local approval server (browser HI
2498
3250
  openBrowserLocal();
2499
3251
  console.log(import_chalk4.default.green(`
2500
3252
  \u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
2501
- console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2502
3253
  process.exit(0);
2503
3254
  }
2504
3255
  if (options.background) {
2505
- const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
3256
+ const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2506
3257
  child.unref();
2507
3258
  console.log(import_chalk4.default.green(`
2508
3259
  \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
3260
  process.exit(0);
2514
3261
  }
2515
3262
  startDaemon();
@@ -2519,53 +3266,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2519
3266
  const processPayload = async (raw) => {
2520
3267
  try {
2521
3268
  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}
3269
+ let payload = JSON.parse(raw);
3270
+ try {
3271
+ payload = JSON.parse(raw);
3272
+ } catch (err) {
3273
+ const tempConfig = getConfig();
3274
+ if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
3275
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
3276
+ const errMsg = err instanceof Error ? err.message : String(err);
3277
+ import_fs5.default.appendFileSync(
3278
+ logPath,
3279
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
3280
+ RAW: ${raw}
2531
3281
  `
2532
- );
3282
+ );
3283
+ }
3284
+ process.exit(0);
3285
+ return;
3286
+ }
3287
+ if (payload.cwd) {
3288
+ try {
3289
+ process.chdir(payload.cwd);
3290
+ _resetConfigCache();
3291
+ } catch {
3292
+ }
3293
+ }
3294
+ const config = getConfig();
3295
+ if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
3296
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
3297
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
3298
+ import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
3299
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
3300
+ `);
2533
3301
  }
2534
- const payload = JSON.parse(raw);
2535
3302
  const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
2536
3303
  const toolInput = payload.tool_input ?? payload.args ?? {};
2537
- const agent = payload.tool_name !== void 0 ? "Claude Code" : payload.name !== void 0 ? "Gemini CLI" : "Terminal";
3304
+ 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
3305
  const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
2539
3306
  const mcpServer = mcpMatch?.[1];
2540
3307
  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
- };
3308
+ const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
3309
+ const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
2548
3310
  console.error(import_chalk4.default.red(`
2549
3311
  \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
- }
3312
+ console.error(import_chalk4.default.gray(` Triggered by: ${blockedByContext}`));
3313
+ if (result2?.changeHint) console.error(import_chalk4.default.cyan(` To change: ${result2.changeHint}`));
2560
3314
  console.error("");
3315
+ let aiFeedbackMessage = "";
3316
+ if (isHumanDecision) {
3317
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
3318
+ REASON: ${msg || "No specific reason provided by user."}
3319
+
3320
+ INSTRUCTIONS FOR AI AGENT:
3321
+ - Do NOT retry this exact command immediately.
3322
+ - Explain to the user that you understand they blocked the action.
3323
+ - Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
3324
+ - 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.`;
3325
+ } else {
3326
+ aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
3327
+ REASON: ${msg}
3328
+
3329
+ INSTRUCTIONS FOR AI AGENT:
3330
+ - This command violates the current security configuration.
3331
+ - Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
3332
+ - Pivot to a non-destructive or read-only alternative.
3333
+ - Inform the user which security rule was triggered.`;
3334
+ }
2561
3335
  process.stdout.write(
2562
3336
  JSON.stringify({
2563
3337
  decision: "block",
2564
- reason: msg,
3338
+ reason: aiFeedbackMessage,
3339
+ // This is the core instruction
2565
3340
  hookSpecificOutput: {
2566
3341
  hookEventName: "PreToolUse",
2567
3342
  permissionDecision: "deny",
2568
- permissionDecisionReason: msg
3343
+ permissionDecisionReason: aiFeedbackMessage
2569
3344
  }
2570
3345
  }) + "\n"
2571
3346
  );
@@ -2576,36 +3351,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2576
3351
  return;
2577
3352
  }
2578
3353
  const meta = { agent, mcpServer };
3354
+ const STATE_CHANGING_TOOLS_PRE = [
3355
+ "bash",
3356
+ "shell",
3357
+ "write_file",
3358
+ "edit_file",
3359
+ "replace",
3360
+ "terminal.execute",
3361
+ "str_replace_based_edit_tool",
3362
+ "create_file"
3363
+ ];
3364
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
3365
+ await createShadowSnapshot();
3366
+ }
2579
3367
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
2580
3368
  if (result.approved) {
2581
- if (result.checkedBy) {
3369
+ if (result.checkedBy)
2582
3370
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
2583
3371
  `);
2584
- }
2585
3372
  process.exit(0);
2586
3373
  }
2587
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && getGlobalSettings().autoStartDaemon) {
3374
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
2588
3375
  console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2589
3376
  const daemonReady = await autoStartDaemonAndWait();
2590
3377
  if (daemonReady) {
2591
3378
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
2592
3379
  if (retry.approved) {
2593
- if (retry.checkedBy) {
3380
+ if (retry.checkedBy)
2594
3381
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
2595
3382
  `);
2596
- }
2597
3383
  process.exit(0);
2598
3384
  }
2599
- sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, retry);
3385
+ sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
3386
+ ...retry,
3387
+ blockedByLabel: retry.blockedByLabel
3388
+ });
2600
3389
  return;
2601
3390
  }
2602
3391
  }
2603
- sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, result);
3392
+ sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
3393
+ ...result,
3394
+ blockedByLabel: result.blockedByLabel
3395
+ });
2604
3396
  } catch (err) {
2605
3397
  if (process.env.NODE9_DEBUG === "1") {
2606
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
3398
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
2607
3399
  const errMsg = err instanceof Error ? err.message : String(err);
2608
- import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
3400
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
2609
3401
  `);
2610
3402
  }
2611
3403
  process.exit(0);
@@ -2616,48 +3408,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
2616
3408
  } else {
2617
3409
  let raw = "";
2618
3410
  let processed = false;
3411
+ let inactivityTimer = null;
2619
3412
  const done = async () => {
2620
3413
  if (processed) return;
2621
3414
  processed = true;
3415
+ if (inactivityTimer) clearTimeout(inactivityTimer);
2622
3416
  if (!raw.trim()) return process.exit(0);
2623
3417
  await processPayload(raw);
2624
3418
  };
2625
3419
  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);
3420
+ process.stdin.on("data", (chunk) => {
3421
+ raw += chunk;
3422
+ if (inactivityTimer) clearTimeout(inactivityTimer);
3423
+ inactivityTimer = setTimeout(() => void done(), 2e3);
3424
+ });
3425
+ process.stdin.on("end", () => {
3426
+ void done();
3427
+ });
3428
+ inactivityTimer = setTimeout(() => void done(), 5e3);
2629
3429
  }
2630
3430
  });
2631
3431
  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) => {
3432
+ const logPayload = async (raw) => {
2633
3433
  try {
2634
3434
  if (!raw || raw.trim() === "") process.exit(0);
2635
3435
  const payload = JSON.parse(raw);
3436
+ const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
3437
+ const rawInput = payload.tool_input ?? payload.args ?? {};
2636
3438
  const entry = {
2637
3439
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2638
- tool: sanitize(payload.tool_name ?? "unknown"),
2639
- input: JSON.parse(redactSecrets(JSON.stringify(payload.tool_input || {})))
3440
+ tool,
3441
+ args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
3442
+ decision: "allowed",
3443
+ source: "post-hook"
2640
3444
  };
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");
3445
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
3446
+ if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
3447
+ import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
3448
+ import_fs5.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
3449
+ const config = getConfig();
3450
+ const STATE_CHANGING_TOOLS = [
3451
+ "bash",
3452
+ "shell",
3453
+ "write_file",
3454
+ "edit_file",
3455
+ "replace",
3456
+ "terminal.execute"
3457
+ ];
3458
+ if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
3459
+ await createShadowSnapshot();
3460
+ }
2645
3461
  } catch {
2646
3462
  }
2647
3463
  process.exit(0);
2648
3464
  };
2649
3465
  if (data) {
2650
- logPayload(data);
3466
+ await logPayload(data);
2651
3467
  } else {
2652
3468
  let raw = "";
2653
3469
  process.stdin.setEncoding("utf-8");
2654
3470
  process.stdin.on("data", (chunk) => raw += chunk);
2655
- process.stdin.on("end", () => logPayload(raw));
3471
+ process.stdin.on("end", () => {
3472
+ void logPayload(raw);
3473
+ });
2656
3474
  setTimeout(() => {
2657
3475
  if (!raw) process.exit(0);
2658
3476
  }, 500);
2659
3477
  }
2660
3478
  });
3479
+ 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) => {
3480
+ const ms = parseDuration(options.duration);
3481
+ if (ms === null) {
3482
+ console.error(
3483
+ import_chalk4.default.red(`
3484
+ \u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
3485
+ `)
3486
+ );
3487
+ process.exit(1);
3488
+ }
3489
+ pauseNode9(ms, options.duration);
3490
+ const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
3491
+ console.log(import_chalk4.default.yellow(`
3492
+ \u23F8 Node9 paused until ${expiresAt}`));
3493
+ console.log(import_chalk4.default.gray(` All tool calls will be allowed without review.`));
3494
+ console.log(import_chalk4.default.gray(` Run "node9 resume" to re-enable early.
3495
+ `));
3496
+ });
3497
+ program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
3498
+ const { paused } = checkPause();
3499
+ if (!paused) {
3500
+ console.log(import_chalk4.default.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
3501
+ return;
3502
+ }
3503
+ resumeNode9();
3504
+ console.log(import_chalk4.default.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
3505
+ });
2661
3506
  var HOOK_BASED_AGENTS = {
2662
3507
  claude: "claude",
2663
3508
  gemini: "gemini",
@@ -2672,35 +3517,17 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2672
3517
  import_chalk4.default.yellow(`
2673
3518
  \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
2674
3519
  );
2675
- 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
3520
  console.error(import_chalk4.default.white(`
2692
- For browser approval popups (no API key required):`));
3521
+ "${target}" uses its own hook system. Use:`));
2693
3522
  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")
3523
+ import_chalk4.default.green(` node9 addto ${target} `) + import_chalk4.default.gray("# one-time setup")
2698
3524
  );
3525
+ console.error(import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# run normally"));
2699
3526
  process.exit(1);
2700
3527
  }
2701
3528
  const fullCommand = commandArgs.join(" ");
2702
3529
  let result = await authorizeHeadless("shell", { command: fullCommand });
2703
- if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getGlobalSettings().autoStartDaemon) {
3530
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
2704
3531
  console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2705
3532
  const daemonReady = await autoStartDaemonAndWait();
2706
3533
  if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
@@ -2713,21 +3540,6 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2713
3540
  import_chalk4.default.red(`
2714
3541
  \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
2715
3542
  );
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
3543
  process.exit(1);
2732
3544
  }
2733
3545
  console.error(import_chalk4.default.green("\n\u2705 Approved \u2014 running command...\n"));
@@ -2736,13 +3548,33 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
2736
3548
  program.help();
2737
3549
  }
2738
3550
  });
3551
+ program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
3552
+ const hash = getLatestSnapshotHash();
3553
+ if (!hash) {
3554
+ console.log(import_chalk4.default.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
3555
+ return;
3556
+ }
3557
+ console.log(import_chalk4.default.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
3558
+ console.log(import_chalk4.default.white(`Target Snapshot: ${import_chalk4.default.gray(hash.slice(0, 7))}`));
3559
+ const proceed = await (0, import_prompts3.confirm)({
3560
+ message: "Revert all files to the state before the last AI action?",
3561
+ default: false
3562
+ });
3563
+ if (proceed) {
3564
+ if (applyUndo(hash)) {
3565
+ console.log(import_chalk4.default.green("\u2705 Project reverted successfully.\n"));
3566
+ } else {
3567
+ console.error(import_chalk4.default.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
3568
+ }
3569
+ }
3570
+ });
2739
3571
  process.on("unhandledRejection", (reason) => {
2740
3572
  const isCheckHook = process.argv[2] === "check";
2741
3573
  if (isCheckHook) {
2742
- if (process.env.NODE9_DEBUG === "1") {
2743
- const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
3574
+ if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
3575
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
2744
3576
  const msg = reason instanceof Error ? reason.message : String(reason);
2745
- import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
3577
+ import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
2746
3578
  `);
2747
3579
  }
2748
3580
  process.exit(0);