@node9/proxy 0.2.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +66 -319
  2. package/dist/cli.js +1417 -608
  3. package/dist/cli.mjs +1417 -608
  4. package/dist/index.js +722 -261
  5. package/dist/index.mjs +722 -261
  6. package/package.json +44 -8
package/dist/index.js CHANGED
@@ -35,13 +35,244 @@ __export(src_exports, {
35
35
  module.exports = __toCommonJS(src_exports);
36
36
 
37
37
  // src/core.ts
38
- var import_chalk = __toESM(require("chalk"));
38
+ var import_chalk2 = __toESM(require("chalk"));
39
39
  var import_prompts = require("@inquirer/prompts");
40
40
  var import_fs = __toESM(require("fs"));
41
41
  var import_path = __toESM(require("path"));
42
42
  var import_os = __toESM(require("os"));
43
43
  var import_picomatch = __toESM(require("picomatch"));
44
44
  var import_sh_syntax = require("sh-syntax");
45
+
46
+ // src/ui/native.ts
47
+ var import_child_process = require("child_process");
48
+ var import_chalk = __toESM(require("chalk"));
49
+ var isTestEnv = () => {
50
+ 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";
51
+ };
52
+ function smartTruncate(str, maxLen = 500) {
53
+ if (str.length <= maxLen) return str;
54
+ const edge = Math.floor(maxLen / 2) - 3;
55
+ return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
56
+ }
57
+ function formatArgs(args) {
58
+ if (args === null || args === void 0) return "(none)";
59
+ let parsed = args;
60
+ if (typeof args === "string") {
61
+ const trimmed = args.trim();
62
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
63
+ try {
64
+ parsed = JSON.parse(trimmed);
65
+ } catch {
66
+ parsed = args;
67
+ }
68
+ } else {
69
+ return smartTruncate(args, 600);
70
+ }
71
+ }
72
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
73
+ const obj = parsed;
74
+ const codeKeys = [
75
+ "command",
76
+ "cmd",
77
+ "shell_command",
78
+ "bash_command",
79
+ "script",
80
+ "code",
81
+ "input",
82
+ "sql",
83
+ "query",
84
+ "arguments",
85
+ "args",
86
+ "param",
87
+ "params",
88
+ "text"
89
+ ];
90
+ const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
91
+ if (foundKey) {
92
+ const val = obj[foundKey];
93
+ const str = typeof val === "string" ? val : JSON.stringify(val);
94
+ return `[${foundKey.toUpperCase()}]:
95
+ ${smartTruncate(str, 500)}`;
96
+ }
97
+ return Object.entries(obj).slice(0, 5).map(
98
+ ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
99
+ ).join("\n");
100
+ }
101
+ return smartTruncate(JSON.stringify(parsed), 200);
102
+ }
103
+ function sendDesktopNotification(title, body) {
104
+ if (isTestEnv()) return;
105
+ try {
106
+ if (process.platform === "darwin") {
107
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
108
+ (0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
109
+ } else if (process.platform === "linux") {
110
+ (0, import_child_process.spawn)("notify-send", [title, body, "--icon=dialog-warning"], {
111
+ detached: true,
112
+ stdio: "ignore"
113
+ }).unref();
114
+ }
115
+ } catch {
116
+ }
117
+ }
118
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
119
+ if (isTestEnv()) return "deny";
120
+ const formattedArgs = formatArgs(args);
121
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
122
+ let message = "";
123
+ if (locked) message += `\u26A0\uFE0F LOCKED BY ADMIN POLICY
124
+ `;
125
+ message += `Tool: ${toolName}
126
+ `;
127
+ message += `Agent: ${agent || "AI Agent"}
128
+ `;
129
+ message += `Rule: ${explainableLabel || "Security Policy"}
130
+
131
+ `;
132
+ message += `${formattedArgs}`;
133
+ process.stderr.write(import_chalk.default.yellow(`
134
+ \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
135
+ `));
136
+ return new Promise((resolve) => {
137
+ let childProcess = null;
138
+ const onAbort = () => {
139
+ if (childProcess && childProcess.pid) {
140
+ try {
141
+ process.kill(childProcess.pid, "SIGKILL");
142
+ } catch {
143
+ }
144
+ }
145
+ resolve("deny");
146
+ };
147
+ if (signal) {
148
+ if (signal.aborted) return resolve("deny");
149
+ signal.addEventListener("abort", onAbort);
150
+ }
151
+ try {
152
+ if (process.platform === "darwin") {
153
+ const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
154
+ const script = `on run argv
155
+ tell application "System Events"
156
+ activate
157
+ display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
158
+ end tell
159
+ end run`;
160
+ childProcess = (0, import_child_process.spawn)("osascript", ["-e", script, "--", message, title]);
161
+ } else if (process.platform === "linux") {
162
+ const argsList = [
163
+ locked ? "--info" : "--question",
164
+ "--modal",
165
+ "--width=450",
166
+ "--title",
167
+ title,
168
+ "--text",
169
+ message,
170
+ "--ok-label",
171
+ locked ? "Waiting..." : "Allow",
172
+ "--timeout",
173
+ "300"
174
+ ];
175
+ if (!locked) {
176
+ argsList.push("--cancel-label", "Block");
177
+ argsList.push("--extra-button", "Always Allow");
178
+ }
179
+ childProcess = (0, import_child_process.spawn)("zenity", argsList);
180
+ } else if (process.platform === "win32") {
181
+ const b64Msg = Buffer.from(message).toString("base64");
182
+ const b64Title = Buffer.from(title).toString("base64");
183
+ const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
184
+ childProcess = (0, import_child_process.spawn)("powershell", ["-Command", ps]);
185
+ }
186
+ let output = "";
187
+ childProcess?.stdout?.on("data", (d) => output += d.toString());
188
+ childProcess?.on("close", (code) => {
189
+ if (signal) signal.removeEventListener("abort", onAbort);
190
+ if (locked) return resolve("deny");
191
+ if (output.includes("Always Allow")) return resolve("always_allow");
192
+ if (code === 0) return resolve("allow");
193
+ resolve("deny");
194
+ });
195
+ } catch {
196
+ resolve("deny");
197
+ }
198
+ });
199
+ }
200
+
201
+ // src/core.ts
202
+ var PAUSED_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "PAUSED");
203
+ var TRUST_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "trust.json");
204
+ function checkPause() {
205
+ try {
206
+ if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
207
+ const state = JSON.parse(import_fs.default.readFileSync(PAUSED_FILE, "utf-8"));
208
+ if (state.expiry > 0 && Date.now() >= state.expiry) {
209
+ try {
210
+ import_fs.default.unlinkSync(PAUSED_FILE);
211
+ } catch {
212
+ }
213
+ return { paused: false };
214
+ }
215
+ return { paused: true, expiresAt: state.expiry, duration: state.duration };
216
+ } catch {
217
+ return { paused: false };
218
+ }
219
+ }
220
+ function atomicWriteSync(filePath, data, options) {
221
+ const dir = import_path.default.dirname(filePath);
222
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
223
+ const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
224
+ import_fs.default.writeFileSync(tmpPath, data, options);
225
+ import_fs.default.renameSync(tmpPath, filePath);
226
+ }
227
+ function getActiveTrustSession(toolName) {
228
+ try {
229
+ if (!import_fs.default.existsSync(TRUST_FILE)) return false;
230
+ const trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
231
+ const now = Date.now();
232
+ const active = trust.entries.filter((e) => e.expiry > now);
233
+ if (active.length !== trust.entries.length) {
234
+ import_fs.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
235
+ }
236
+ return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+ function writeTrustSession(toolName, durationMs) {
242
+ try {
243
+ let trust = { entries: [] };
244
+ try {
245
+ if (import_fs.default.existsSync(TRUST_FILE)) {
246
+ trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
247
+ }
248
+ } catch {
249
+ }
250
+ const now = Date.now();
251
+ trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
252
+ trust.entries.push({ tool: toolName, expiry: now + durationMs });
253
+ atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
254
+ } catch (err) {
255
+ if (process.env.NODE9_DEBUG === "1") {
256
+ console.error("[Node9 Trust Error]:", err);
257
+ }
258
+ }
259
+ }
260
+ function appendAuditModeEntry(toolName, args) {
261
+ try {
262
+ const entry = JSON.stringify({
263
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
264
+ tool: toolName,
265
+ args,
266
+ decision: "would-have-blocked",
267
+ source: "audit-mode"
268
+ });
269
+ const logPath = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
270
+ const dir = import_path.default.dirname(logPath);
271
+ if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
272
+ import_fs.default.appendFileSync(logPath, entry + "\n");
273
+ } catch {
274
+ }
275
+ }
45
276
  var DANGEROUS_WORDS = [
46
277
  "delete",
47
278
  "drop",
@@ -59,10 +290,6 @@ var DANGEROUS_WORDS = [
59
290
  function tokenize(toolName) {
60
291
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
61
292
  }
62
- function containsDangerousWord(toolName, dangerousWords) {
63
- const tokens = tokenize(toolName);
64
- return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
65
- }
66
293
  function matchesPattern(text, patterns) {
67
294
  const p = Array.isArray(patterns) ? patterns : [patterns];
68
295
  if (p.length === 0) return false;
@@ -154,8 +381,15 @@ async function analyzeShellCommand(command) {
154
381
  return { actions, paths, allTokens };
155
382
  }
156
383
  var DEFAULT_CONFIG = {
157
- settings: { mode: "standard" },
384
+ settings: {
385
+ mode: "standard",
386
+ autoStartDaemon: true,
387
+ enableUndo: false,
388
+ enableHookLogDebug: false,
389
+ approvers: { native: true, browser: true, cloud: true, terminal: true }
390
+ },
158
391
  policy: {
392
+ sandboxPaths: [],
159
393
  dangerousWords: DANGEROUS_WORDS,
160
394
  ignoredTools: [
161
395
  "list_*",
@@ -163,57 +397,16 @@ var DEFAULT_CONFIG = {
163
397
  "read_*",
164
398
  "describe_*",
165
399
  "read",
166
- "write",
167
- "edit",
168
- "multiedit",
169
- "glob",
170
400
  "grep",
171
401
  "ls",
172
- "notebookread",
173
- "notebookedit",
174
- "todoread",
175
- "todowrite",
176
- "webfetch",
177
- "websearch",
178
- "exitplanmode",
179
402
  "askuserquestion"
180
403
  ],
181
- toolInspection: {
182
- bash: "command",
183
- run_shell_command: "command",
184
- shell: "command",
185
- "terminal.execute": "command"
186
- },
187
- rules: [
188
- { action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
189
- ]
404
+ toolInspection: { bash: "command", shell: "command" },
405
+ rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
190
406
  },
191
407
  environments: {}
192
408
  };
193
409
  var cachedConfig = null;
194
- function getGlobalSettings() {
195
- try {
196
- const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
197
- if (import_fs.default.existsSync(globalConfigPath)) {
198
- const parsed = JSON.parse(import_fs.default.readFileSync(globalConfigPath, "utf-8"));
199
- const settings = parsed.settings || {};
200
- return {
201
- mode: settings.mode || "standard",
202
- autoStartDaemon: settings.autoStartDaemon !== false,
203
- slackEnabled: settings.slackEnabled !== false,
204
- // agentMode defaults to false — user must explicitly opt in via `node9 login`
205
- agentMode: settings.agentMode === true
206
- };
207
- }
208
- } catch {
209
- }
210
- return { mode: "standard", autoStartDaemon: true, slackEnabled: true, agentMode: false };
211
- }
212
- function hasSlack() {
213
- const creds = getCredentials();
214
- if (!creds?.apiKey) return false;
215
- return getGlobalSettings().slackEnabled;
216
- }
217
410
  function getInternalToken() {
218
411
  try {
219
412
  const pidFile = import_path.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
@@ -225,51 +418,83 @@ function getInternalToken() {
225
418
  return null;
226
419
  }
227
420
  }
228
- async function evaluatePolicy(toolName, args) {
421
+ async function evaluatePolicy(toolName, args, agent) {
229
422
  const config = getConfig();
230
- if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
423
+ if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
424
+ let allTokens = [];
425
+ let actionTokens = [];
426
+ let pathTokens = [];
231
427
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
232
428
  if (shellCommand) {
233
- const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
429
+ const analyzed = await analyzeShellCommand(shellCommand);
430
+ allTokens = analyzed.allTokens;
431
+ actionTokens = analyzed.actions;
432
+ pathTokens = analyzed.paths;
234
433
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
235
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) return "review";
236
- for (const action of actions) {
237
- const basename = action.includes("/") ? action.split("/").pop() : action;
238
- const rule = config.policy.rules.find(
239
- (r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
240
- );
241
- if (rule) {
242
- if (paths.length > 0) {
243
- const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
244
- if (anyBlocked) return "review";
245
- const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
246
- if (allAllowed) return "allow";
247
- }
248
- return "review";
249
- }
434
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
435
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
250
436
  }
251
- const isDangerous2 = allTokens.some(
252
- (token) => config.policy.dangerousWords.some((word) => {
253
- const w = word.toLowerCase();
254
- if (token === w) return true;
255
- try {
256
- return new RegExp(`\\b${w}\\b`, "i").test(token);
257
- } catch {
258
- return false;
259
- }
260
- })
437
+ } else {
438
+ allTokens = tokenize(toolName);
439
+ actionTokens = [toolName];
440
+ }
441
+ const isManual = agent === "Terminal";
442
+ if (isManual) {
443
+ const NUCLEAR_COMMANDS = [
444
+ "drop",
445
+ "destroy",
446
+ "purge",
447
+ "rmdir",
448
+ "format",
449
+ "truncate",
450
+ "alter",
451
+ "grant",
452
+ "revoke",
453
+ "docker"
454
+ ];
455
+ const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
456
+ if (!hasNuclear) return { decision: "allow" };
457
+ }
458
+ if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
459
+ const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
460
+ if (allInSandbox) return { decision: "allow" };
461
+ }
462
+ for (const action of actionTokens) {
463
+ const rule = config.policy.rules.find(
464
+ (r) => r.action === action || matchesPattern(action, r.action)
261
465
  );
262
- if (isDangerous2) return "review";
263
- if (config.settings.mode === "strict") return "review";
264
- return "allow";
466
+ if (rule) {
467
+ if (pathTokens.length > 0) {
468
+ const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
469
+ if (anyBlocked)
470
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
471
+ const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
472
+ if (allAllowed) return { decision: "allow" };
473
+ }
474
+ return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
475
+ }
476
+ }
477
+ const isDangerous = allTokens.some(
478
+ (token) => config.policy.dangerousWords.some((word) => {
479
+ const w = word.toLowerCase();
480
+ if (token === w) return true;
481
+ try {
482
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
483
+ } catch {
484
+ return false;
485
+ }
486
+ })
487
+ );
488
+ if (isDangerous) {
489
+ const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
490
+ return { decision: "review", blockedByLabel: label };
265
491
  }
266
- const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
267
- if (isDangerous || config.settings.mode === "strict") {
492
+ if (config.settings.mode === "strict") {
268
493
  const envConfig = getActiveEnvironment(config);
269
- if (envConfig?.requireApproval === false) return "allow";
270
- return "review";
494
+ if (envConfig?.requireApproval === false) return { decision: "allow" };
495
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
271
496
  }
272
- return "allow";
497
+ return { decision: "allow" };
273
498
  }
274
499
  function isIgnoredTool(toolName) {
275
500
  const config = getConfig();
@@ -300,22 +525,40 @@ function getPersistentDecision(toolName) {
300
525
  }
301
526
  return null;
302
527
  }
303
- async function askDaemon(toolName, args, meta) {
528
+ async function askDaemon(toolName, args, meta, signal) {
304
529
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
305
- const checkRes = await fetch(`${base}/check`, {
306
- method: "POST",
307
- headers: { "Content-Type": "application/json" },
308
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
309
- signal: AbortSignal.timeout(5e3)
310
- });
311
- if (!checkRes.ok) throw new Error("Daemon fail");
312
- const { id } = await checkRes.json();
313
- const waitRes = await fetch(`${base}/wait/${id}`, { signal: AbortSignal.timeout(12e4) });
314
- if (!waitRes.ok) return "deny";
315
- const { decision } = await waitRes.json();
316
- if (decision === "allow") return "allow";
317
- if (decision === "abandoned") return "abandoned";
318
- return "deny";
530
+ const checkCtrl = new AbortController();
531
+ const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
532
+ const onAbort = () => checkCtrl.abort();
533
+ if (signal) signal.addEventListener("abort", onAbort);
534
+ try {
535
+ const checkRes = await fetch(`${base}/check`, {
536
+ method: "POST",
537
+ headers: { "Content-Type": "application/json" },
538
+ body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
539
+ signal: checkCtrl.signal
540
+ });
541
+ if (!checkRes.ok) throw new Error("Daemon fail");
542
+ const { id } = await checkRes.json();
543
+ const waitCtrl = new AbortController();
544
+ const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
545
+ const onWaitAbort = () => waitCtrl.abort();
546
+ if (signal) signal.addEventListener("abort", onWaitAbort);
547
+ try {
548
+ const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
549
+ if (!waitRes.ok) return "deny";
550
+ const { decision } = await waitRes.json();
551
+ if (decision === "allow") return "allow";
552
+ if (decision === "abandoned") return "abandoned";
553
+ return "deny";
554
+ } finally {
555
+ clearTimeout(waitTimer);
556
+ if (signal) signal.removeEventListener("abort", onWaitAbort);
557
+ }
558
+ } finally {
559
+ clearTimeout(checkTimer);
560
+ if (signal) signal.removeEventListener("abort", onAbort);
561
+ }
319
562
  }
320
563
  async function notifyDaemonViewer(toolName, args, meta) {
321
564
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
@@ -345,166 +588,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
345
588
  });
346
589
  }
347
590
  async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
348
- const { agentMode } = getGlobalSettings();
349
- const cloudEnforced = agentMode && hasSlack();
350
- if (!cloudEnforced) {
351
- if (isIgnoredTool(toolName)) return { approved: true };
352
- const policyDecision = await evaluatePolicy(toolName, args);
353
- if (policyDecision === "allow") return { approved: true, checkedBy: "local-policy" };
591
+ if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
592
+ const pauseState = checkPause();
593
+ if (pauseState.paused) return { approved: true, checkedBy: "paused" };
594
+ const creds = getCredentials();
595
+ const config = getConfig();
596
+ const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
597
+ const approvers = {
598
+ ...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
599
+ };
600
+ if (isTestEnv2) {
601
+ approvers.native = false;
602
+ approvers.browser = false;
603
+ approvers.terminal = false;
604
+ }
605
+ const isManual = meta?.agent === "Terminal";
606
+ let explainableLabel = "Local Config";
607
+ if (config.settings.mode === "audit") {
608
+ if (!isIgnoredTool(toolName)) {
609
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
610
+ if (policyResult.decision === "review") {
611
+ appendAuditModeEntry(toolName, args);
612
+ sendDesktopNotification(
613
+ "Node9 Audit Mode",
614
+ `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
615
+ );
616
+ }
617
+ }
618
+ return { approved: true, checkedBy: "audit" };
619
+ }
620
+ if (!isIgnoredTool(toolName)) {
621
+ if (getActiveTrustSession(toolName)) {
622
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
623
+ return { approved: true, checkedBy: "trust" };
624
+ }
625
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
626
+ if (policyResult.decision === "allow") {
627
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
628
+ return { approved: true, checkedBy: "local-policy" };
629
+ }
630
+ explainableLabel = policyResult.blockedByLabel || "Local Config";
354
631
  const persistent = getPersistentDecision(toolName);
355
- if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
356
- if (persistent === "deny")
632
+ if (persistent === "allow") {
633
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
634
+ return { approved: true, checkedBy: "persistent" };
635
+ }
636
+ if (persistent === "deny") {
357
637
  return {
358
638
  approved: false,
359
- reason: `Node9: "${toolName}" is set to always deny.`,
639
+ reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
360
640
  blockedBy: "persistent-deny",
361
- changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
641
+ blockedByLabel: "Persistent User Rule"
362
642
  };
363
- }
364
- if (cloudEnforced) {
365
- const creds = getCredentials();
366
- const envConfig = getActiveEnvironment(getConfig());
367
- let viewerId = null;
368
- const internalToken = getInternalToken();
369
- if (isDaemonRunning() && internalToken) {
370
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
371
- }
372
- const approved = await callNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
373
- if (viewerId && internalToken) {
374
- resolveViaDaemon(viewerId, approved ? "allow" : "deny", internalToken).catch(() => null);
375
643
  }
376
- return {
377
- approved,
378
- checkedBy: approved ? "cloud" : void 0,
379
- blockedBy: approved ? void 0 : "team-policy",
380
- changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
381
- };
644
+ } else {
645
+ if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
646
+ return { approved: true };
382
647
  }
383
- if (isDaemonRunning()) {
384
- console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
385
- console.error(import_chalk.default.cyan(` Browser UI \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
386
- `));
648
+ let cloudRequestId = null;
649
+ let isRemoteLocked = false;
650
+ const cloudEnforced = approvers.cloud && !!creds?.apiKey;
651
+ if (cloudEnforced) {
387
652
  try {
388
- const daemonDecision = await askDaemon(toolName, args, meta);
389
- if (daemonDecision === "abandoned") {
390
- console.error(import_chalk.default.yellow("\n\u26A0\uFE0F Browser closed without a decision. Falling back..."));
391
- } else {
653
+ const envConfig = getActiveEnvironment(getConfig());
654
+ const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
655
+ if (!initResult.pending) {
392
656
  return {
393
- approved: daemonDecision === "allow",
394
- reason: daemonDecision === "deny" ? `Node9 blocked "${toolName}" \u2014 denied in browser.` : void 0,
395
- checkedBy: daemonDecision === "allow" ? "daemon" : void 0,
396
- blockedBy: daemonDecision === "deny" ? "local-decision" : void 0,
397
- changeHint: daemonDecision === "deny" ? `Open the daemon UI to change: node9 daemon --openui` : void 0
657
+ approved: !!initResult.approved,
658
+ reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
659
+ checkedBy: initResult.approved ? "cloud" : void 0,
660
+ blockedBy: initResult.approved ? void 0 : "team-policy",
661
+ blockedByLabel: "Organization Policy (SaaS)"
398
662
  };
399
663
  }
400
- } catch {
664
+ cloudRequestId = initResult.requestId || null;
665
+ isRemoteLocked = !!initResult.remoteApprovalOnly;
666
+ explainableLabel = "Organization Policy (SaaS)";
667
+ } catch (err) {
668
+ const error = err;
669
+ const isAuthError = error.message.includes("401") || error.message.includes("403");
670
+ const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
671
+ 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;
672
+ console.error(
673
+ import_chalk2.default.yellow(`
674
+ \u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + import_chalk2.default.dim(`
675
+ Falling back to local rules...
676
+ `)
677
+ );
401
678
  }
402
679
  }
403
- if (allowTerminalFallback && process.stdout.isTTY) {
404
- console.log(import_chalk.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
405
- console.log(`${import_chalk.default.bold("Action:")} ${import_chalk.default.red(toolName)}`);
406
- const argsPreview = JSON.stringify(args, null, 2);
407
- console.log(
408
- `${import_chalk.default.bold("Args:")}
409
- ${import_chalk.default.gray(argsPreview.length > 500 ? argsPreview.slice(0, 500) + "..." : argsPreview)}`
680
+ if (cloudEnforced && cloudRequestId) {
681
+ console.error(
682
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
410
683
  );
411
- const controller = new AbortController();
412
- const TIMEOUT_MS = 3e4;
413
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
414
- try {
415
- const approved = await (0, import_prompts.confirm)(
416
- { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
417
- { signal: controller.signal }
418
- );
419
- clearTimeout(timer);
420
- return { approved };
421
- } catch {
422
- clearTimeout(timer);
423
- console.error(import_chalk.default.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
424
- return { approved: false };
684
+ console.error(import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n"));
685
+ } else if (!cloudEnforced) {
686
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
687
+ console.error(
688
+ import_chalk2.default.dim(`
689
+ \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
690
+ `)
691
+ );
692
+ }
693
+ const abortController = new AbortController();
694
+ const { signal } = abortController;
695
+ const racePromises = [];
696
+ let viewerId = null;
697
+ const internalToken = getInternalToken();
698
+ if (cloudEnforced && cloudRequestId) {
699
+ racePromises.push(
700
+ (async () => {
701
+ try {
702
+ if (isDaemonRunning() && internalToken) {
703
+ viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
704
+ }
705
+ const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
706
+ return {
707
+ approved: cloudResult.approved,
708
+ reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
709
+ checkedBy: cloudResult.approved ? "cloud" : void 0,
710
+ blockedBy: cloudResult.approved ? void 0 : "team-policy",
711
+ blockedByLabel: "Organization Policy (SaaS)"
712
+ };
713
+ } catch (err) {
714
+ const error = err;
715
+ if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
716
+ throw err;
717
+ }
718
+ })()
719
+ );
720
+ }
721
+ if (approvers.native && !isManual) {
722
+ racePromises.push(
723
+ (async () => {
724
+ const decision = await askNativePopup(
725
+ toolName,
726
+ args,
727
+ meta?.agent,
728
+ explainableLabel,
729
+ isRemoteLocked,
730
+ signal
731
+ );
732
+ if (decision === "always_allow") {
733
+ writeTrustSession(toolName, 36e5);
734
+ return { approved: true, checkedBy: "trust" };
735
+ }
736
+ const isApproved = decision === "allow";
737
+ return {
738
+ approved: isApproved,
739
+ reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
740
+ checkedBy: isApproved ? "daemon" : void 0,
741
+ blockedBy: isApproved ? void 0 : "local-decision",
742
+ blockedByLabel: "User Decision (Native)"
743
+ };
744
+ })()
745
+ );
746
+ }
747
+ if (approvers.browser && isDaemonRunning()) {
748
+ racePromises.push(
749
+ (async () => {
750
+ try {
751
+ if (!approvers.native && !cloudEnforced) {
752
+ console.error(
753
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
754
+ );
755
+ console.error(import_chalk2.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
756
+ `));
757
+ }
758
+ const daemonDecision = await askDaemon(toolName, args, meta, signal);
759
+ if (daemonDecision === "abandoned") throw new Error("Abandoned");
760
+ const isApproved = daemonDecision === "allow";
761
+ return {
762
+ approved: isApproved,
763
+ reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
764
+ checkedBy: isApproved ? "daemon" : void 0,
765
+ blockedBy: isApproved ? void 0 : "local-decision",
766
+ blockedByLabel: "User Decision (Browser)"
767
+ };
768
+ } catch (err) {
769
+ throw err;
770
+ }
771
+ })()
772
+ );
773
+ }
774
+ if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
775
+ racePromises.push(
776
+ (async () => {
777
+ try {
778
+ console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
779
+ console.log(`${import_chalk2.default.bold("Action:")} ${import_chalk2.default.red(toolName)}`);
780
+ console.log(`${import_chalk2.default.bold("Flagged By:")} ${import_chalk2.default.yellow(explainableLabel)}`);
781
+ if (isRemoteLocked) {
782
+ console.log(import_chalk2.default.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
783
+ `));
784
+ await new Promise((_, reject) => {
785
+ signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
786
+ });
787
+ }
788
+ const TIMEOUT_MS = 6e4;
789
+ let timer;
790
+ const result = await new Promise((resolve, reject) => {
791
+ timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
792
+ (0, import_prompts.confirm)(
793
+ { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
794
+ { signal }
795
+ ).then(resolve).catch(reject);
796
+ });
797
+ clearTimeout(timer);
798
+ return {
799
+ approved: result,
800
+ reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
801
+ checkedBy: result ? "terminal" : void 0,
802
+ blockedBy: result ? void 0 : "local-decision",
803
+ blockedByLabel: "User Decision (Terminal)"
804
+ };
805
+ } catch (err) {
806
+ const error = err;
807
+ if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
808
+ throw err;
809
+ if (error.message === "Terminal Timeout") {
810
+ return {
811
+ approved: false,
812
+ reason: "The terminal prompt timed out without a human response.",
813
+ blockedBy: "local-decision"
814
+ };
815
+ }
816
+ throw err;
817
+ }
818
+ })()
819
+ );
820
+ }
821
+ if (racePromises.length === 0) {
822
+ return {
823
+ approved: false,
824
+ noApprovalMechanism: true,
825
+ reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
826
+ REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
827
+ blockedBy: "no-approval-mechanism",
828
+ blockedByLabel: explainableLabel
829
+ };
830
+ }
831
+ const finalResult = await new Promise((resolve) => {
832
+ let resolved = false;
833
+ let failures = 0;
834
+ const total = racePromises.length;
835
+ const finish = (res) => {
836
+ if (!resolved) {
837
+ resolved = true;
838
+ abortController.abort();
839
+ if (viewerId && internalToken) {
840
+ resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
841
+ () => null
842
+ );
843
+ }
844
+ resolve(res);
845
+ }
846
+ };
847
+ for (const p of racePromises) {
848
+ p.then(finish).catch((err) => {
849
+ if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
850
+ return;
851
+ if (err.message === "Abandoned") {
852
+ finish({
853
+ approved: false,
854
+ reason: "Browser dashboard closed without making a decision.",
855
+ blockedBy: "local-decision",
856
+ blockedByLabel: "Browser Dashboard (Abandoned)"
857
+ });
858
+ return;
859
+ }
860
+ failures++;
861
+ if (failures === total && !resolved) {
862
+ finish({ approved: false, reason: "All approval channels failed or disconnected." });
863
+ }
864
+ });
425
865
  }
866
+ });
867
+ if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
868
+ await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
426
869
  }
427
- return {
428
- approved: false,
429
- noApprovalMechanism: true,
430
- reason: `Node9 blocked "${toolName}". No approval mechanism is active.`,
431
- blockedBy: "no-approval-mechanism",
432
- changeHint: `Start the approval daemon: node9 daemon --background
433
- Or connect to your team: node9 login <apiKey>`
434
- };
870
+ return finalResult;
435
871
  }
436
872
  function getConfig() {
437
873
  if (cachedConfig) return cachedConfig;
438
- const projectConfig = tryLoadConfig(import_path.default.join(process.cwd(), "node9.config.json"));
439
- if (projectConfig) {
440
- cachedConfig = buildConfig(projectConfig);
441
- return cachedConfig;
442
- }
443
- const globalConfig = tryLoadConfig(import_path.default.join(import_os.default.homedir(), ".node9", "config.json"));
444
- if (globalConfig) {
445
- cachedConfig = buildConfig(globalConfig);
446
- return cachedConfig;
447
- }
448
- cachedConfig = DEFAULT_CONFIG;
874
+ const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
875
+ const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
876
+ const globalConfig = tryLoadConfig(globalPath);
877
+ const projectConfig = tryLoadConfig(projectPath);
878
+ const mergedSettings = {
879
+ ...DEFAULT_CONFIG.settings,
880
+ approvers: { ...DEFAULT_CONFIG.settings.approvers }
881
+ };
882
+ const mergedPolicy = {
883
+ sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
884
+ dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
885
+ ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
886
+ toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
887
+ rules: [...DEFAULT_CONFIG.policy.rules]
888
+ };
889
+ const applyLayer = (source) => {
890
+ if (!source) return;
891
+ const s = source.settings || {};
892
+ const p = source.policy || {};
893
+ if (s.mode !== void 0) mergedSettings.mode = s.mode;
894
+ if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
895
+ if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
896
+ if (s.enableHookLogDebug !== void 0)
897
+ mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
898
+ if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
899
+ if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
900
+ if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
901
+ if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
902
+ if (p.toolInspection)
903
+ mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
904
+ if (p.rules) mergedPolicy.rules.push(...p.rules);
905
+ };
906
+ applyLayer(globalConfig);
907
+ applyLayer(projectConfig);
908
+ if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
909
+ mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
910
+ mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
911
+ mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
912
+ cachedConfig = {
913
+ settings: mergedSettings,
914
+ policy: mergedPolicy,
915
+ environments: {}
916
+ };
449
917
  return cachedConfig;
450
918
  }
451
919
  function tryLoadConfig(filePath) {
452
920
  if (!import_fs.default.existsSync(filePath)) return null;
453
921
  try {
454
- const config = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
455
- validateConfig(config, filePath);
456
- return config;
922
+ return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
457
923
  } catch {
458
924
  return null;
459
925
  }
460
926
  }
461
- function validateConfig(config, path2) {
462
- const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
463
- Object.keys(config).forEach((key) => {
464
- if (!allowedTopLevel.includes(key))
465
- console.warn(import_chalk.default.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path2}`));
466
- });
467
- }
468
- function buildConfig(parsed) {
469
- const p = parsed.policy || {};
470
- const s = parsed.settings || {};
471
- return {
472
- settings: {
473
- mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
474
- autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
475
- },
476
- policy: {
477
- dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
478
- ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
479
- toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
480
- rules: p.rules ?? DEFAULT_CONFIG.policy.rules
481
- },
482
- environments: parsed.environments || {}
483
- };
484
- }
485
927
  function getActiveEnvironment(config) {
486
928
  const env = process.env.NODE_ENV || "development";
487
929
  return config.environments[env] ?? null;
488
930
  }
489
931
  function getCredentials() {
490
932
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
491
- if (process.env.NODE9_API_KEY)
933
+ if (process.env.NODE9_API_KEY) {
492
934
  return {
493
935
  apiKey: process.env.NODE9_API_KEY,
494
936
  apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
495
937
  };
496
- try {
497
- const projectConfigPath = import_path.default.join(process.cwd(), "node9.config.json");
498
- if (import_fs.default.existsSync(projectConfigPath)) {
499
- const projectConfig = JSON.parse(import_fs.default.readFileSync(projectConfigPath, "utf-8"));
500
- if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
501
- return {
502
- apiKey: projectConfig.apiKey,
503
- apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
504
- };
505
- }
506
- }
507
- } catch {
508
938
  }
509
939
  try {
510
940
  const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
@@ -533,10 +963,32 @@ async function authorizeAction(toolName, args) {
533
963
  const result = await authorizeHeadless(toolName, args, true);
534
964
  return result.approved;
535
965
  }
536
- async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
966
+ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
967
+ const controller = new AbortController();
968
+ setTimeout(() => controller.abort(), 5e3);
969
+ fetch(`${creds.apiUrl}/audit`, {
970
+ method: "POST",
971
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
972
+ body: JSON.stringify({
973
+ toolName,
974
+ args,
975
+ checkedBy,
976
+ context: {
977
+ agent: meta?.agent,
978
+ mcpServer: meta?.mcpServer,
979
+ hostname: import_os.default.hostname(),
980
+ cwd: process.cwd(),
981
+ platform: import_os.default.platform()
982
+ }
983
+ }),
984
+ signal: controller.signal
985
+ }).catch(() => {
986
+ });
987
+ }
988
+ async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
989
+ const controller = new AbortController();
990
+ const timeout = setTimeout(() => controller.abort(), 1e4);
537
991
  try {
538
- const controller = new AbortController();
539
- const timeout = setTimeout(() => controller.abort(), 35e3);
540
992
  const response = await fetch(creds.apiUrl, {
541
993
  method: "POST",
542
994
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
@@ -554,46 +1006,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
554
1006
  }),
555
1007
  signal: controller.signal
556
1008
  });
1009
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1010
+ return await response.json();
1011
+ } finally {
557
1012
  clearTimeout(timeout);
558
- if (!response.ok) throw new Error("API fail");
559
- const data = await response.json();
560
- if (!data.pending) return data.approved;
561
- if (!data.requestId) return false;
562
- const statusUrl = `${creds.apiUrl}/status/${data.requestId}`;
563
- console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
564
- if (isDaemonRunning()) {
565
- console.error(
566
- import_chalk.default.cyan(" Browser UI \u2192 ") + import_chalk.default.bold(`http://${DAEMON_HOST}:${DAEMON_PORT}/`)
567
- );
568
- }
569
- console.error(import_chalk.default.cyan(" Dashboard \u2192 ") + import_chalk.default.bold("Mission Control > Flows"));
570
- console.error(import_chalk.default.gray(" Agent is paused. Approve or deny to continue.\n"));
571
- const POLL_INTERVAL_MS = 3e3;
572
- const POLL_DEADLINE = Date.now() + 5 * 60 * 1e3;
573
- while (Date.now() < POLL_DEADLINE) {
574
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
575
- try {
576
- const statusRes = await fetch(statusUrl, {
577
- headers: { Authorization: `Bearer ${creds.apiKey}` },
578
- signal: AbortSignal.timeout(5e3)
579
- });
580
- if (!statusRes.ok) continue;
581
- const { status } = await statusRes.json();
582
- if (status === "APPROVED") {
583
- console.error(import_chalk.default.green("\u2705 Approved \u2014 continuing.\n"));
584
- return true;
585
- }
586
- if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
587
- console.error(import_chalk.default.red("\u274C Denied \u2014 action blocked.\n"));
588
- return false;
589
- }
590
- } catch {
1013
+ }
1014
+ }
1015
+ async function pollNode9SaaS(requestId, creds, signal) {
1016
+ const statusUrl = `${creds.apiUrl}/status/${requestId}`;
1017
+ const POLL_INTERVAL_MS = 1e3;
1018
+ const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
1019
+ while (Date.now() < POLL_DEADLINE) {
1020
+ if (signal.aborted) throw new Error("Aborted");
1021
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1022
+ try {
1023
+ const pollCtrl = new AbortController();
1024
+ const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
1025
+ const statusRes = await fetch(statusUrl, {
1026
+ headers: { Authorization: `Bearer ${creds.apiKey}` },
1027
+ signal: pollCtrl.signal
1028
+ });
1029
+ clearTimeout(pollTimer);
1030
+ if (!statusRes.ok) continue;
1031
+ const { status, reason } = await statusRes.json();
1032
+ if (status === "APPROVED") {
1033
+ console.error(import_chalk2.default.green("\u2705 Approved via Cloud.\n"));
1034
+ return { approved: true, reason };
591
1035
  }
1036
+ if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
1037
+ console.error(import_chalk2.default.red("\u274C Denied via Cloud.\n"));
1038
+ return { approved: false, reason };
1039
+ }
1040
+ } catch {
592
1041
  }
593
- console.error(import_chalk.default.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
594
- return false;
1042
+ }
1043
+ return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
1044
+ }
1045
+ async function resolveNode9SaaS(requestId, creds, approved) {
1046
+ try {
1047
+ const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
1048
+ const ctrl = new AbortController();
1049
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
1050
+ await fetch(resolveUrl, {
1051
+ method: "PATCH",
1052
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1053
+ body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
1054
+ signal: ctrl.signal
1055
+ });
1056
+ clearTimeout(timer);
595
1057
  } catch {
596
- return false;
597
1058
  }
598
1059
  }
599
1060