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