@node9/proxy 0.2.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.
package/dist/cli.js ADDED
@@ -0,0 +1,2754 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_commander = require("commander");
28
+
29
+ // src/core.ts
30
+ var import_chalk = __toESM(require("chalk"));
31
+ var import_prompts = require("@inquirer/prompts");
32
+ var import_fs = __toESM(require("fs"));
33
+ var import_path = __toESM(require("path"));
34
+ var import_os = __toESM(require("os"));
35
+ var import_picomatch = __toESM(require("picomatch"));
36
+ var import_sh_syntax = require("sh-syntax");
37
+ var DANGEROUS_WORDS = [
38
+ "delete",
39
+ "drop",
40
+ "remove",
41
+ "terminate",
42
+ "refund",
43
+ "write",
44
+ "update",
45
+ "destroy",
46
+ "rm",
47
+ "rmdir",
48
+ "purge",
49
+ "format"
50
+ ];
51
+ function tokenize(toolName) {
52
+ return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
53
+ }
54
+ function containsDangerousWord(toolName, dangerousWords) {
55
+ const tokens = tokenize(toolName);
56
+ return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
57
+ }
58
+ function matchesPattern(text, patterns) {
59
+ const p = Array.isArray(patterns) ? patterns : [patterns];
60
+ if (p.length === 0) return false;
61
+ const isMatch = (0, import_picomatch.default)(p, { nocase: true, dot: true });
62
+ const target = text.toLowerCase();
63
+ const directMatch = isMatch(target);
64
+ if (directMatch) return true;
65
+ const withoutDotSlash = text.replace(/^\.\//, "");
66
+ return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
67
+ }
68
+ function getNestedValue(obj, path5) {
69
+ if (!obj || typeof obj !== "object") return null;
70
+ return path5.split(".").reduce((prev, curr) => prev?.[curr], obj);
71
+ }
72
+ function extractShellCommand(toolName, args, toolInspection) {
73
+ const patterns = Object.keys(toolInspection);
74
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
75
+ if (!matchingPattern) return null;
76
+ const fieldPath = toolInspection[matchingPattern];
77
+ const value = getNestedValue(args, fieldPath);
78
+ return typeof value === "string" ? value : null;
79
+ }
80
+ async function analyzeShellCommand(command) {
81
+ const actions = [];
82
+ const paths = [];
83
+ const allTokens = [];
84
+ const addToken = (token) => {
85
+ const lower = token.toLowerCase();
86
+ allTokens.push(lower);
87
+ if (lower.includes("/")) {
88
+ const segments = lower.split("/").filter(Boolean);
89
+ allTokens.push(...segments);
90
+ }
91
+ if (lower.startsWith("-")) {
92
+ allTokens.push(lower.replace(/^-+/, ""));
93
+ }
94
+ };
95
+ try {
96
+ const ast = await (0, import_sh_syntax.parse)(command);
97
+ const walk = (node) => {
98
+ if (!node) return;
99
+ if (node.type === "CallExpr") {
100
+ const parts = (node.Args || []).map((arg) => {
101
+ return (arg.Parts || []).map((p) => p.Value || "").join("");
102
+ }).filter((s) => s.length > 0);
103
+ if (parts.length > 0) {
104
+ actions.push(parts[0].toLowerCase());
105
+ parts.forEach((p) => addToken(p));
106
+ parts.slice(1).forEach((p) => {
107
+ if (!p.startsWith("-")) paths.push(p);
108
+ });
109
+ }
110
+ }
111
+ for (const key in node) {
112
+ if (key === "Parent") continue;
113
+ const val = node[key];
114
+ if (Array.isArray(val)) {
115
+ val.forEach((child) => {
116
+ if (child && typeof child === "object" && "type" in child) {
117
+ walk(child);
118
+ }
119
+ });
120
+ } else if (val && typeof val === "object" && "type" in val) {
121
+ walk(val);
122
+ }
123
+ }
124
+ };
125
+ walk(ast);
126
+ } catch {
127
+ }
128
+ if (allTokens.length === 0) {
129
+ const normalized = command.replace(/\\(.)/g, "$1");
130
+ const sanitized = normalized.replace(/["'<>]/g, " ");
131
+ const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
132
+ segments.forEach((segment) => {
133
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
134
+ if (tokens.length > 0) {
135
+ const action = tokens[0].toLowerCase();
136
+ if (!actions.includes(action)) actions.push(action);
137
+ tokens.forEach((t) => {
138
+ addToken(t);
139
+ if (t !== tokens[0] && !t.startsWith("-")) {
140
+ if (!paths.includes(t)) paths.push(t);
141
+ }
142
+ });
143
+ }
144
+ });
145
+ }
146
+ return { actions, paths, allTokens };
147
+ }
148
+ function redactSecrets(text) {
149
+ if (!text) return text;
150
+ let redacted = text;
151
+ redacted = redacted.replace(
152
+ /(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
153
+ "$1********"
154
+ );
155
+ redacted = redacted.replace(
156
+ /(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
157
+ "$1$2********"
158
+ );
159
+ return redacted;
160
+ }
161
+ var DEFAULT_CONFIG = {
162
+ settings: { mode: "standard" },
163
+ policy: {
164
+ dangerousWords: DANGEROUS_WORDS,
165
+ ignoredTools: [
166
+ "list_*",
167
+ "get_*",
168
+ "read_*",
169
+ "describe_*",
170
+ "read",
171
+ "write",
172
+ "edit",
173
+ "multiedit",
174
+ "glob",
175
+ "grep",
176
+ "ls",
177
+ "notebookread",
178
+ "notebookedit",
179
+ "todoread",
180
+ "todowrite",
181
+ "webfetch",
182
+ "websearch",
183
+ "exitplanmode",
184
+ "askuserquestion"
185
+ ],
186
+ toolInspection: {
187
+ bash: "command",
188
+ run_shell_command: "command",
189
+ shell: "command",
190
+ "terminal.execute": "command"
191
+ },
192
+ rules: [
193
+ { action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
194
+ ]
195
+ },
196
+ environments: {}
197
+ };
198
+ var cachedConfig = null;
199
+ function getGlobalSettings() {
200
+ try {
201
+ const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
202
+ if (import_fs.default.existsSync(globalConfigPath)) {
203
+ const parsed = JSON.parse(import_fs.default.readFileSync(globalConfigPath, "utf-8"));
204
+ const settings = parsed.settings || {};
205
+ return {
206
+ mode: settings.mode || "standard",
207
+ autoStartDaemon: settings.autoStartDaemon !== false,
208
+ slackEnabled: settings.slackEnabled !== false,
209
+ // agentMode defaults to false — user must explicitly opt in via `node9 login`
210
+ agentMode: settings.agentMode === true
211
+ };
212
+ }
213
+ } catch {
214
+ }
215
+ return { mode: "standard", autoStartDaemon: true, slackEnabled: true, agentMode: false };
216
+ }
217
+ function hasSlack() {
218
+ const creds = getCredentials();
219
+ if (!creds?.apiKey) return false;
220
+ return getGlobalSettings().slackEnabled;
221
+ }
222
+ function getInternalToken() {
223
+ try {
224
+ const pidFile = import_path.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
225
+ if (!import_fs.default.existsSync(pidFile)) return null;
226
+ const data = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
227
+ process.kill(data.pid, 0);
228
+ return data.internalToken ?? null;
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+ async function evaluatePolicy(toolName, args) {
234
+ const config = getConfig();
235
+ if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
236
+ const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
237
+ if (shellCommand) {
238
+ const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
239
+ const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
240
+ if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) return "review";
241
+ for (const action of actions) {
242
+ const basename = action.includes("/") ? action.split("/").pop() : action;
243
+ const rule = config.policy.rules.find(
244
+ (r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
245
+ );
246
+ if (rule) {
247
+ if (paths.length > 0) {
248
+ const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
249
+ if (anyBlocked) return "review";
250
+ const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
251
+ if (allAllowed) return "allow";
252
+ }
253
+ return "review";
254
+ }
255
+ }
256
+ const isDangerous2 = allTokens.some(
257
+ (token) => config.policy.dangerousWords.some((word) => {
258
+ const w = word.toLowerCase();
259
+ if (token === w) return true;
260
+ try {
261
+ return new RegExp(`\\b${w}\\b`, "i").test(token);
262
+ } catch {
263
+ return false;
264
+ }
265
+ })
266
+ );
267
+ if (isDangerous2) return "review";
268
+ if (config.settings.mode === "strict") return "review";
269
+ return "allow";
270
+ }
271
+ const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
272
+ if (isDangerous || config.settings.mode === "strict") {
273
+ const envConfig = getActiveEnvironment(config);
274
+ if (envConfig?.requireApproval === false) return "allow";
275
+ return "review";
276
+ }
277
+ return "allow";
278
+ }
279
+ function isIgnoredTool(toolName) {
280
+ const config = getConfig();
281
+ return matchesPattern(toolName, config.policy.ignoredTools);
282
+ }
283
+ var DAEMON_PORT = 7391;
284
+ var DAEMON_HOST = "127.0.0.1";
285
+ function isDaemonRunning() {
286
+ try {
287
+ const pidFile = import_path.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
288
+ if (!import_fs.default.existsSync(pidFile)) return false;
289
+ const { pid, port } = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
290
+ if (port !== DAEMON_PORT) return false;
291
+ process.kill(pid, 0);
292
+ return true;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+ function getPersistentDecision(toolName) {
298
+ try {
299
+ const file = import_path.default.join(import_os.default.homedir(), ".node9", "decisions.json");
300
+ if (!import_fs.default.existsSync(file)) return null;
301
+ const decisions = JSON.parse(import_fs.default.readFileSync(file, "utf-8"));
302
+ const d = decisions[toolName];
303
+ if (d === "allow" || d === "deny") return d;
304
+ } catch {
305
+ }
306
+ return null;
307
+ }
308
+ async function askDaemon(toolName, args, meta) {
309
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
310
+ const checkRes = await fetch(`${base}/check`, {
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/json" },
313
+ body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
314
+ signal: AbortSignal.timeout(5e3)
315
+ });
316
+ if (!checkRes.ok) throw new Error("Daemon fail");
317
+ const { id } = await checkRes.json();
318
+ const waitRes = await fetch(`${base}/wait/${id}`, { signal: AbortSignal.timeout(12e4) });
319
+ if (!waitRes.ok) return "deny";
320
+ const { decision } = await waitRes.json();
321
+ if (decision === "allow") return "allow";
322
+ if (decision === "abandoned") return "abandoned";
323
+ return "deny";
324
+ }
325
+ async function notifyDaemonViewer(toolName, args, meta) {
326
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
327
+ const res = await fetch(`${base}/check`, {
328
+ method: "POST",
329
+ headers: { "Content-Type": "application/json" },
330
+ body: JSON.stringify({
331
+ toolName,
332
+ args,
333
+ slackDelegated: true,
334
+ agent: meta?.agent,
335
+ mcpServer: meta?.mcpServer
336
+ }),
337
+ signal: AbortSignal.timeout(3e3)
338
+ });
339
+ if (!res.ok) throw new Error("Daemon unreachable");
340
+ const { id } = await res.json();
341
+ return id;
342
+ }
343
+ async function resolveViaDaemon(id, decision, internalToken) {
344
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
345
+ await fetch(`${base}/resolve/${id}`, {
346
+ method: "POST",
347
+ headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
348
+ body: JSON.stringify({ decision }),
349
+ signal: AbortSignal.timeout(3e3)
350
+ });
351
+ }
352
+ async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
353
+ const { agentMode } = getGlobalSettings();
354
+ const cloudEnforced = agentMode && hasSlack();
355
+ if (!cloudEnforced) {
356
+ if (isIgnoredTool(toolName)) return { approved: true };
357
+ const policyDecision = await evaluatePolicy(toolName, args);
358
+ if (policyDecision === "allow") return { approved: true, checkedBy: "local-policy" };
359
+ const persistent = getPersistentDecision(toolName);
360
+ if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
361
+ if (persistent === "deny")
362
+ return {
363
+ approved: false,
364
+ reason: `Node9: "${toolName}" is set to always deny.`,
365
+ blockedBy: "persistent-deny",
366
+ changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
367
+ };
368
+ }
369
+ if (cloudEnforced) {
370
+ const creds = getCredentials();
371
+ const envConfig = getActiveEnvironment(getConfig());
372
+ let viewerId = null;
373
+ const internalToken = getInternalToken();
374
+ if (isDaemonRunning() && internalToken) {
375
+ viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
376
+ }
377
+ const approved = await callNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
378
+ if (viewerId && internalToken) {
379
+ resolveViaDaemon(viewerId, approved ? "allow" : "deny", internalToken).catch(() => null);
380
+ }
381
+ return {
382
+ approved,
383
+ checkedBy: approved ? "cloud" : void 0,
384
+ blockedBy: approved ? void 0 : "team-policy",
385
+ changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
386
+ };
387
+ }
388
+ if (isDaemonRunning()) {
389
+ console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
390
+ console.error(import_chalk.default.cyan(` Browser UI \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
391
+ `));
392
+ try {
393
+ const daemonDecision = await askDaemon(toolName, args, meta);
394
+ if (daemonDecision === "abandoned") {
395
+ console.error(import_chalk.default.yellow("\n\u26A0\uFE0F Browser closed without a decision. Falling back..."));
396
+ } else {
397
+ return {
398
+ approved: daemonDecision === "allow",
399
+ reason: daemonDecision === "deny" ? `Node9 blocked "${toolName}" \u2014 denied in browser.` : void 0,
400
+ checkedBy: daemonDecision === "allow" ? "daemon" : void 0,
401
+ blockedBy: daemonDecision === "deny" ? "local-decision" : void 0,
402
+ changeHint: daemonDecision === "deny" ? `Open the daemon UI to change: node9 daemon --openui` : void 0
403
+ };
404
+ }
405
+ } catch {
406
+ }
407
+ }
408
+ if (allowTerminalFallback && process.stdout.isTTY) {
409
+ console.log(import_chalk.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
410
+ console.log(`${import_chalk.default.bold("Action:")} ${import_chalk.default.red(toolName)}`);
411
+ const argsPreview = JSON.stringify(args, null, 2);
412
+ console.log(
413
+ `${import_chalk.default.bold("Args:")}
414
+ ${import_chalk.default.gray(argsPreview.length > 500 ? argsPreview.slice(0, 500) + "..." : argsPreview)}`
415
+ );
416
+ const controller = new AbortController();
417
+ const TIMEOUT_MS = 3e4;
418
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
419
+ try {
420
+ const approved = await (0, import_prompts.confirm)(
421
+ { message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
422
+ { signal: controller.signal }
423
+ );
424
+ clearTimeout(timer);
425
+ return { approved };
426
+ } catch {
427
+ clearTimeout(timer);
428
+ console.error(import_chalk.default.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
429
+ return { approved: false };
430
+ }
431
+ }
432
+ return {
433
+ approved: false,
434
+ noApprovalMechanism: true,
435
+ reason: `Node9 blocked "${toolName}". No approval mechanism is active.`,
436
+ blockedBy: "no-approval-mechanism",
437
+ changeHint: `Start the approval daemon: node9 daemon --background
438
+ Or connect to your team: node9 login <apiKey>`
439
+ };
440
+ }
441
+ function listCredentialProfiles() {
442
+ try {
443
+ const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
444
+ if (!import_fs.default.existsSync(credPath)) return [];
445
+ const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
446
+ if (!creds.apiKey) return Object.keys(creds).filter((k) => typeof creds[k] === "object");
447
+ } catch {
448
+ }
449
+ return [];
450
+ }
451
+ function getConfig() {
452
+ if (cachedConfig) return cachedConfig;
453
+ const projectConfig = tryLoadConfig(import_path.default.join(process.cwd(), "node9.config.json"));
454
+ if (projectConfig) {
455
+ cachedConfig = buildConfig(projectConfig);
456
+ return cachedConfig;
457
+ }
458
+ const globalConfig = tryLoadConfig(import_path.default.join(import_os.default.homedir(), ".node9", "config.json"));
459
+ if (globalConfig) {
460
+ cachedConfig = buildConfig(globalConfig);
461
+ return cachedConfig;
462
+ }
463
+ cachedConfig = DEFAULT_CONFIG;
464
+ return cachedConfig;
465
+ }
466
+ function tryLoadConfig(filePath) {
467
+ if (!import_fs.default.existsSync(filePath)) return null;
468
+ try {
469
+ const config = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
470
+ validateConfig(config, filePath);
471
+ return config;
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+ function validateConfig(config, path5) {
477
+ const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
478
+ Object.keys(config).forEach((key) => {
479
+ if (!allowedTopLevel.includes(key))
480
+ console.warn(import_chalk.default.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path5}`));
481
+ });
482
+ }
483
+ function buildConfig(parsed) {
484
+ const p = parsed.policy || {};
485
+ const s = parsed.settings || {};
486
+ return {
487
+ settings: {
488
+ mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
489
+ autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
490
+ },
491
+ policy: {
492
+ dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
493
+ ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
494
+ toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
495
+ rules: p.rules ?? DEFAULT_CONFIG.policy.rules
496
+ },
497
+ environments: parsed.environments || {}
498
+ };
499
+ }
500
+ function getActiveEnvironment(config) {
501
+ const env = process.env.NODE_ENV || "development";
502
+ return config.environments[env] ?? null;
503
+ }
504
+ function getCredentials() {
505
+ const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
506
+ if (process.env.NODE9_API_KEY)
507
+ return {
508
+ apiKey: process.env.NODE9_API_KEY,
509
+ apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
510
+ };
511
+ try {
512
+ const projectConfigPath = import_path.default.join(process.cwd(), "node9.config.json");
513
+ if (import_fs.default.existsSync(projectConfigPath)) {
514
+ const projectConfig = JSON.parse(import_fs.default.readFileSync(projectConfigPath, "utf-8"));
515
+ if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
516
+ return {
517
+ apiKey: projectConfig.apiKey,
518
+ apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
519
+ };
520
+ }
521
+ }
522
+ } catch {
523
+ }
524
+ try {
525
+ const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
526
+ if (import_fs.default.existsSync(credPath)) {
527
+ const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
528
+ const profileName = process.env.NODE9_PROFILE || "default";
529
+ const profile = creds[profileName];
530
+ if (profile?.apiKey) {
531
+ return {
532
+ apiKey: profile.apiKey,
533
+ apiUrl: profile.apiUrl || DEFAULT_API_URL
534
+ };
535
+ }
536
+ if (creds.apiKey) {
537
+ return {
538
+ apiKey: creds.apiKey,
539
+ apiUrl: creds.apiUrl || DEFAULT_API_URL
540
+ };
541
+ }
542
+ }
543
+ } catch {
544
+ }
545
+ return null;
546
+ }
547
+ async function authorizeAction(toolName, args) {
548
+ const result = await authorizeHeadless(toolName, args, true);
549
+ return result.approved;
550
+ }
551
+ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
552
+ try {
553
+ const controller = new AbortController();
554
+ const timeout = setTimeout(() => controller.abort(), 35e3);
555
+ const response = await fetch(creds.apiUrl, {
556
+ method: "POST",
557
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
558
+ body: JSON.stringify({
559
+ toolName,
560
+ args,
561
+ slackChannel,
562
+ context: {
563
+ agent: meta?.agent,
564
+ mcpServer: meta?.mcpServer,
565
+ hostname: import_os.default.hostname(),
566
+ cwd: process.cwd(),
567
+ platform: import_os.default.platform()
568
+ }
569
+ }),
570
+ signal: controller.signal
571
+ });
572
+ clearTimeout(timeout);
573
+ if (!response.ok) throw new Error("API fail");
574
+ const data = await response.json();
575
+ if (!data.pending) return data.approved;
576
+ if (!data.requestId) return false;
577
+ const statusUrl = `${creds.apiUrl}/status/${data.requestId}`;
578
+ console.error(import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
579
+ if (isDaemonRunning()) {
580
+ console.error(
581
+ import_chalk.default.cyan(" Browser UI \u2192 ") + import_chalk.default.bold(`http://${DAEMON_HOST}:${DAEMON_PORT}/`)
582
+ );
583
+ }
584
+ console.error(import_chalk.default.cyan(" Dashboard \u2192 ") + import_chalk.default.bold("Mission Control > Flows"));
585
+ console.error(import_chalk.default.gray(" Agent is paused. Approve or deny to continue.\n"));
586
+ const POLL_INTERVAL_MS = 3e3;
587
+ const POLL_DEADLINE = Date.now() + 5 * 60 * 1e3;
588
+ while (Date.now() < POLL_DEADLINE) {
589
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
590
+ try {
591
+ const statusRes = await fetch(statusUrl, {
592
+ headers: { Authorization: `Bearer ${creds.apiKey}` },
593
+ signal: AbortSignal.timeout(5e3)
594
+ });
595
+ if (!statusRes.ok) continue;
596
+ const { status } = await statusRes.json();
597
+ if (status === "APPROVED") {
598
+ console.error(import_chalk.default.green("\u2705 Approved \u2014 continuing.\n"));
599
+ return true;
600
+ }
601
+ if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
602
+ console.error(import_chalk.default.red("\u274C Denied \u2014 action blocked.\n"));
603
+ return false;
604
+ }
605
+ } catch {
606
+ }
607
+ }
608
+ console.error(import_chalk.default.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
609
+ return false;
610
+ } catch {
611
+ return false;
612
+ }
613
+ }
614
+
615
+ // src/setup.ts
616
+ var import_fs2 = __toESM(require("fs"));
617
+ var import_path2 = __toESM(require("path"));
618
+ var import_os2 = __toESM(require("os"));
619
+ var import_chalk2 = __toESM(require("chalk"));
620
+ var import_prompts2 = require("@inquirer/prompts");
621
+ function printDaemonTip() {
622
+ console.log(
623
+ import_chalk2.default.cyan("\n \u{1F4A1} Enable browser approvals (no API key needed):") + import_chalk2.default.green(" node9 daemon --background")
624
+ );
625
+ }
626
+ function fullPathCommand(subcommand) {
627
+ if (process.env.NODE9_TESTING === "1") return `node9 ${subcommand}`;
628
+ const nodeExec = process.execPath;
629
+ const cliScript = process.argv[1];
630
+ return `${nodeExec} ${cliScript} ${subcommand}`;
631
+ }
632
+ function readJson(filePath) {
633
+ try {
634
+ if (import_fs2.default.existsSync(filePath)) {
635
+ return JSON.parse(import_fs2.default.readFileSync(filePath, "utf-8"));
636
+ }
637
+ } catch {
638
+ }
639
+ return null;
640
+ }
641
+ function writeJson(filePath, data) {
642
+ const dir = import_path2.default.dirname(filePath);
643
+ if (!import_fs2.default.existsSync(dir)) import_fs2.default.mkdirSync(dir, { recursive: true });
644
+ import_fs2.default.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
645
+ }
646
+ async function setupClaude() {
647
+ const homeDir2 = import_os2.default.homedir();
648
+ const mcpPath = import_path2.default.join(homeDir2, ".claude.json");
649
+ const hooksPath = import_path2.default.join(homeDir2, ".claude", "settings.json");
650
+ const claudeConfig = readJson(mcpPath) ?? {};
651
+ const settings = readJson(hooksPath) ?? {};
652
+ const servers = claudeConfig.mcpServers ?? {};
653
+ let anythingChanged = false;
654
+ if (!settings.hooks) settings.hooks = {};
655
+ const hasPreHook = settings.hooks.PreToolUse?.some(
656
+ (m) => m.hooks.some((h) => h.command?.includes("node9 check") || h.command?.includes("cli.js check"))
657
+ );
658
+ if (!hasPreHook) {
659
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
660
+ settings.hooks.PreToolUse.push({
661
+ matcher: ".*",
662
+ hooks: [{ type: "command", command: fullPathCommand("check"), timeout: 60 }]
663
+ });
664
+ console.log(import_chalk2.default.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
665
+ anythingChanged = true;
666
+ }
667
+ const hasPostHook = settings.hooks.PostToolUse?.some(
668
+ (m) => m.hooks.some((h) => h.command?.includes("node9 log") || h.command?.includes("cli.js log"))
669
+ );
670
+ if (!hasPostHook) {
671
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
672
+ settings.hooks.PostToolUse.push({
673
+ matcher: ".*",
674
+ hooks: [{ type: "command", command: fullPathCommand("log") }]
675
+ });
676
+ console.log(import_chalk2.default.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
677
+ anythingChanged = true;
678
+ }
679
+ if (anythingChanged) {
680
+ writeJson(hooksPath, settings);
681
+ console.log("");
682
+ }
683
+ const serversToWrap = [];
684
+ for (const [name, server] of Object.entries(servers)) {
685
+ if (!server.command || server.command === "node9") continue;
686
+ const parts = [server.command, ...server.args ?? []];
687
+ serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
688
+ }
689
+ if (serversToWrap.length > 0) {
690
+ console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
691
+ console.log(import_chalk2.default.white(` ${mcpPath}`));
692
+ for (const { name, originalCmd } of serversToWrap) {
693
+ console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
694
+ }
695
+ console.log("");
696
+ const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
697
+ if (proceed) {
698
+ for (const { name, parts } of serversToWrap) {
699
+ servers[name] = { ...servers[name], command: "node9", args: parts };
700
+ }
701
+ claudeConfig.mcpServers = servers;
702
+ writeJson(mcpPath, claudeConfig);
703
+ console.log(import_chalk2.default.green(`
704
+ \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
705
+ anythingChanged = true;
706
+ } else {
707
+ console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
708
+ }
709
+ console.log("");
710
+ }
711
+ if (!anythingChanged && serversToWrap.length === 0) {
712
+ console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
713
+ printDaemonTip();
714
+ return;
715
+ }
716
+ if (anythingChanged) {
717
+ console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
718
+ console.log(import_chalk2.default.gray(" Restart Claude Code for changes to take effect."));
719
+ printDaemonTip();
720
+ }
721
+ }
722
+ async function setupGemini() {
723
+ const homeDir2 = import_os2.default.homedir();
724
+ const settingsPath = import_path2.default.join(homeDir2, ".gemini", "settings.json");
725
+ const settings = readJson(settingsPath) ?? {};
726
+ const servers = settings.mcpServers ?? {};
727
+ let anythingChanged = false;
728
+ if (!settings.hooks) settings.hooks = {};
729
+ const hasBeforeHook = Array.isArray(settings.hooks.BeforeTool) && settings.hooks.BeforeTool.some(
730
+ (m) => m.hooks.some((h) => h.command?.includes("node9 check") || h.command?.includes("cli.js check"))
731
+ );
732
+ if (!hasBeforeHook) {
733
+ if (!settings.hooks.BeforeTool) settings.hooks.BeforeTool = [];
734
+ if (!Array.isArray(settings.hooks.BeforeTool)) settings.hooks.BeforeTool = [];
735
+ settings.hooks.BeforeTool.push({
736
+ matcher: ".*",
737
+ hooks: [
738
+ { name: "node9-check", type: "command", command: fullPathCommand("check"), timeout: 6e4 }
739
+ ]
740
+ });
741
+ console.log(import_chalk2.default.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
742
+ anythingChanged = true;
743
+ }
744
+ const hasAfterHook = Array.isArray(settings.hooks.AfterTool) && settings.hooks.AfterTool.some(
745
+ (m) => m.hooks.some((h) => h.command?.includes("node9 log") || h.command?.includes("cli.js log"))
746
+ );
747
+ if (!hasAfterHook) {
748
+ if (!settings.hooks.AfterTool) settings.hooks.AfterTool = [];
749
+ if (!Array.isArray(settings.hooks.AfterTool)) settings.hooks.AfterTool = [];
750
+ settings.hooks.AfterTool.push({
751
+ matcher: ".*",
752
+ hooks: [{ name: "node9-log", type: "command", command: fullPathCommand("log") }]
753
+ });
754
+ console.log(import_chalk2.default.green(" \u2705 AfterTool hook added \u2192 node9 log"));
755
+ anythingChanged = true;
756
+ }
757
+ if (anythingChanged) {
758
+ writeJson(settingsPath, settings);
759
+ console.log("");
760
+ }
761
+ const serversToWrap = [];
762
+ for (const [name, server] of Object.entries(servers)) {
763
+ if (!server.command || server.command === "node9") continue;
764
+ const parts = [server.command, ...server.args ?? []];
765
+ serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
766
+ }
767
+ if (serversToWrap.length > 0) {
768
+ console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
769
+ console.log(import_chalk2.default.white(` ${settingsPath} (mcpServers)`));
770
+ for (const { name, originalCmd } of serversToWrap) {
771
+ console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
772
+ }
773
+ console.log("");
774
+ const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
775
+ if (proceed) {
776
+ for (const { name, parts } of serversToWrap) {
777
+ servers[name] = { ...servers[name], command: "node9", args: parts };
778
+ }
779
+ settings.mcpServers = servers;
780
+ writeJson(settingsPath, settings);
781
+ console.log(import_chalk2.default.green(`
782
+ \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
783
+ anythingChanged = true;
784
+ } else {
785
+ console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
786
+ }
787
+ console.log("");
788
+ }
789
+ if (!anythingChanged && serversToWrap.length === 0) {
790
+ console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
791
+ printDaemonTip();
792
+ return;
793
+ }
794
+ if (anythingChanged) {
795
+ console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
796
+ console.log(import_chalk2.default.gray(" Restart Gemini CLI for changes to take effect."));
797
+ printDaemonTip();
798
+ }
799
+ }
800
+ async function setupCursor() {
801
+ const homeDir2 = import_os2.default.homedir();
802
+ const mcpPath = import_path2.default.join(homeDir2, ".cursor", "mcp.json");
803
+ const hooksPath = import_path2.default.join(homeDir2, ".cursor", "hooks.json");
804
+ const mcpConfig = readJson(mcpPath) ?? {};
805
+ const hooksFile = readJson(hooksPath) ?? { version: 1 };
806
+ const servers = mcpConfig.mcpServers ?? {};
807
+ let anythingChanged = false;
808
+ if (!hooksFile.hooks) hooksFile.hooks = {};
809
+ const hasPreHook = hooksFile.hooks.preToolUse?.some(
810
+ (h) => h.command === "node9" && h.args?.includes("check") || h.command?.includes("cli.js")
811
+ );
812
+ if (!hasPreHook) {
813
+ if (!hooksFile.hooks.preToolUse) hooksFile.hooks.preToolUse = [];
814
+ hooksFile.hooks.preToolUse.push({ command: fullPathCommand("check") });
815
+ console.log(import_chalk2.default.green(" \u2705 preToolUse hook added \u2192 node9 check"));
816
+ anythingChanged = true;
817
+ }
818
+ const hasPostHook = hooksFile.hooks.postToolUse?.some(
819
+ (h) => h.command === "node9" && h.args?.includes("log") || h.command?.includes("cli.js")
820
+ );
821
+ if (!hasPostHook) {
822
+ if (!hooksFile.hooks.postToolUse) hooksFile.hooks.postToolUse = [];
823
+ hooksFile.hooks.postToolUse.push({ command: fullPathCommand("log") });
824
+ console.log(import_chalk2.default.green(" \u2705 postToolUse hook added \u2192 node9 log"));
825
+ anythingChanged = true;
826
+ }
827
+ if (anythingChanged) {
828
+ writeJson(hooksPath, hooksFile);
829
+ console.log("");
830
+ }
831
+ const serversToWrap = [];
832
+ for (const [name, server] of Object.entries(servers)) {
833
+ if (!server.command || server.command === "node9") continue;
834
+ const parts = [server.command, ...server.args ?? []];
835
+ serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
836
+ }
837
+ if (serversToWrap.length > 0) {
838
+ console.log(import_chalk2.default.bold("The following existing entries will be modified:\n"));
839
+ console.log(import_chalk2.default.white(` ${mcpPath}`));
840
+ for (const { name, originalCmd } of serversToWrap) {
841
+ console.log(import_chalk2.default.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
842
+ }
843
+ console.log("");
844
+ const proceed = await (0, import_prompts2.confirm)({ message: "Wrap these MCP servers?", default: true });
845
+ if (proceed) {
846
+ for (const { name, parts } of serversToWrap) {
847
+ servers[name] = { ...servers[name], command: "node9", args: parts };
848
+ }
849
+ mcpConfig.mcpServers = servers;
850
+ writeJson(mcpPath, mcpConfig);
851
+ console.log(import_chalk2.default.green(`
852
+ \u2705 ${serversToWrap.length} MCP server(s) wrapped`));
853
+ anythingChanged = true;
854
+ } else {
855
+ console.log(import_chalk2.default.yellow(" Skipped MCP server wrapping."));
856
+ }
857
+ console.log("");
858
+ }
859
+ if (!anythingChanged && serversToWrap.length === 0) {
860
+ console.log(import_chalk2.default.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
861
+ printDaemonTip();
862
+ return;
863
+ }
864
+ if (anythingChanged) {
865
+ console.log(import_chalk2.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
866
+ console.log(import_chalk2.default.gray(" Restart Cursor for changes to take effect."));
867
+ printDaemonTip();
868
+ }
869
+ }
870
+
871
+ // src/daemon/ui.html
872
+ var ui_default = `<!doctype html>
873
+ <html lang="en">
874
+ <head>
875
+ <meta charset="UTF-8" />
876
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
877
+ <title>Node9 Security Guard</title>
878
+ <style>
879
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Fira+Code:wght@400;500&display=swap');
880
+ :root {
881
+ --bg: #0a0c10;
882
+ --card: #1c2128;
883
+ --panel: #161b22;
884
+ --border: #30363d;
885
+ --text: #adbac7;
886
+ --text-bright: #cdd9e5;
887
+ --muted: #768390;
888
+ --primary: #f0883e;
889
+ --success: #347d39;
890
+ --danger: #c93c37;
891
+ --accent: #539bf5;
892
+ }
893
+ * {
894
+ box-sizing: border-box;
895
+ margin: 0;
896
+ padding: 0;
897
+ }
898
+ body {
899
+ background: var(--bg);
900
+ color: var(--text);
901
+ font-family:
902
+ 'Inter',
903
+ -apple-system,
904
+ sans-serif;
905
+ min-height: 100vh;
906
+ }
907
+
908
+ .shell {
909
+ max-width: 1000px;
910
+ margin: 0 auto;
911
+ padding: 32px 24px 48px;
912
+ display: grid;
913
+ grid-template-rows: auto 1fr;
914
+ gap: 24px;
915
+ }
916
+ header {
917
+ display: flex;
918
+ align-items: center;
919
+ gap: 12px;
920
+ padding-bottom: 20px;
921
+ border-bottom: 1px solid var(--border);
922
+ }
923
+ .logo {
924
+ font-size: 24px;
925
+ }
926
+ h1 {
927
+ font-size: 18px;
928
+ font-weight: 700;
929
+ color: var(--text-bright);
930
+ letter-spacing: -0.3px;
931
+ }
932
+ .status-badge {
933
+ margin-left: auto;
934
+ font-size: 11px;
935
+ font-weight: 700;
936
+ padding: 3px 10px;
937
+ border-radius: 6px;
938
+ text-transform: uppercase;
939
+ letter-spacing: 0.5px;
940
+ background: rgba(48, 54, 61, 0.5);
941
+ border: 1px solid var(--border);
942
+ color: var(--muted);
943
+ }
944
+ .status-badge.online {
945
+ color: #57ab5a;
946
+ border-color: rgba(52, 125, 57, 0.4);
947
+ }
948
+
949
+ .body {
950
+ display: grid;
951
+ grid-template-columns: 1fr 272px;
952
+ gap: 20px;
953
+ align-items: start;
954
+ }
955
+
956
+ .warning-banner {
957
+ display: none;
958
+ background: rgba(240, 136, 62, 0.08);
959
+ border: 1px solid rgba(240, 136, 62, 0.3);
960
+ border-radius: 10px;
961
+ padding: 10px 14px;
962
+ margin-bottom: 14px;
963
+ font-size: 12px;
964
+ color: var(--primary);
965
+ line-height: 1.5;
966
+ }
967
+ .warning-banner.show {
968
+ display: block;
969
+ }
970
+
971
+ .main {
972
+ min-width: 0;
973
+ }
974
+ .section-title {
975
+ font-size: 11px;
976
+ font-weight: 700;
977
+ text-transform: uppercase;
978
+ letter-spacing: 1px;
979
+ color: var(--muted);
980
+ margin-bottom: 12px;
981
+ }
982
+ #empty {
983
+ display: flex;
984
+ flex-direction: column;
985
+ align-items: center;
986
+ justify-content: center;
987
+ gap: 10px;
988
+ padding: 48px 24px;
989
+ border: 2px dashed var(--border);
990
+ border-radius: 16px;
991
+ color: var(--muted);
992
+ font-size: 13px;
993
+ text-align: center;
994
+ line-height: 1.5;
995
+ }
996
+ #empty .empty-icon {
997
+ font-size: 28px;
998
+ }
999
+ .card {
1000
+ background: var(--card);
1001
+ border: 1px solid var(--border);
1002
+ border-radius: 14px;
1003
+ padding: 24px;
1004
+ margin-bottom: 16px;
1005
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
1006
+ animation: pop 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
1007
+ }
1008
+ .card.slack-viewer {
1009
+ border-color: rgba(83, 155, 245, 0.3);
1010
+ }
1011
+ @keyframes pop {
1012
+ from {
1013
+ opacity: 0;
1014
+ transform: scale(0.97) translateY(6px);
1015
+ }
1016
+ to {
1017
+ opacity: 1;
1018
+ transform: none;
1019
+ }
1020
+ }
1021
+ .label {
1022
+ font-size: 10px;
1023
+ font-weight: 700;
1024
+ text-transform: uppercase;
1025
+ color: var(--muted);
1026
+ letter-spacing: 1px;
1027
+ display: block;
1028
+ margin-bottom: 6px;
1029
+ }
1030
+ .source-row {
1031
+ display: flex;
1032
+ align-items: center;
1033
+ gap: 6px;
1034
+ margin-bottom: 10px;
1035
+ flex-wrap: wrap;
1036
+ }
1037
+ .agent-badge {
1038
+ font-size: 11px;
1039
+ font-weight: 600;
1040
+ padding: 2px 8px;
1041
+ border-radius: 5px;
1042
+ background: rgba(83, 155, 245, 0.12);
1043
+ border: 1px solid rgba(83, 155, 245, 0.25);
1044
+ color: var(--accent);
1045
+ letter-spacing: 0.2px;
1046
+ }
1047
+ .mcp-badge {
1048
+ font-size: 11px;
1049
+ font-weight: 600;
1050
+ padding: 2px 8px;
1051
+ border-radius: 5px;
1052
+ background: rgba(87, 171, 90, 0.12);
1053
+ border: 1px solid rgba(87, 171, 90, 0.25);
1054
+ color: #57ab5a;
1055
+ font-family: 'Fira Code', monospace;
1056
+ letter-spacing: 0.2px;
1057
+ }
1058
+ .source-arrow {
1059
+ font-size: 11px;
1060
+ color: var(--muted);
1061
+ }
1062
+ .tool-chip {
1063
+ display: inline-block;
1064
+ padding: 3px 10px;
1065
+ background: rgba(240, 136, 62, 0.1);
1066
+ color: var(--primary);
1067
+ border-radius: 6px;
1068
+ font-family: 'Fira Code', monospace;
1069
+ font-size: 13px;
1070
+ font-weight: 500;
1071
+ margin-bottom: 16px;
1072
+ }
1073
+ .slack-indicator {
1074
+ display: flex;
1075
+ align-items: center;
1076
+ gap: 6px;
1077
+ font-size: 11px;
1078
+ color: var(--accent);
1079
+ background: rgba(83, 155, 245, 0.08);
1080
+ border: 1px solid rgba(83, 155, 245, 0.2);
1081
+ border-radius: 6px;
1082
+ padding: 5px 10px;
1083
+ margin-bottom: 14px;
1084
+ }
1085
+ pre {
1086
+ background: #0d1117;
1087
+ padding: 14px 16px;
1088
+ border-radius: 10px;
1089
+ font-family: 'Fira Code', monospace;
1090
+ font-size: 12px;
1091
+ color: #e6edf3;
1092
+ line-height: 1.6;
1093
+ border: 1px solid var(--border);
1094
+ margin-bottom: 20px;
1095
+ overflow-x: auto;
1096
+ white-space: pre-wrap;
1097
+ word-break: break-all;
1098
+ }
1099
+ .actions {
1100
+ display: grid;
1101
+ grid-template-columns: 1fr 1fr;
1102
+ gap: 8px;
1103
+ }
1104
+ button {
1105
+ padding: 11px 14px;
1106
+ border-radius: 8px;
1107
+ border: 1px solid transparent;
1108
+ font-size: 13px;
1109
+ font-weight: 600;
1110
+ cursor: pointer;
1111
+ transition: all 0.15s ease;
1112
+ display: flex;
1113
+ align-items: center;
1114
+ justify-content: center;
1115
+ gap: 6px;
1116
+ font-family: inherit;
1117
+ }
1118
+ .btn-allow {
1119
+ background: var(--success);
1120
+ color: #fff;
1121
+ grid-column: span 2;
1122
+ }
1123
+ .btn-deny {
1124
+ background: var(--danger);
1125
+ color: #fff;
1126
+ grid-column: span 2;
1127
+ }
1128
+ .btn-secondary {
1129
+ background: transparent;
1130
+ border-color: var(--border);
1131
+ color: var(--text);
1132
+ font-size: 12px;
1133
+ font-weight: 500;
1134
+ }
1135
+ button:hover:not(:disabled) {
1136
+ filter: brightness(1.15);
1137
+ transform: translateY(-1px);
1138
+ }
1139
+ button:active:not(:disabled) {
1140
+ transform: none;
1141
+ filter: brightness(0.95);
1142
+ }
1143
+ button:disabled {
1144
+ opacity: 0.35;
1145
+ cursor: not-allowed;
1146
+ }
1147
+
1148
+ .sidebar {
1149
+ display: flex;
1150
+ flex-direction: column;
1151
+ gap: 12px;
1152
+ position: sticky;
1153
+ top: 24px;
1154
+ }
1155
+ .panel {
1156
+ background: var(--panel);
1157
+ border: 1px solid var(--border);
1158
+ border-radius: 12px;
1159
+ padding: 16px;
1160
+ }
1161
+ .panel-title {
1162
+ font-size: 12px;
1163
+ font-weight: 700;
1164
+ color: var(--text-bright);
1165
+ margin-bottom: 12px;
1166
+ display: flex;
1167
+ align-items: center;
1168
+ gap: 6px;
1169
+ }
1170
+ .setting-row {
1171
+ display: flex;
1172
+ align-items: flex-start;
1173
+ gap: 12px;
1174
+ margin-bottom: 12px;
1175
+ }
1176
+ .setting-row:last-child {
1177
+ margin-bottom: 0;
1178
+ }
1179
+ .setting-text {
1180
+ flex: 1;
1181
+ }
1182
+ .setting-label {
1183
+ font-size: 12px;
1184
+ color: var(--text-bright);
1185
+ margin-bottom: 3px;
1186
+ }
1187
+ .setting-desc {
1188
+ font-size: 11px;
1189
+ color: var(--muted);
1190
+ line-height: 1.5;
1191
+ }
1192
+ .toggle {
1193
+ position: relative;
1194
+ display: inline-block;
1195
+ width: 40px;
1196
+ height: 22px;
1197
+ flex-shrink: 0;
1198
+ margin-top: 1px;
1199
+ }
1200
+ .toggle input {
1201
+ opacity: 0;
1202
+ width: 0;
1203
+ height: 0;
1204
+ }
1205
+ .slider {
1206
+ position: absolute;
1207
+ cursor: pointer;
1208
+ inset: 0;
1209
+ background: #30363d;
1210
+ border-radius: 22px;
1211
+ transition: 0.2s;
1212
+ }
1213
+ .slider:before {
1214
+ content: '';
1215
+ position: absolute;
1216
+ width: 16px;
1217
+ height: 16px;
1218
+ left: 3px;
1219
+ bottom: 3px;
1220
+ background: #fff;
1221
+ border-radius: 50%;
1222
+ transition: 0.2s;
1223
+ }
1224
+ input:checked + .slider {
1225
+ background: var(--success);
1226
+ }
1227
+ input:checked + .slider:before {
1228
+ transform: translateX(18px);
1229
+ }
1230
+ input:disabled + .slider {
1231
+ opacity: 0.4;
1232
+ cursor: not-allowed;
1233
+ }
1234
+
1235
+ .slack-key-row {
1236
+ display: flex;
1237
+ gap: 6px;
1238
+ margin-top: 10px;
1239
+ }
1240
+ .slack-key-input {
1241
+ flex: 1;
1242
+ background: #0d1117;
1243
+ border: 1px solid var(--border);
1244
+ border-radius: 6px;
1245
+ color: var(--text-bright);
1246
+ padding: 6px 10px;
1247
+ font-size: 12px;
1248
+ font-family: 'Fira Code', monospace;
1249
+ outline: none;
1250
+ }
1251
+ .slack-key-input:focus {
1252
+ border-color: var(--accent);
1253
+ }
1254
+ .btn-save-key {
1255
+ background: var(--accent);
1256
+ color: #fff;
1257
+ border: none;
1258
+ border-radius: 6px;
1259
+ padding: 6px 12px;
1260
+ font-size: 12px;
1261
+ font-weight: 600;
1262
+ cursor: pointer;
1263
+ white-space: nowrap;
1264
+ font-family: inherit;
1265
+ }
1266
+ .btn-save-key:hover {
1267
+ filter: brightness(1.1);
1268
+ }
1269
+ .slack-status-line {
1270
+ font-size: 11px;
1271
+ color: var(--muted);
1272
+ margin-top: 6px;
1273
+ }
1274
+ .slack-status-line.active {
1275
+ color: #57ab5a;
1276
+ }
1277
+
1278
+ .decisions-empty {
1279
+ font-size: 12px;
1280
+ color: var(--muted);
1281
+ }
1282
+ .decision-row {
1283
+ display: flex;
1284
+ align-items: center;
1285
+ gap: 8px;
1286
+ padding: 7px 0;
1287
+ border-bottom: 1px solid var(--border);
1288
+ }
1289
+ .decision-row:last-child {
1290
+ border-bottom: none;
1291
+ }
1292
+ .decision-tool {
1293
+ flex: 1;
1294
+ font-family: 'Fira Code', monospace;
1295
+ font-size: 11px;
1296
+ color: var(--text-bright);
1297
+ word-break: break-all;
1298
+ }
1299
+ .decision-badge {
1300
+ padding: 2px 6px;
1301
+ border-radius: 4px;
1302
+ font-size: 10px;
1303
+ font-weight: 700;
1304
+ text-transform: uppercase;
1305
+ letter-spacing: 0.4px;
1306
+ white-space: nowrap;
1307
+ }
1308
+ .decision-badge.allow {
1309
+ background: rgba(52, 125, 57, 0.2);
1310
+ color: #57ab5a;
1311
+ }
1312
+ .decision-badge.deny {
1313
+ background: rgba(201, 60, 55, 0.2);
1314
+ color: #e5534b;
1315
+ }
1316
+ .btn-revoke {
1317
+ background: transparent;
1318
+ border: 1px solid var(--border);
1319
+ color: var(--muted);
1320
+ padding: 3px 8px;
1321
+ font-size: 10px;
1322
+ border-radius: 5px;
1323
+ white-space: nowrap;
1324
+ font-family: inherit;
1325
+ cursor: pointer;
1326
+ }
1327
+ .btn-revoke:hover {
1328
+ border-color: var(--danger);
1329
+ color: var(--danger);
1330
+ }
1331
+
1332
+ .modal-overlay {
1333
+ display: none;
1334
+ position: fixed;
1335
+ inset: 0;
1336
+ background: rgba(0, 0, 0, 0.7);
1337
+ z-index: 100;
1338
+ align-items: center;
1339
+ justify-content: center;
1340
+ }
1341
+ .modal-overlay.show {
1342
+ display: flex;
1343
+ }
1344
+ .modal {
1345
+ background: var(--card);
1346
+ border: 1px solid var(--border);
1347
+ border-radius: 16px;
1348
+ padding: 28px;
1349
+ max-width: 380px;
1350
+ width: 90%;
1351
+ }
1352
+ .modal h2 {
1353
+ font-size: 16px;
1354
+ color: var(--text-bright);
1355
+ margin-bottom: 10px;
1356
+ }
1357
+ .modal p {
1358
+ font-size: 13px;
1359
+ color: var(--muted);
1360
+ line-height: 1.6;
1361
+ margin-bottom: 20px;
1362
+ }
1363
+ .modal-actions {
1364
+ display: flex;
1365
+ gap: 10px;
1366
+ }
1367
+ .modal-actions button {
1368
+ flex: 1;
1369
+ padding: 10px;
1370
+ border-radius: 8px;
1371
+ font-size: 13px;
1372
+ font-weight: 600;
1373
+ cursor: pointer;
1374
+ font-family: inherit;
1375
+ }
1376
+ .btn-modal-yes {
1377
+ background: var(--success);
1378
+ color: #fff;
1379
+ border: none;
1380
+ }
1381
+ .btn-modal-no {
1382
+ background: transparent;
1383
+ color: var(--text);
1384
+ border: 1px solid var(--border);
1385
+ }
1386
+
1387
+ @media (max-width: 680px) {
1388
+ .body {
1389
+ grid-template-columns: 1fr;
1390
+ }
1391
+ .sidebar {
1392
+ position: static;
1393
+ }
1394
+ }
1395
+ </style>
1396
+ </head>
1397
+ <body>
1398
+ <div class="shell">
1399
+ <header>
1400
+ <span class="logo">\u{1F6E1}\uFE0F</span>
1401
+ <h1>Node9 Guard</h1>
1402
+ <div id="st" class="status-badge">Waiting...</div>
1403
+ </header>
1404
+
1405
+ <div class="body">
1406
+ <div class="main">
1407
+ <div id="warnBanner" class="warning-banner">
1408
+ \u26A0\uFE0F Auto-start is off \u2014 daemon started manually. Run "node9 daemon stop" to stop it, or
1409
+ enable Auto-start in Settings.
1410
+ </div>
1411
+ <div class="section-title">Pending Approvals</div>
1412
+ <div id="list"></div>
1413
+ <div id="empty">
1414
+ <span class="empty-icon">\u2728</span>
1415
+ <span>All clear \u2014 no pending tool calls.</span>
1416
+ </div>
1417
+ </div>
1418
+
1419
+ <div class="sidebar">
1420
+ <div class="panel">
1421
+ <div class="panel-title">\u2699\uFE0F Settings</div>
1422
+ <div class="setting-row">
1423
+ <div class="setting-text">
1424
+ <div class="setting-label">Auto-start daemon</div>
1425
+ <div class="setting-desc">
1426
+ Open this browser automatically when a dangerous call arrives.
1427
+ </div>
1428
+ </div>
1429
+ <label class="toggle">
1430
+ <input
1431
+ type="checkbox"
1432
+ id="autoStartToggle"
1433
+ onchange="saveSetting('autoStartDaemon', this.checked)"
1434
+ />
1435
+ <span class="slider"></span>
1436
+ </label>
1437
+ </div>
1438
+ </div>
1439
+
1440
+ <div class="panel">
1441
+ <div class="panel-title">\u{1F4AC} Slack Approvals</div>
1442
+ <div class="setting-row">
1443
+ <div class="setting-text">
1444
+ <div class="setting-label">Enable Slack</div>
1445
+ <div class="setting-desc">
1446
+ Use Slack as the approval authority when a key is saved.
1447
+ </div>
1448
+ </div>
1449
+ <label class="toggle">
1450
+ <input
1451
+ type="checkbox"
1452
+ id="slackEnabledToggle"
1453
+ onchange="saveSetting('slackEnabled', this.checked)"
1454
+ />
1455
+ <span class="slider"></span>
1456
+ </label>
1457
+ </div>
1458
+ <div class="slack-key-row">
1459
+ <input
1460
+ type="password"
1461
+ id="slackKeyInput"
1462
+ class="slack-key-input"
1463
+ placeholder="Paste API key\u2026"
1464
+ />
1465
+ <button class="btn-save-key" onclick="saveSlackKey()">Save</button>
1466
+ </div>
1467
+ <div id="slackStatusLine" class="slack-status-line">No key saved</div>
1468
+ </div>
1469
+
1470
+ <div class="panel">
1471
+ <div class="panel-title">\u{1F4CB} Persistent Decisions</div>
1472
+ <div id="decisionsList"><span class="decisions-empty">None yet.</span></div>
1473
+ </div>
1474
+ </div>
1475
+ </div>
1476
+ </div>
1477
+
1478
+ <div id="slackModal" class="modal-overlay">
1479
+ <div class="modal">
1480
+ <h2>\u2705 Slack key saved</h2>
1481
+ <p>
1482
+ Slack is now the approval authority. Would you like to
1483
+ <strong>disable the browser popup</strong>? You can re-enable it in Settings anytime.
1484
+ </p>
1485
+ <div class="modal-actions">
1486
+ <button class="btn-modal-yes" onclick="dismissModal(true)">Yes, disable popup</button>
1487
+ <button class="btn-modal-no" onclick="dismissModal(false)">Keep popup</button>
1488
+ </div>
1489
+ </div>
1490
+ </div>
1491
+
1492
+ <script>
1493
+ const CSRF_TOKEN = '{{CSRF_TOKEN}}';
1494
+ const list = document.getElementById('list');
1495
+ const empty = document.getElementById('empty');
1496
+ const st = document.getElementById('st');
1497
+ const requests = new Set();
1498
+ let orgName = null;
1499
+ let autoDenyMs = 120000;
1500
+
1501
+ function highlightSyntax(code) {
1502
+ if (typeof code !== 'string') return esc(code);
1503
+ return esc(code)
1504
+ .replace(
1505
+ /(\\b(sudo|rm|cat|grep|ls|git|npm|yarn|pnpm|python3|node|curl|wget|sh|bash|zsh|docker)\\b)/g,
1506
+ '<span class="hl-cmd"></span>'
1507
+ )
1508
+ .replace(/(\\s)(-[-a-zA-Z0-9]+)/g, '<span class="hl-flag"></span>')
1509
+ .replace(/(".*?"|'.*?')/g, '<span class="hl-str"></span>');
1510
+ }
1511
+
1512
+ function updateDenyButton(id, timestamp) {
1513
+ const btn = document.querySelector('#c-' + id + ' .btn-deny');
1514
+ if (!btn) return;
1515
+ const elapsed = Date.now() - timestamp;
1516
+ const remaining = Math.max(0, Math.ceil((autoDenyMs - elapsed) / 1000));
1517
+ if (remaining <= 0) {
1518
+ btn.textContent = 'Auto-Denying...';
1519
+ btn.disabled = true;
1520
+ } else {
1521
+ btn.textContent = 'Block Action (' + remaining + 's)';
1522
+ setTimeout(() => updateDenyButton(id, timestamp), 1000);
1523
+ }
1524
+ }
1525
+
1526
+ function esc(s) {
1527
+ return String(s)
1528
+ .replace(/&/g, '&amp;')
1529
+ .replace(/</g, '&lt;')
1530
+ .replace(/>/g, '&gt;')
1531
+ .replace(/"/g, '&quot;');
1532
+ }
1533
+ function refresh() {
1534
+ empty.style.display = requests.size === 0 ? 'block' : 'none';
1535
+ }
1536
+
1537
+ function sendDecision(id, decision, persist) {
1538
+ const card = document.getElementById('c-' + id);
1539
+ if (card) card.style.opacity = '0.5';
1540
+ fetch('/decision/' + id, {
1541
+ method: 'POST',
1542
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
1543
+ body: JSON.stringify({ decision, persist: !!persist }),
1544
+ });
1545
+ setTimeout(() => {
1546
+ card?.remove();
1547
+ requests.delete(id);
1548
+ refresh();
1549
+ }, 200);
1550
+ }
1551
+
1552
+ function addCard(req) {
1553
+ if (requests.has(req.id)) return;
1554
+ requests.add(req.id);
1555
+ refresh();
1556
+ const isSlack = !!req.slackDelegated;
1557
+ const cmd = esc(
1558
+ String(
1559
+ req.args &&
1560
+ (req.args.command ||
1561
+ req.args.cmd ||
1562
+ req.args.script ||
1563
+ JSON.stringify(req.args, null, 2))
1564
+ )
1565
+ );
1566
+ const card = document.createElement('div');
1567
+ card.className = 'card' + (isSlack ? ' slack-viewer' : '');
1568
+ card.id = 'c-' + req.id;
1569
+ const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
1570
+ const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
1571
+ card.innerHTML = \`
1572
+ <div class="source-row">
1573
+ <span class="agent-badge">\${agentLabel}</span>
1574
+ \${mcpLabel ? \`<span class="source-arrow">\u2192</span><span class="mcp-badge">mcp::\${mcpLabel}</span>\` : ''}
1575
+ </div>
1576
+ <div class="tool-chip">\${esc(req.toolName)}</div>
1577
+ \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
1578
+ <span class="label">Input Payload</span>
1579
+ <pre>\${cmd}</pre>
1580
+ <div class="actions">
1581
+ <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${isSlack ? 'disabled' : ''}>Approve Execution</button>
1582
+ <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${isSlack ? 'disabled' : ''}>Block Action</button>
1583
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${isSlack ? 'disabled' : ''}>Always Allow</button>
1584
+ <button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${isSlack ? 'disabled' : ''}>Always Deny</button>
1585
+ </div>
1586
+ \`;
1587
+ list.appendChild(card);
1588
+ if (!isSlack) updateDenyButton(req.id, req.timestamp || Date.now());
1589
+ if (!isSlack) {
1590
+ try {
1591
+ new Audio(
1592
+ 'data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YTdvT18AAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA='
1593
+ ).play();
1594
+ } catch (e) {}
1595
+ }
1596
+ }
1597
+
1598
+ function connect() {
1599
+ const ev = new EventSource('/events');
1600
+ ev.onopen = () => {
1601
+ st.className = 'status-badge online';
1602
+ st.innerText = 'System Live';
1603
+ };
1604
+ ev.onerror = () => {
1605
+ st.className = 'status-badge';
1606
+ st.innerText = 'Reconnecting...';
1607
+ setTimeout(connect, 2000);
1608
+ ev.close();
1609
+ };
1610
+ ev.addEventListener('init', (e) => {
1611
+ const data = JSON.parse(e.data);
1612
+ orgName = data.orgName;
1613
+ autoDenyMs = data.autoDenyMs;
1614
+ if (orgName) {
1615
+ const b = document.getElementById('cloudBadge');
1616
+ b.innerText = orgName;
1617
+ b.classList.add('online');
1618
+ }
1619
+ data.requests.forEach(addCard);
1620
+ });
1621
+ ev.addEventListener('add', (e) => {
1622
+ addCard(JSON.parse(e.data));
1623
+ });
1624
+ ev.addEventListener('remove', (e) => {
1625
+ const id = JSON.parse(e.data).id;
1626
+ document.getElementById('c-' + id)?.remove();
1627
+ requests.delete(id);
1628
+ refresh();
1629
+ });
1630
+ ev.addEventListener('decisions', (e) => {
1631
+ renderDecisions(JSON.parse(e.data));
1632
+ });
1633
+ ev.addEventListener('slack-status', (e) => {
1634
+ applySlackStatus(JSON.parse(e.data));
1635
+ });
1636
+ }
1637
+ connect();
1638
+
1639
+ function saveSetting(key, value) {
1640
+ fetch('/settings', {
1641
+ method: 'POST',
1642
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
1643
+ body: JSON.stringify({ [key]: value }),
1644
+ }).catch(() => {});
1645
+ }
1646
+
1647
+ fetch('/settings')
1648
+ .then((r) => r.json())
1649
+ .then((s) => {
1650
+ const tog = document.getElementById('autoStartToggle');
1651
+ tog.checked = s.autoStartDaemon;
1652
+ if (!s.autoStarted) {
1653
+ tog.disabled = true;
1654
+ tog.title = 'Start the daemon via "node9 daemon" to change this setting';
1655
+ }
1656
+ if (!s.autoStartDaemon && !s.autoStarted) {
1657
+ document.getElementById('warnBanner').classList.add('show');
1658
+ }
1659
+ })
1660
+ .catch(() => {});
1661
+
1662
+ document.getElementById('autoStartToggle').addEventListener('change', function () {
1663
+ document.getElementById('warnBanner').classList.toggle('show', !this.checked);
1664
+ });
1665
+
1666
+ function applySlackStatus(s) {
1667
+ const tog = document.getElementById('slackEnabledToggle');
1668
+ const line = document.getElementById('slackStatusLine');
1669
+ tog.disabled = !s.hasKey;
1670
+ tog.checked = s.hasKey && s.enabled;
1671
+ if (s.hasKey) {
1672
+ line.textContent = s.enabled
1673
+ ? '\u{1F7E2} Slack active \u2014 approvals handled by Slack'
1674
+ : '\u26AB Key saved, Slack disabled';
1675
+ line.className = 'slack-status-line' + (s.enabled ? ' active' : '');
1676
+ } else {
1677
+ line.textContent = 'No key saved';
1678
+ line.className = 'slack-status-line';
1679
+ }
1680
+ }
1681
+
1682
+ fetch('/slack-status')
1683
+ .then((r) => r.json())
1684
+ .then(applySlackStatus)
1685
+ .catch(() => {});
1686
+
1687
+ function saveSlackKey() {
1688
+ const key = document.getElementById('slackKeyInput').value.trim();
1689
+ if (!key) return;
1690
+ fetch('/slack-key', {
1691
+ method: 'POST',
1692
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
1693
+ body: JSON.stringify({ apiKey: key }),
1694
+ })
1695
+ .then((r) => {
1696
+ if (r.ok) {
1697
+ document.getElementById('slackKeyInput').value = '';
1698
+ document.getElementById('slackModal').classList.add('show');
1699
+ }
1700
+ })
1701
+ .catch(() => {});
1702
+ }
1703
+
1704
+ function dismissModal(disablePopup) {
1705
+ document.getElementById('slackModal').classList.remove('show');
1706
+ if (disablePopup) {
1707
+ saveSetting('autoStartDaemon', false);
1708
+ document.getElementById('autoStartToggle').checked = false;
1709
+ document.getElementById('warnBanner').classList.remove('show');
1710
+ }
1711
+ }
1712
+
1713
+ function renderDecisions(decisions) {
1714
+ const dl = document.getElementById('decisionsList');
1715
+ const entries = Object.entries(decisions);
1716
+ if (entries.length === 0) {
1717
+ dl.innerHTML = '<span class="decisions-empty">None yet.</span>';
1718
+ return;
1719
+ }
1720
+ dl.innerHTML = entries
1721
+ .map(
1722
+ ([tool, dec]) => \`
1723
+ <div class="decision-row" id="dr-\${encodeURIComponent(tool)}">
1724
+ <span class="decision-tool">\${esc(tool)}</span>
1725
+ <span class="decision-badge \${esc(dec)}">\${dec === 'allow' ? 'Always Allow' : 'Always Deny'}</span>
1726
+ <button class="btn-revoke" onclick="revokeDecision(this)">Revoke</button>
1727
+ </div>
1728
+ \`
1729
+ )
1730
+ .join('');
1731
+ entries.forEach(([tool]) => {
1732
+ const row = document.getElementById('dr-' + encodeURIComponent(tool));
1733
+ if (row) row.querySelector('.btn-revoke').dataset.tool = tool;
1734
+ });
1735
+ }
1736
+
1737
+ function revokeDecision(btn) {
1738
+ const toolName = btn.dataset.tool;
1739
+ if (!toolName) return;
1740
+ fetch('/decisions/' + encodeURIComponent(toolName), {
1741
+ method: 'DELETE',
1742
+ headers: { 'X-Node9-Token': CSRF_TOKEN },
1743
+ }).catch(() => {});
1744
+ document.getElementById('dr-' + encodeURIComponent(toolName))?.remove();
1745
+ if (!document.querySelector('#decisionsList .decision-row')) {
1746
+ document.getElementById('decisionsList').innerHTML =
1747
+ '<span class="decisions-empty">None yet.</span>';
1748
+ }
1749
+ }
1750
+
1751
+ fetch('/decisions')
1752
+ .then((r) => r.json())
1753
+ .then(renderDecisions)
1754
+ .catch(() => {});
1755
+ </script>
1756
+ </body>
1757
+ </html>
1758
+ `;
1759
+
1760
+ // src/daemon/ui.ts
1761
+ var UI_HTML_TEMPLATE = ui_default;
1762
+
1763
+ // src/daemon/index.ts
1764
+ var import_http = __toESM(require("http"));
1765
+ var import_fs3 = __toESM(require("fs"));
1766
+ var import_path3 = __toESM(require("path"));
1767
+ var import_os3 = __toESM(require("os"));
1768
+ var import_child_process = require("child_process");
1769
+ var import_crypto = require("crypto");
1770
+ var import_chalk3 = __toESM(require("chalk"));
1771
+ var DAEMON_PORT2 = 7391;
1772
+ var DAEMON_HOST2 = "127.0.0.1";
1773
+ var homeDir = import_os3.default.homedir();
1774
+ var DAEMON_PID_FILE = import_path3.default.join(homeDir, ".node9", "daemon.pid");
1775
+ var DECISIONS_FILE = import_path3.default.join(homeDir, ".node9", "decisions.json");
1776
+ var GLOBAL_CONFIG_FILE = import_path3.default.join(homeDir, ".node9", "config.json");
1777
+ var CREDENTIALS_FILE = import_path3.default.join(homeDir, ".node9", "credentials.json");
1778
+ var AUDIT_LOG_FILE = import_path3.default.join(homeDir, ".node9", "audit.log");
1779
+ var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
1780
+ function redactArgs(value) {
1781
+ if (!value || typeof value !== "object") return value;
1782
+ if (Array.isArray(value)) return value.map(redactArgs);
1783
+ const result = {};
1784
+ for (const [k, v] of Object.entries(value)) {
1785
+ result[k] = SECRET_KEY_RE.test(k) ? "[REDACTED]" : redactArgs(v);
1786
+ }
1787
+ return result;
1788
+ }
1789
+ function appendAuditLog(data) {
1790
+ try {
1791
+ const entry = JSON.stringify({ ...data, args: redactArgs(data.args) }) + "\n";
1792
+ const dir = import_path3.default.dirname(AUDIT_LOG_FILE);
1793
+ if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1794
+ import_fs3.default.appendFileSync(AUDIT_LOG_FILE, entry);
1795
+ } catch {
1796
+ }
1797
+ }
1798
+ function getAuditHistory(limit = 20) {
1799
+ try {
1800
+ if (!import_fs3.default.existsSync(AUDIT_LOG_FILE)) return [];
1801
+ const lines = import_fs3.default.readFileSync(AUDIT_LOG_FILE, "utf-8").trim().split("\n");
1802
+ if (lines.length === 1 && lines[0] === "") return [];
1803
+ return lines.slice(-limit).map((l) => JSON.parse(l)).reverse();
1804
+ } catch {
1805
+ return [];
1806
+ }
1807
+ }
1808
+ var AUTO_DENY_MS = 12e4;
1809
+ function getOrgName() {
1810
+ try {
1811
+ if (import_fs3.default.existsSync(CREDENTIALS_FILE)) {
1812
+ return "Node9 Cloud";
1813
+ }
1814
+ } catch {
1815
+ }
1816
+ return null;
1817
+ }
1818
+ var autoStarted = process.env.NODE9_AUTO_STARTED === "1";
1819
+ function readGlobalSettings() {
1820
+ try {
1821
+ if (import_fs3.default.existsSync(GLOBAL_CONFIG_FILE)) {
1822
+ const config = JSON.parse(import_fs3.default.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
1823
+ const s = config?.settings ?? {};
1824
+ return {
1825
+ autoStartDaemon: s.autoStartDaemon !== false,
1826
+ slackEnabled: s.slackEnabled !== false,
1827
+ agentMode: s.agentMode === true
1828
+ };
1829
+ }
1830
+ } catch {
1831
+ }
1832
+ return { autoStartDaemon: true, slackEnabled: true, agentMode: false };
1833
+ }
1834
+ function hasStoredSlackKey() {
1835
+ return import_fs3.default.existsSync(CREDENTIALS_FILE);
1836
+ }
1837
+ function writeGlobalSetting(key, value) {
1838
+ let config = {};
1839
+ try {
1840
+ if (import_fs3.default.existsSync(GLOBAL_CONFIG_FILE)) {
1841
+ config = JSON.parse(import_fs3.default.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
1842
+ }
1843
+ } catch {
1844
+ }
1845
+ if (!config.settings || typeof config.settings !== "object") config.settings = {};
1846
+ config.settings[key] = value;
1847
+ const dir = import_path3.default.dirname(GLOBAL_CONFIG_FILE);
1848
+ if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1849
+ import_fs3.default.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
1850
+ }
1851
+ var pending = /* @__PURE__ */ new Map();
1852
+ var sseClients = /* @__PURE__ */ new Set();
1853
+ var abandonTimer = null;
1854
+ var daemonServer = null;
1855
+ function abandonPending() {
1856
+ abandonTimer = null;
1857
+ pending.forEach((entry, id) => {
1858
+ clearTimeout(entry.timer);
1859
+ if (entry.waiter) entry.waiter("abandoned");
1860
+ else entry.earlyDecision = "abandoned";
1861
+ pending.delete(id);
1862
+ broadcast("remove", { id });
1863
+ });
1864
+ if (autoStarted) {
1865
+ try {
1866
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
1867
+ } catch {
1868
+ }
1869
+ setTimeout(() => {
1870
+ daemonServer?.close();
1871
+ process.exit(0);
1872
+ }, 200);
1873
+ }
1874
+ }
1875
+ function broadcast(event, data) {
1876
+ const msg = `event: ${event}
1877
+ data: ${JSON.stringify(data)}
1878
+
1879
+ `;
1880
+ sseClients.forEach((client) => {
1881
+ try {
1882
+ client.write(msg);
1883
+ } catch {
1884
+ sseClients.delete(client);
1885
+ }
1886
+ });
1887
+ }
1888
+ function openBrowser(url) {
1889
+ try {
1890
+ const opts = { stdio: "ignore" };
1891
+ if (process.platform === "darwin") (0, import_child_process.execSync)(`open "${url}"`, opts);
1892
+ else if (process.platform === "win32") (0, import_child_process.execSync)(`cmd /c start "" "${url}"`, opts);
1893
+ else (0, import_child_process.execSync)(`xdg-open "${url}"`, opts);
1894
+ } catch {
1895
+ }
1896
+ }
1897
+ function readBody(req) {
1898
+ return new Promise((resolve) => {
1899
+ let body = "";
1900
+ req.on("data", (chunk) => body += chunk);
1901
+ req.on("end", () => resolve(body));
1902
+ });
1903
+ }
1904
+ function readPersistentDecisions() {
1905
+ try {
1906
+ if (import_fs3.default.existsSync(DECISIONS_FILE)) {
1907
+ return JSON.parse(import_fs3.default.readFileSync(DECISIONS_FILE, "utf-8"));
1908
+ }
1909
+ } catch {
1910
+ }
1911
+ return {};
1912
+ }
1913
+ function writePersistentDecision(toolName, decision) {
1914
+ try {
1915
+ const dir = import_path3.default.dirname(DECISIONS_FILE);
1916
+ if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
1917
+ const decisions = readPersistentDecisions();
1918
+ decisions[toolName] = decision;
1919
+ import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
1920
+ broadcast("decisions", decisions);
1921
+ } catch {
1922
+ }
1923
+ }
1924
+ function startDaemon() {
1925
+ const csrfToken = (0, import_crypto.randomUUID)();
1926
+ const internalToken = (0, import_crypto.randomUUID)();
1927
+ const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
1928
+ const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
1929
+ const server = import_http.default.createServer(async (req, res) => {
1930
+ const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
1931
+ if (req.method === "GET" && pathname === "/") {
1932
+ res.writeHead(200, { "Content-Type": "text/html" });
1933
+ return res.end(UI_HTML);
1934
+ }
1935
+ if (req.method === "GET" && pathname === "/events") {
1936
+ res.writeHead(200, {
1937
+ "Content-Type": "text/event-stream",
1938
+ "Cache-Control": "no-cache",
1939
+ Connection: "keep-alive"
1940
+ });
1941
+ if (abandonTimer) {
1942
+ clearTimeout(abandonTimer);
1943
+ abandonTimer = null;
1944
+ }
1945
+ sseClients.add(res);
1946
+ res.write(
1947
+ `event: init
1948
+ data: ${JSON.stringify({
1949
+ requests: Array.from(pending.values()).map((e) => ({
1950
+ id: e.id,
1951
+ toolName: e.toolName,
1952
+ args: e.args,
1953
+ slackDelegated: e.slackDelegated,
1954
+ timestamp: e.timestamp,
1955
+ agent: e.agent,
1956
+ mcpServer: e.mcpServer
1957
+ })),
1958
+ orgName: getOrgName(),
1959
+ autoDenyMs: AUTO_DENY_MS
1960
+ })}
1961
+
1962
+ `
1963
+ );
1964
+ res.write(`event: decisions
1965
+ data: ${JSON.stringify(readPersistentDecisions())}
1966
+
1967
+ `);
1968
+ return req.on("close", () => {
1969
+ sseClients.delete(res);
1970
+ if (sseClients.size === 0 && pending.size > 0) {
1971
+ abandonTimer = setTimeout(abandonPending, 2e3);
1972
+ }
1973
+ });
1974
+ }
1975
+ if (req.method === "POST" && pathname === "/check") {
1976
+ try {
1977
+ const body = await readBody(req);
1978
+ if (body.length > 65536) return res.writeHead(413).end();
1979
+ const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
1980
+ const id = (0, import_crypto.randomUUID)();
1981
+ const entry = {
1982
+ id,
1983
+ toolName,
1984
+ args,
1985
+ agent: typeof agent === "string" ? agent : void 0,
1986
+ mcpServer: typeof mcpServer === "string" ? mcpServer : void 0,
1987
+ slackDelegated: !!slackDelegated,
1988
+ timestamp: Date.now(),
1989
+ earlyDecision: null,
1990
+ waiter: null,
1991
+ timer: setTimeout(() => {
1992
+ if (pending.has(id)) {
1993
+ const e = pending.get(id);
1994
+ appendAuditLog({
1995
+ toolName: e.toolName,
1996
+ args: e.args,
1997
+ decision: "auto-deny",
1998
+ timestamp: Date.now()
1999
+ });
2000
+ if (e.waiter) e.waiter("deny");
2001
+ else e.earlyDecision = "deny";
2002
+ pending.delete(id);
2003
+ broadcast("remove", { id });
2004
+ }
2005
+ }, AUTO_DENY_MS)
2006
+ };
2007
+ pending.set(id, entry);
2008
+ broadcast("add", {
2009
+ id,
2010
+ toolName,
2011
+ args,
2012
+ slackDelegated: entry.slackDelegated,
2013
+ agent: entry.agent,
2014
+ mcpServer: entry.mcpServer
2015
+ });
2016
+ if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
2017
+ res.writeHead(200, { "Content-Type": "application/json" });
2018
+ return res.end(JSON.stringify({ id }));
2019
+ } catch {
2020
+ res.writeHead(400).end();
2021
+ }
2022
+ }
2023
+ if (req.method === "GET" && pathname.startsWith("/wait/")) {
2024
+ const id = pathname.split("/").pop();
2025
+ const entry = pending.get(id);
2026
+ if (!entry) return res.writeHead(404).end();
2027
+ if (entry.earlyDecision) {
2028
+ res.writeHead(200, { "Content-Type": "application/json" });
2029
+ return res.end(JSON.stringify({ decision: entry.earlyDecision }));
2030
+ }
2031
+ entry.waiter = (d) => {
2032
+ res.writeHead(200, { "Content-Type": "application/json" });
2033
+ res.end(JSON.stringify({ decision: d }));
2034
+ };
2035
+ return;
2036
+ }
2037
+ if (req.method === "POST" && pathname.startsWith("/decision/")) {
2038
+ if (!validToken(req)) return res.writeHead(403).end();
2039
+ try {
2040
+ const id = pathname.split("/").pop();
2041
+ const entry = pending.get(id);
2042
+ if (!entry) return res.writeHead(404).end();
2043
+ const { decision, persist } = JSON.parse(await readBody(req));
2044
+ if (persist) writePersistentDecision(entry.toolName, decision);
2045
+ appendAuditLog({
2046
+ toolName: entry.toolName,
2047
+ args: entry.args,
2048
+ decision,
2049
+ timestamp: Date.now()
2050
+ });
2051
+ clearTimeout(entry.timer);
2052
+ if (entry.waiter) entry.waiter(decision);
2053
+ else entry.earlyDecision = decision;
2054
+ pending.delete(id);
2055
+ broadcast("remove", { id });
2056
+ res.writeHead(200);
2057
+ return res.end(JSON.stringify({ ok: true }));
2058
+ } catch {
2059
+ res.writeHead(400).end();
2060
+ }
2061
+ }
2062
+ if (req.method === "GET" && pathname === "/settings") {
2063
+ const s = readGlobalSettings();
2064
+ res.writeHead(200, { "Content-Type": "application/json" });
2065
+ return res.end(JSON.stringify({ ...s, autoStarted }));
2066
+ }
2067
+ if (req.method === "POST" && pathname === "/settings") {
2068
+ if (!validToken(req)) return res.writeHead(403).end();
2069
+ try {
2070
+ const body = await readBody(req);
2071
+ const data = JSON.parse(body);
2072
+ if (data.autoStartDaemon !== void 0)
2073
+ writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
2074
+ if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
2075
+ if (data.agentMode !== void 0) writeGlobalSetting("agentMode", data.agentMode);
2076
+ res.writeHead(200);
2077
+ return res.end(JSON.stringify({ ok: true }));
2078
+ } catch {
2079
+ res.writeHead(400).end();
2080
+ }
2081
+ }
2082
+ if (req.method === "GET" && pathname === "/slack-status") {
2083
+ const s = readGlobalSettings();
2084
+ res.writeHead(200, { "Content-Type": "application/json" });
2085
+ return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
2086
+ }
2087
+ if (req.method === "POST" && pathname === "/slack-key") {
2088
+ if (!validToken(req)) return res.writeHead(403).end();
2089
+ try {
2090
+ const { apiKey } = JSON.parse(await readBody(req));
2091
+ if (!import_fs3.default.existsSync(import_path3.default.dirname(CREDENTIALS_FILE)))
2092
+ import_fs3.default.mkdirSync(import_path3.default.dirname(CREDENTIALS_FILE), { recursive: true });
2093
+ import_fs3.default.writeFileSync(
2094
+ CREDENTIALS_FILE,
2095
+ JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
2096
+ { mode: 384 }
2097
+ );
2098
+ broadcast("slack-status", { hasKey: true, enabled: readGlobalSettings().slackEnabled });
2099
+ res.writeHead(200);
2100
+ return res.end(JSON.stringify({ ok: true }));
2101
+ } catch {
2102
+ res.writeHead(400).end();
2103
+ }
2104
+ }
2105
+ if (req.method === "DELETE" && pathname.startsWith("/decisions/")) {
2106
+ if (!validToken(req)) return res.writeHead(403).end();
2107
+ try {
2108
+ const toolName = decodeURIComponent(pathname.split("/").pop());
2109
+ const decisions = readPersistentDecisions();
2110
+ delete decisions[toolName];
2111
+ import_fs3.default.writeFileSync(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
2112
+ broadcast("decisions", decisions);
2113
+ res.writeHead(200);
2114
+ return res.end(JSON.stringify({ ok: true }));
2115
+ } catch {
2116
+ res.writeHead(400).end();
2117
+ }
2118
+ }
2119
+ if (req.method === "POST" && pathname.startsWith("/resolve/")) {
2120
+ const internalAuth = req.headers["x-node9-internal"];
2121
+ if (internalAuth !== internalToken) return res.writeHead(403).end();
2122
+ try {
2123
+ const id = pathname.split("/").pop();
2124
+ const entry = pending.get(id);
2125
+ if (!entry) return res.writeHead(404).end();
2126
+ const { decision } = JSON.parse(await readBody(req));
2127
+ appendAuditLog({
2128
+ toolName: entry.toolName,
2129
+ args: entry.args,
2130
+ decision,
2131
+ timestamp: Date.now()
2132
+ });
2133
+ clearTimeout(entry.timer);
2134
+ if (entry.waiter) entry.waiter(decision);
2135
+ else entry.earlyDecision = decision;
2136
+ pending.delete(id);
2137
+ broadcast("remove", { id });
2138
+ res.writeHead(200);
2139
+ return res.end(JSON.stringify({ ok: true }));
2140
+ } catch {
2141
+ res.writeHead(400).end();
2142
+ }
2143
+ }
2144
+ if (req.method === "GET" && pathname === "/audit") {
2145
+ res.writeHead(200, { "Content-Type": "application/json" });
2146
+ return res.end(JSON.stringify(getAuditHistory()));
2147
+ }
2148
+ res.writeHead(404).end();
2149
+ });
2150
+ daemonServer = server;
2151
+ server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
2152
+ if (!import_fs3.default.existsSync(import_path3.default.dirname(DAEMON_PID_FILE)))
2153
+ import_fs3.default.mkdirSync(import_path3.default.dirname(DAEMON_PID_FILE), { recursive: true });
2154
+ import_fs3.default.writeFileSync(
2155
+ DAEMON_PID_FILE,
2156
+ JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
2157
+ { mode: 384 }
2158
+ );
2159
+ console.log(import_chalk3.default.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
2160
+ });
2161
+ }
2162
+ function stopDaemon() {
2163
+ if (!import_fs3.default.existsSync(DAEMON_PID_FILE)) return console.log(import_chalk3.default.yellow("Not running."));
2164
+ try {
2165
+ const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2166
+ process.kill(pid, "SIGTERM");
2167
+ console.log(import_chalk3.default.green("\u2705 Stopped."));
2168
+ } catch {
2169
+ console.log(import_chalk3.default.gray("Cleaned up stale PID file."));
2170
+ } finally {
2171
+ try {
2172
+ import_fs3.default.unlinkSync(DAEMON_PID_FILE);
2173
+ } catch {
2174
+ }
2175
+ }
2176
+ }
2177
+ function daemonStatus() {
2178
+ if (!import_fs3.default.existsSync(DAEMON_PID_FILE))
2179
+ return console.log(import_chalk3.default.yellow("Node9 daemon: not running"));
2180
+ try {
2181
+ const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
2182
+ process.kill(pid, 0);
2183
+ console.log(import_chalk3.default.green("Node9 daemon: running"));
2184
+ } catch {
2185
+ console.log(import_chalk3.default.yellow("Node9 daemon: not running (stale PID)"));
2186
+ }
2187
+ }
2188
+
2189
+ // src/cli.ts
2190
+ var import_child_process2 = require("child_process");
2191
+ var import_execa = require("execa");
2192
+ var import_execa2 = require("execa");
2193
+ var import_chalk4 = __toESM(require("chalk"));
2194
+ var import_readline = __toESM(require("readline"));
2195
+ var import_fs4 = __toESM(require("fs"));
2196
+ var import_path4 = __toESM(require("path"));
2197
+ var import_os4 = __toESM(require("os"));
2198
+ var { version } = JSON.parse(
2199
+ import_fs4.default.readFileSync(import_path4.default.join(__dirname, "../package.json"), "utf-8")
2200
+ );
2201
+ function sanitize(value) {
2202
+ return value.replace(/[\x00-\x1F\x7F]/g, "");
2203
+ }
2204
+ function openBrowserLocal() {
2205
+ const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
2206
+ try {
2207
+ const opts = { stdio: "ignore" };
2208
+ if (process.platform === "darwin") (0, import_child_process2.execSync)(`open "${url}"`, opts);
2209
+ else if (process.platform === "win32") (0, import_child_process2.execSync)(`cmd /c start "" "${url}"`, opts);
2210
+ else (0, import_child_process2.execSync)(`xdg-open "${url}"`, opts);
2211
+ } catch {
2212
+ }
2213
+ }
2214
+ async function autoStartDaemonAndWait() {
2215
+ try {
2216
+ const child = (0, import_child_process2.spawn)("node9", ["daemon"], {
2217
+ detached: true,
2218
+ stdio: "ignore",
2219
+ env: { ...process.env, NODE9_AUTO_STARTED: "1" }
2220
+ });
2221
+ child.unref();
2222
+ for (let i = 0; i < 12; i++) {
2223
+ await new Promise((r) => setTimeout(r, 250));
2224
+ if (isDaemonRunning()) return true;
2225
+ }
2226
+ } catch {
2227
+ }
2228
+ return false;
2229
+ }
2230
+ var program = new import_commander.Command();
2231
+ program.name("node9").description("The Sudo Command for AI Agents").version(version);
2232
+ async function runProxy(targetCommand) {
2233
+ const commandParts = (0, import_execa.parseCommandString)(targetCommand);
2234
+ const cmd = commandParts[0];
2235
+ const args = commandParts.slice(1);
2236
+ let executable = cmd;
2237
+ try {
2238
+ const { stdout } = await (0, import_execa2.execa)("which", [cmd]);
2239
+ if (stdout) executable = stdout.trim();
2240
+ } catch {
2241
+ }
2242
+ console.log(import_chalk4.default.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
2243
+ const child = (0, import_child_process2.spawn)(executable, args, {
2244
+ stdio: ["pipe", "pipe", "inherit"],
2245
+ shell: true,
2246
+ env: { ...process.env, FORCE_COLOR: "1", TERM: process.env.TERM || "xterm-256color" }
2247
+ });
2248
+ process.stdin.pipe(child.stdin);
2249
+ const childOut = import_readline.default.createInterface({ input: child.stdout, terminal: false });
2250
+ childOut.on("line", async (line) => {
2251
+ try {
2252
+ const message = JSON.parse(line);
2253
+ if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
2254
+ const name = message.params?.name || message.params?.tool_name || "unknown";
2255
+ const toolArgs = message.params?.arguments || message.params?.tool_input || {};
2256
+ const approved = await authorizeAction(sanitize(name), toolArgs);
2257
+ if (!approved) {
2258
+ const errorResponse = {
2259
+ jsonrpc: "2.0",
2260
+ id: message.id,
2261
+ error: { code: -32e3, message: "Node9: Action denied." }
2262
+ };
2263
+ child.stdin.write(JSON.stringify(errorResponse) + "\n");
2264
+ return;
2265
+ }
2266
+ }
2267
+ process.stdout.write(line + "\n");
2268
+ } catch {
2269
+ process.stdout.write(line + "\n");
2270
+ }
2271
+ });
2272
+ child.on("exit", (code) => process.exit(code || 0));
2273
+ }
2274
+ program.command("login").argument("<apiKey>").option("--local", "Save key for audit/logging only \u2014 local config still controls all decisions").option("--profile <name>", 'Save as a named profile (default: "default")').action((apiKey, options) => {
2275
+ const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
2276
+ const credPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "credentials.json");
2277
+ if (!import_fs4.default.existsSync(import_path4.default.dirname(credPath)))
2278
+ import_fs4.default.mkdirSync(import_path4.default.dirname(credPath), { recursive: true });
2279
+ const profileName = options.profile || "default";
2280
+ let existingCreds = {};
2281
+ try {
2282
+ if (import_fs4.default.existsSync(credPath)) {
2283
+ const raw = JSON.parse(import_fs4.default.readFileSync(credPath, "utf-8"));
2284
+ if (raw.apiKey) {
2285
+ existingCreds = {
2286
+ default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
2287
+ };
2288
+ } else {
2289
+ existingCreds = raw;
2290
+ }
2291
+ }
2292
+ } catch {
2293
+ }
2294
+ existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
2295
+ import_fs4.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
2296
+ if (profileName === "default") {
2297
+ const configPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
2298
+ let config = {};
2299
+ try {
2300
+ if (import_fs4.default.existsSync(configPath))
2301
+ config = JSON.parse(import_fs4.default.readFileSync(configPath, "utf-8"));
2302
+ } catch {
2303
+ }
2304
+ if (!config.settings || typeof config.settings !== "object") config.settings = {};
2305
+ config.settings.agentMode = !options.local;
2306
+ if (!import_fs4.default.existsSync(import_path4.default.dirname(configPath)))
2307
+ import_fs4.default.mkdirSync(import_path4.default.dirname(configPath), { recursive: true });
2308
+ import_fs4.default.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
2309
+ }
2310
+ if (options.profile && profileName !== "default") {
2311
+ console.log(import_chalk4.default.green(`\u2705 Profile "${profileName}" saved`));
2312
+ console.log(import_chalk4.default.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
2313
+ console.log(
2314
+ import_chalk4.default.gray(
2315
+ ` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
2316
+ )
2317
+ );
2318
+ } else if (options.local) {
2319
+ console.log(import_chalk4.default.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
2320
+ console.log(import_chalk4.default.gray(` All decisions stay on this machine.`));
2321
+ console.log(
2322
+ import_chalk4.default.gray(` No data is sent to the cloud. Local config is the only authority.`)
2323
+ );
2324
+ console.log(import_chalk4.default.gray(` To enable cloud enforcement: node9 login <apiKey>`));
2325
+ } else {
2326
+ console.log(import_chalk4.default.green(`\u2705 Logged in \u2014 agent mode`));
2327
+ console.log(import_chalk4.default.gray(` Team policy enforced for all calls via Node9 cloud.`));
2328
+ console.log(import_chalk4.default.gray(` To keep local control only: node9 login <apiKey> --local`));
2329
+ }
2330
+ });
2331
+ program.command("addto").description("Integrate Node9 with an AI agent").addHelpText("after", "\n Supported targets: claude gemini cursor").argument("<target>", "The agent to protect: claude | gemini | cursor").action(async (target) => {
2332
+ if (target === "gemini") return await setupGemini();
2333
+ if (target === "claude") return await setupClaude();
2334
+ if (target === "cursor") return await setupCursor();
2335
+ console.error(import_chalk4.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
2336
+ process.exit(1);
2337
+ });
2338
+ program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").action((options) => {
2339
+ const configPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
2340
+ if (import_fs4.default.existsSync(configPath) && !options.force) {
2341
+ console.log(import_chalk4.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
2342
+ console.log(import_chalk4.default.gray(` Run with --force to overwrite.`));
2343
+ return;
2344
+ }
2345
+ const defaultConfig = {
2346
+ version: "1.0",
2347
+ settings: { mode: "standard" },
2348
+ policy: {
2349
+ dangerousWords: DANGEROUS_WORDS,
2350
+ ignoredTools: [
2351
+ "list_*",
2352
+ "get_*",
2353
+ "read_*",
2354
+ "describe_*",
2355
+ "read",
2356
+ "write",
2357
+ "edit",
2358
+ "multiedit",
2359
+ "glob",
2360
+ "grep",
2361
+ "ls",
2362
+ "notebookread",
2363
+ "notebookedit",
2364
+ "todoread",
2365
+ "todowrite",
2366
+ "webfetch",
2367
+ "websearch",
2368
+ "exitplanmode",
2369
+ "askuserquestion"
2370
+ ],
2371
+ toolInspection: {
2372
+ bash: "command",
2373
+ shell: "command",
2374
+ run_shell_command: "command",
2375
+ "terminal.execute": "command"
2376
+ },
2377
+ rules: [
2378
+ {
2379
+ action: "rm",
2380
+ allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"]
2381
+ }
2382
+ ]
2383
+ }
2384
+ };
2385
+ if (!import_fs4.default.existsSync(import_path4.default.dirname(configPath)))
2386
+ import_fs4.default.mkdirSync(import_path4.default.dirname(configPath), { recursive: true });
2387
+ import_fs4.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
2388
+ console.log(import_chalk4.default.green(`\u2705 Global config created: ${configPath}`));
2389
+ console.log(import_chalk4.default.gray(` Edit this file to add custom tool inspection or security rules.`));
2390
+ });
2391
+ program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
2392
+ const creds = getCredentials();
2393
+ const daemonRunning = isDaemonRunning();
2394
+ const settings = getGlobalSettings();
2395
+ console.log("");
2396
+ if (creds && settings.agentMode) {
2397
+ console.log(import_chalk4.default.green(" \u25CF Agent mode") + import_chalk4.default.gray(" \u2014 cloud team policy enforced"));
2398
+ console.log(import_chalk4.default.gray(" All calls \u2192 Node9 cloud \u2192 Policy Studio rules apply"));
2399
+ console.log(import_chalk4.default.gray(" Switch to local control: node9 login <apiKey> --local"));
2400
+ } else if (creds && !settings.agentMode) {
2401
+ console.log(
2402
+ import_chalk4.default.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 all decisions stay on this machine")
2403
+ );
2404
+ console.log(
2405
+ import_chalk4.default.gray(" No data is sent to the cloud. Local config is the only authority.")
2406
+ );
2407
+ console.log(import_chalk4.default.gray(" Enable cloud enforcement: node9 login <apiKey>"));
2408
+ } else {
2409
+ console.log(import_chalk4.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 no API key"));
2410
+ console.log(import_chalk4.default.gray(" All decisions stay on this machine."));
2411
+ console.log(import_chalk4.default.gray(" Connect to your team: node9 login <apiKey>"));
2412
+ }
2413
+ console.log("");
2414
+ if (daemonRunning) {
2415
+ console.log(
2416
+ import_chalk4.default.green(" \u25CF Daemon running") + import_chalk4.default.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
2417
+ );
2418
+ } else {
2419
+ console.log(import_chalk4.default.gray(" \u25CB Daemon stopped"));
2420
+ console.log(import_chalk4.default.gray(" Start: node9 daemon --background"));
2421
+ }
2422
+ console.log("");
2423
+ console.log(` Mode: ${import_chalk4.default.white(settings.mode)}`);
2424
+ const projectConfig = import_path4.default.join(process.cwd(), "node9.config.json");
2425
+ const globalConfig = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
2426
+ const configSource = import_fs4.default.existsSync(projectConfig) ? projectConfig : import_fs4.default.existsSync(globalConfig) ? globalConfig : import_chalk4.default.gray("none (built-in defaults)");
2427
+ console.log(` Config: ${import_chalk4.default.gray(configSource)}`);
2428
+ const profiles = listCredentialProfiles();
2429
+ if (profiles.length > 1) {
2430
+ const activeProfile = process.env.NODE9_PROFILE || "default";
2431
+ console.log("");
2432
+ console.log(` Active profile: ${import_chalk4.default.white(activeProfile)}`);
2433
+ console.log(
2434
+ ` All profiles: ${profiles.map((p) => p === activeProfile ? import_chalk4.default.green(p) : import_chalk4.default.gray(p)).join(import_chalk4.default.gray(", "))}`
2435
+ );
2436
+ console.log(import_chalk4.default.gray(` Switch: NODE9_PROFILE=<name> claude`));
2437
+ }
2438
+ const decisionsFile = import_path4.default.join(import_os4.default.homedir(), ".node9", "decisions.json");
2439
+ let decisions = {};
2440
+ try {
2441
+ if (import_fs4.default.existsSync(decisionsFile))
2442
+ decisions = JSON.parse(import_fs4.default.readFileSync(decisionsFile, "utf-8"));
2443
+ } catch {
2444
+ }
2445
+ const keys = Object.keys(decisions);
2446
+ console.log("");
2447
+ if (keys.length > 0) {
2448
+ console.log(` Persistent decisions (${keys.length}):`);
2449
+ keys.forEach((tool) => {
2450
+ const d = decisions[tool];
2451
+ const badge = d === "allow" ? import_chalk4.default.green("allow") : import_chalk4.default.red("deny");
2452
+ console.log(` ${import_chalk4.default.gray("\xB7")} ${tool.padEnd(35)} ${badge}`);
2453
+ });
2454
+ console.log(import_chalk4.default.gray("\n Manage: node9 daemon --openui \u2192 Decisions tab"));
2455
+ } else {
2456
+ console.log(import_chalk4.default.gray(" No persistent decisions set"));
2457
+ }
2458
+ const auditLogPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "audit.log");
2459
+ try {
2460
+ if (import_fs4.default.existsSync(auditLogPath)) {
2461
+ const lines = import_fs4.default.readFileSync(auditLogPath, "utf-8").split("\n").filter((l) => l.trim().length > 0);
2462
+ console.log("");
2463
+ console.log(
2464
+ ` \u{1F4CB} Local Audit Log: ` + import_chalk4.default.white(`${lines.length} agent action${lines.length !== 1 ? "s" : ""} recorded`) + import_chalk4.default.gray(` (cat ~/.node9/audit.log to view)`)
2465
+ );
2466
+ }
2467
+ } catch {
2468
+ }
2469
+ console.log("");
2470
+ });
2471
+ program.command("daemon").description("Run the local approval server (browser HITL for free tier)").addHelpText(
2472
+ "after",
2473
+ "\n Subcommands: start (default), stop, status\n Options:\n --background (-b) start detached, no second terminal needed\n --openui (-o) start in background and open the browser (or just open if already running)\n Example: node9 daemon --background"
2474
+ ).argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option(
2475
+ "-o, --openui",
2476
+ "Start in background and open browser (or just open browser if already running)"
2477
+ ).action(
2478
+ async (action, options) => {
2479
+ const cmd = (action ?? "start").toLowerCase();
2480
+ if (cmd === "stop") return stopDaemon();
2481
+ if (cmd === "status") return daemonStatus();
2482
+ if (cmd !== "start" && action !== void 0) {
2483
+ console.error(import_chalk4.default.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
2484
+ process.exit(1);
2485
+ }
2486
+ if (options.openui) {
2487
+ if (isDaemonRunning()) {
2488
+ openBrowserLocal();
2489
+ console.log(import_chalk4.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2490
+ process.exit(0);
2491
+ }
2492
+ const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2493
+ child.unref();
2494
+ for (let i = 0; i < 12; i++) {
2495
+ await new Promise((r) => setTimeout(r, 250));
2496
+ if (isDaemonRunning()) break;
2497
+ }
2498
+ openBrowserLocal();
2499
+ console.log(import_chalk4.default.green(`
2500
+ \u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
2501
+ console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2502
+ process.exit(0);
2503
+ }
2504
+ if (options.background) {
2505
+ const child = (0, import_child_process2.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
2506
+ child.unref();
2507
+ console.log(import_chalk4.default.green(`
2508
+ \u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
2509
+ console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
2510
+ console.log(import_chalk4.default.gray(` node9 daemon status \u2014 check if running`));
2511
+ console.log(import_chalk4.default.gray(` node9 daemon stop \u2014 stop it
2512
+ `));
2513
+ process.exit(0);
2514
+ }
2515
+ startDaemon();
2516
+ }
2517
+ );
2518
+ program.command("check").description("Hook handler \u2014 evaluates a tool call before execution").argument("[data]", "JSON string of the tool call").action(async (data) => {
2519
+ const processPayload = async (raw) => {
2520
+ try {
2521
+ if (!raw || raw.trim() === "") process.exit(0);
2522
+ if (process.env.NODE9_DEBUG === "1") {
2523
+ const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
2524
+ if (!import_fs4.default.existsSync(import_path4.default.dirname(logPath)))
2525
+ import_fs4.default.mkdirSync(import_path4.default.dirname(logPath), { recursive: true });
2526
+ import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
2527
+ `);
2528
+ import_fs4.default.appendFileSync(
2529
+ logPath,
2530
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] TTY: ${process.stdout.isTTY}
2531
+ `
2532
+ );
2533
+ }
2534
+ const payload = JSON.parse(raw);
2535
+ const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
2536
+ const toolInput = payload.tool_input ?? payload.args ?? {};
2537
+ const agent = payload.tool_name !== void 0 ? "Claude Code" : payload.name !== void 0 ? "Gemini CLI" : "Terminal";
2538
+ const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
2539
+ const mcpServer = mcpMatch?.[1];
2540
+ const sendBlock = (msg, result2) => {
2541
+ const BLOCKED_BY_LABELS = {
2542
+ "team-policy": "team policy (set by your admin)",
2543
+ "persistent-deny": "you set this tool to always deny",
2544
+ "local-config": "your local config (dangerousWords / rules)",
2545
+ "local-decision": "you denied it in the browser",
2546
+ "no-approval-mechanism": "no approval method is configured"
2547
+ };
2548
+ console.error(import_chalk4.default.red(`
2549
+ \u{1F6D1} Node9 blocked "${toolName}"`));
2550
+ if (result2?.blockedBy) {
2551
+ console.error(
2552
+ import_chalk4.default.gray(
2553
+ ` Blocked by: ${BLOCKED_BY_LABELS[result2.blockedBy] ?? result2.blockedBy}`
2554
+ )
2555
+ );
2556
+ }
2557
+ if (result2?.changeHint) {
2558
+ console.error(import_chalk4.default.cyan(` To change: ${result2.changeHint}`));
2559
+ }
2560
+ console.error("");
2561
+ process.stdout.write(
2562
+ JSON.stringify({
2563
+ decision: "block",
2564
+ reason: msg,
2565
+ hookSpecificOutput: {
2566
+ hookEventName: "PreToolUse",
2567
+ permissionDecision: "deny",
2568
+ permissionDecisionReason: msg
2569
+ }
2570
+ }) + "\n"
2571
+ );
2572
+ process.exit(0);
2573
+ };
2574
+ if (!toolName) {
2575
+ sendBlock("Node9: unrecognised hook payload \u2014 tool name missing.");
2576
+ return;
2577
+ }
2578
+ const meta = { agent, mcpServer };
2579
+ const result = await authorizeHeadless(toolName, toolInput, false, meta);
2580
+ if (result.approved) {
2581
+ if (result.checkedBy) {
2582
+ process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
2583
+ `);
2584
+ }
2585
+ process.exit(0);
2586
+ }
2587
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && getGlobalSettings().autoStartDaemon) {
2588
+ console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2589
+ const daemonReady = await autoStartDaemonAndWait();
2590
+ if (daemonReady) {
2591
+ const retry = await authorizeHeadless(toolName, toolInput, false, meta);
2592
+ if (retry.approved) {
2593
+ if (retry.checkedBy) {
2594
+ process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
2595
+ `);
2596
+ }
2597
+ process.exit(0);
2598
+ }
2599
+ sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, retry);
2600
+ return;
2601
+ }
2602
+ }
2603
+ sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, result);
2604
+ } catch (err) {
2605
+ if (process.env.NODE9_DEBUG === "1") {
2606
+ const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
2607
+ const errMsg = err instanceof Error ? err.message : String(err);
2608
+ import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
2609
+ `);
2610
+ }
2611
+ process.exit(0);
2612
+ }
2613
+ };
2614
+ if (data) {
2615
+ await processPayload(data);
2616
+ } else {
2617
+ let raw = "";
2618
+ let processed = false;
2619
+ const done = async () => {
2620
+ if (processed) return;
2621
+ processed = true;
2622
+ if (!raw.trim()) return process.exit(0);
2623
+ await processPayload(raw);
2624
+ };
2625
+ process.stdin.setEncoding("utf-8");
2626
+ process.stdin.on("data", (chunk) => raw += chunk);
2627
+ process.stdin.on("end", () => void done());
2628
+ setTimeout(() => void done(), 5e3);
2629
+ }
2630
+ });
2631
+ program.command("log").description("PostToolUse hook \u2014 records executed tool calls").argument("[data]", "JSON string of the tool call").action(async (data) => {
2632
+ const logPayload = (raw) => {
2633
+ try {
2634
+ if (!raw || raw.trim() === "") process.exit(0);
2635
+ const payload = JSON.parse(raw);
2636
+ const entry = {
2637
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2638
+ tool: sanitize(payload.tool_name ?? "unknown"),
2639
+ input: JSON.parse(redactSecrets(JSON.stringify(payload.tool_input || {})))
2640
+ };
2641
+ const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "audit.log");
2642
+ if (!import_fs4.default.existsSync(import_path4.default.dirname(logPath)))
2643
+ import_fs4.default.mkdirSync(import_path4.default.dirname(logPath), { recursive: true });
2644
+ import_fs4.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
2645
+ } catch {
2646
+ }
2647
+ process.exit(0);
2648
+ };
2649
+ if (data) {
2650
+ logPayload(data);
2651
+ } else {
2652
+ let raw = "";
2653
+ process.stdin.setEncoding("utf-8");
2654
+ process.stdin.on("data", (chunk) => raw += chunk);
2655
+ process.stdin.on("end", () => logPayload(raw));
2656
+ setTimeout(() => {
2657
+ if (!raw) process.exit(0);
2658
+ }, 500);
2659
+ }
2660
+ });
2661
+ var HOOK_BASED_AGENTS = {
2662
+ claude: "claude",
2663
+ gemini: "gemini",
2664
+ cursor: "cursor"
2665
+ };
2666
+ program.argument("[command...]", "The agent command to run (e.g., gemini)").action(async (commandArgs) => {
2667
+ if (commandArgs && commandArgs.length > 0) {
2668
+ const firstArg = commandArgs[0].toLowerCase();
2669
+ if (HOOK_BASED_AGENTS[firstArg] !== void 0) {
2670
+ const target = HOOK_BASED_AGENTS[firstArg];
2671
+ console.error(
2672
+ import_chalk4.default.yellow(`
2673
+ \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
2674
+ );
2675
+ console.error(
2676
+ import_chalk4.default.white(`
2677
+ "${target}" is an interactive terminal app \u2014 it needs a real`)
2678
+ );
2679
+ console.error(
2680
+ import_chalk4.default.white(` TTY and communicates via its own hook system, not JSON-RPC.
2681
+ `)
2682
+ );
2683
+ console.error(import_chalk4.default.bold(` Use the hook-based integration instead:
2684
+ `));
2685
+ console.error(
2686
+ import_chalk4.default.green(` node9 addto ${target} `) + import_chalk4.default.gray("# one-time setup")
2687
+ );
2688
+ console.error(
2689
+ import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# run normally \u2014 Node9 hooks fire automatically")
2690
+ );
2691
+ console.error(import_chalk4.default.white(`
2692
+ For browser approval popups (no API key required):`));
2693
+ console.error(
2694
+ import_chalk4.default.green(` node9 daemon --background`) + import_chalk4.default.gray("# start (no second terminal needed)")
2695
+ );
2696
+ console.error(
2697
+ import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# Node9 will open browser on dangerous actions\n")
2698
+ );
2699
+ process.exit(1);
2700
+ }
2701
+ const fullCommand = commandArgs.join(" ");
2702
+ let result = await authorizeHeadless("shell", { command: fullCommand });
2703
+ if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getGlobalSettings().autoStartDaemon) {
2704
+ console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
2705
+ const daemonReady = await autoStartDaemonAndWait();
2706
+ if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
2707
+ }
2708
+ if (result.noApprovalMechanism && process.stdout.isTTY) {
2709
+ result = await authorizeHeadless("shell", { command: fullCommand }, true);
2710
+ }
2711
+ if (!result.approved) {
2712
+ console.error(
2713
+ import_chalk4.default.red(`
2714
+ \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
2715
+ );
2716
+ if (result.blockedBy) {
2717
+ const BLOCKED_BY_LABELS = {
2718
+ "team-policy": "Team policy (Node9 cloud)",
2719
+ "persistent-deny": "Persistent deny rule",
2720
+ "local-config": "Local config",
2721
+ "local-decision": "Browser UI decision",
2722
+ "no-approval-mechanism": "No approval mechanism available"
2723
+ };
2724
+ console.error(
2725
+ import_chalk4.default.gray(` Blocked by: ${BLOCKED_BY_LABELS[result.blockedBy] ?? result.blockedBy}`)
2726
+ );
2727
+ }
2728
+ if (result.changeHint) {
2729
+ console.error(import_chalk4.default.cyan(` To change: ${result.changeHint}`));
2730
+ }
2731
+ process.exit(1);
2732
+ }
2733
+ console.error(import_chalk4.default.green("\n\u2705 Approved \u2014 running command...\n"));
2734
+ await runProxy(fullCommand);
2735
+ } else {
2736
+ program.help();
2737
+ }
2738
+ });
2739
+ process.on("unhandledRejection", (reason) => {
2740
+ const isCheckHook = process.argv[2] === "check";
2741
+ if (isCheckHook) {
2742
+ if (process.env.NODE9_DEBUG === "1") {
2743
+ const logPath = import_path4.default.join(import_os4.default.homedir(), ".node9", "hook-debug.log");
2744
+ const msg = reason instanceof Error ? reason.message : String(reason);
2745
+ import_fs4.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
2746
+ `);
2747
+ }
2748
+ process.exit(0);
2749
+ } else {
2750
+ console.error("[Node9] Unhandled error:", reason);
2751
+ process.exit(1);
2752
+ }
2753
+ });
2754
+ program.parse();