@node9/proxy 0.2.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -319
- package/dist/cli.js +1417 -608
- package/dist/cli.mjs +1417 -608
- package/dist/index.js +722 -261
- package/dist/index.mjs +722 -261
- package/package.json +44 -8
package/dist/cli.mjs
CHANGED
|
@@ -4,13 +4,254 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/core.ts
|
|
7
|
-
import
|
|
7
|
+
import chalk2 from "chalk";
|
|
8
8
|
import { confirm } from "@inquirer/prompts";
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import os from "os";
|
|
12
12
|
import pm from "picomatch";
|
|
13
13
|
import { parse } from "sh-syntax";
|
|
14
|
+
|
|
15
|
+
// src/ui/native.ts
|
|
16
|
+
import { spawn } from "child_process";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
var isTestEnv = () => {
|
|
19
|
+
return process.env.NODE_ENV === "test" || process.env.VITEST === "true" || !!process.env.VITEST || process.env.CI === "true" || !!process.env.CI || process.env.NODE9_TESTING === "1";
|
|
20
|
+
};
|
|
21
|
+
function smartTruncate(str, maxLen = 500) {
|
|
22
|
+
if (str.length <= maxLen) return str;
|
|
23
|
+
const edge = Math.floor(maxLen / 2) - 3;
|
|
24
|
+
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
|
|
25
|
+
}
|
|
26
|
+
function formatArgs(args) {
|
|
27
|
+
if (args === null || args === void 0) return "(none)";
|
|
28
|
+
let parsed = args;
|
|
29
|
+
if (typeof args === "string") {
|
|
30
|
+
const trimmed = args.trim();
|
|
31
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(trimmed);
|
|
34
|
+
} catch {
|
|
35
|
+
parsed = args;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
return smartTruncate(args, 600);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
42
|
+
const obj = parsed;
|
|
43
|
+
const codeKeys = [
|
|
44
|
+
"command",
|
|
45
|
+
"cmd",
|
|
46
|
+
"shell_command",
|
|
47
|
+
"bash_command",
|
|
48
|
+
"script",
|
|
49
|
+
"code",
|
|
50
|
+
"input",
|
|
51
|
+
"sql",
|
|
52
|
+
"query",
|
|
53
|
+
"arguments",
|
|
54
|
+
"args",
|
|
55
|
+
"param",
|
|
56
|
+
"params",
|
|
57
|
+
"text"
|
|
58
|
+
];
|
|
59
|
+
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
60
|
+
if (foundKey) {
|
|
61
|
+
const val = obj[foundKey];
|
|
62
|
+
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
63
|
+
return `[${foundKey.toUpperCase()}]:
|
|
64
|
+
${smartTruncate(str, 500)}`;
|
|
65
|
+
}
|
|
66
|
+
return Object.entries(obj).slice(0, 5).map(
|
|
67
|
+
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
68
|
+
).join("\n");
|
|
69
|
+
}
|
|
70
|
+
return smartTruncate(JSON.stringify(parsed), 200);
|
|
71
|
+
}
|
|
72
|
+
function sendDesktopNotification(title, body) {
|
|
73
|
+
if (isTestEnv()) return;
|
|
74
|
+
try {
|
|
75
|
+
if (process.platform === "darwin") {
|
|
76
|
+
const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
|
|
77
|
+
spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
78
|
+
} else if (process.platform === "linux") {
|
|
79
|
+
spawn("notify-send", [title, body, "--icon=dialog-warning"], {
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: "ignore"
|
|
82
|
+
}).unref();
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
|
|
88
|
+
if (isTestEnv()) return "deny";
|
|
89
|
+
const formattedArgs = formatArgs(args);
|
|
90
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
|
|
91
|
+
let message = "";
|
|
92
|
+
if (locked) message += `\u26A0\uFE0F LOCKED BY ADMIN POLICY
|
|
93
|
+
`;
|
|
94
|
+
message += `Tool: ${toolName}
|
|
95
|
+
`;
|
|
96
|
+
message += `Agent: ${agent || "AI Agent"}
|
|
97
|
+
`;
|
|
98
|
+
message += `Rule: ${explainableLabel || "Security Policy"}
|
|
99
|
+
|
|
100
|
+
`;
|
|
101
|
+
message += `${formattedArgs}`;
|
|
102
|
+
process.stderr.write(chalk.yellow(`
|
|
103
|
+
\u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
|
|
104
|
+
`));
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
let childProcess = null;
|
|
107
|
+
const onAbort = () => {
|
|
108
|
+
if (childProcess && childProcess.pid) {
|
|
109
|
+
try {
|
|
110
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
resolve("deny");
|
|
115
|
+
};
|
|
116
|
+
if (signal) {
|
|
117
|
+
if (signal.aborted) return resolve("deny");
|
|
118
|
+
signal.addEventListener("abort", onAbort);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
if (process.platform === "darwin") {
|
|
122
|
+
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
|
|
123
|
+
const script = `on run argv
|
|
124
|
+
tell application "System Events"
|
|
125
|
+
activate
|
|
126
|
+
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
127
|
+
end tell
|
|
128
|
+
end run`;
|
|
129
|
+
childProcess = spawn("osascript", ["-e", script, "--", message, title]);
|
|
130
|
+
} else if (process.platform === "linux") {
|
|
131
|
+
const argsList = [
|
|
132
|
+
locked ? "--info" : "--question",
|
|
133
|
+
"--modal",
|
|
134
|
+
"--width=450",
|
|
135
|
+
"--title",
|
|
136
|
+
title,
|
|
137
|
+
"--text",
|
|
138
|
+
message,
|
|
139
|
+
"--ok-label",
|
|
140
|
+
locked ? "Waiting..." : "Allow",
|
|
141
|
+
"--timeout",
|
|
142
|
+
"300"
|
|
143
|
+
];
|
|
144
|
+
if (!locked) {
|
|
145
|
+
argsList.push("--cancel-label", "Block");
|
|
146
|
+
argsList.push("--extra-button", "Always Allow");
|
|
147
|
+
}
|
|
148
|
+
childProcess = spawn("zenity", argsList);
|
|
149
|
+
} else if (process.platform === "win32") {
|
|
150
|
+
const b64Msg = Buffer.from(message).toString("base64");
|
|
151
|
+
const b64Title = Buffer.from(title).toString("base64");
|
|
152
|
+
const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
153
|
+
childProcess = spawn("powershell", ["-Command", ps]);
|
|
154
|
+
}
|
|
155
|
+
let output = "";
|
|
156
|
+
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
157
|
+
childProcess?.on("close", (code) => {
|
|
158
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
159
|
+
if (locked) return resolve("deny");
|
|
160
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
161
|
+
if (code === 0) return resolve("allow");
|
|
162
|
+
resolve("deny");
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
resolve("deny");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/core.ts
|
|
171
|
+
var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
|
|
172
|
+
var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
|
|
173
|
+
function checkPause() {
|
|
174
|
+
try {
|
|
175
|
+
if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
|
|
176
|
+
const state = JSON.parse(fs.readFileSync(PAUSED_FILE, "utf-8"));
|
|
177
|
+
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
178
|
+
try {
|
|
179
|
+
fs.unlinkSync(PAUSED_FILE);
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
return { paused: false };
|
|
183
|
+
}
|
|
184
|
+
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
185
|
+
} catch {
|
|
186
|
+
return { paused: false };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function atomicWriteSync(filePath, data, options) {
|
|
190
|
+
const dir = path.dirname(filePath);
|
|
191
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
192
|
+
const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
|
|
193
|
+
fs.writeFileSync(tmpPath, data, options);
|
|
194
|
+
fs.renameSync(tmpPath, filePath);
|
|
195
|
+
}
|
|
196
|
+
function pauseNode9(durationMs, durationStr) {
|
|
197
|
+
const state = { expiry: Date.now() + durationMs, duration: durationStr };
|
|
198
|
+
atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
|
|
199
|
+
}
|
|
200
|
+
function resumeNode9() {
|
|
201
|
+
try {
|
|
202
|
+
if (fs.existsSync(PAUSED_FILE)) fs.unlinkSync(PAUSED_FILE);
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function getActiveTrustSession(toolName) {
|
|
207
|
+
try {
|
|
208
|
+
if (!fs.existsSync(TRUST_FILE)) return false;
|
|
209
|
+
const trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const active = trust.entries.filter((e) => e.expiry > now);
|
|
212
|
+
if (active.length !== trust.entries.length) {
|
|
213
|
+
fs.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
214
|
+
}
|
|
215
|
+
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function writeTrustSession(toolName, durationMs) {
|
|
221
|
+
try {
|
|
222
|
+
let trust = { entries: [] };
|
|
223
|
+
try {
|
|
224
|
+
if (fs.existsSync(TRUST_FILE)) {
|
|
225
|
+
trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
231
|
+
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
232
|
+
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
235
|
+
console.error("[Node9 Trust Error]:", err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function appendAuditModeEntry(toolName, args) {
|
|
240
|
+
try {
|
|
241
|
+
const entry = JSON.stringify({
|
|
242
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
243
|
+
tool: toolName,
|
|
244
|
+
args,
|
|
245
|
+
decision: "would-have-blocked",
|
|
246
|
+
source: "audit-mode"
|
|
247
|
+
});
|
|
248
|
+
const logPath = path.join(os.homedir(), ".node9", "audit.log");
|
|
249
|
+
const dir = path.dirname(logPath);
|
|
250
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
251
|
+
fs.appendFileSync(logPath, entry + "\n");
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
}
|
|
14
255
|
var DANGEROUS_WORDS = [
|
|
15
256
|
"delete",
|
|
16
257
|
"drop",
|
|
@@ -28,10 +269,6 @@ var DANGEROUS_WORDS = [
|
|
|
28
269
|
function tokenize(toolName) {
|
|
29
270
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
30
271
|
}
|
|
31
|
-
function containsDangerousWord(toolName, dangerousWords) {
|
|
32
|
-
const tokens = tokenize(toolName);
|
|
33
|
-
return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
|
|
34
|
-
}
|
|
35
272
|
function matchesPattern(text, patterns) {
|
|
36
273
|
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
37
274
|
if (p.length === 0) return false;
|
|
@@ -42,9 +279,9 @@ function matchesPattern(text, patterns) {
|
|
|
42
279
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
43
280
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
44
281
|
}
|
|
45
|
-
function getNestedValue(obj,
|
|
282
|
+
function getNestedValue(obj, path6) {
|
|
46
283
|
if (!obj || typeof obj !== "object") return null;
|
|
47
|
-
return
|
|
284
|
+
return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
48
285
|
}
|
|
49
286
|
function extractShellCommand(toolName, args, toolInspection) {
|
|
50
287
|
const patterns = Object.keys(toolInspection);
|
|
@@ -136,8 +373,15 @@ function redactSecrets(text) {
|
|
|
136
373
|
return redacted;
|
|
137
374
|
}
|
|
138
375
|
var DEFAULT_CONFIG = {
|
|
139
|
-
settings: {
|
|
376
|
+
settings: {
|
|
377
|
+
mode: "standard",
|
|
378
|
+
autoStartDaemon: true,
|
|
379
|
+
enableUndo: false,
|
|
380
|
+
enableHookLogDebug: false,
|
|
381
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
382
|
+
},
|
|
140
383
|
policy: {
|
|
384
|
+
sandboxPaths: [],
|
|
141
385
|
dangerousWords: DANGEROUS_WORDS,
|
|
142
386
|
ignoredTools: [
|
|
143
387
|
"list_*",
|
|
@@ -145,34 +389,19 @@ var DEFAULT_CONFIG = {
|
|
|
145
389
|
"read_*",
|
|
146
390
|
"describe_*",
|
|
147
391
|
"read",
|
|
148
|
-
"write",
|
|
149
|
-
"edit",
|
|
150
|
-
"multiedit",
|
|
151
|
-
"glob",
|
|
152
392
|
"grep",
|
|
153
393
|
"ls",
|
|
154
|
-
"notebookread",
|
|
155
|
-
"notebookedit",
|
|
156
|
-
"todoread",
|
|
157
|
-
"todowrite",
|
|
158
|
-
"webfetch",
|
|
159
|
-
"websearch",
|
|
160
|
-
"exitplanmode",
|
|
161
394
|
"askuserquestion"
|
|
162
395
|
],
|
|
163
|
-
toolInspection: {
|
|
164
|
-
|
|
165
|
-
run_shell_command: "command",
|
|
166
|
-
shell: "command",
|
|
167
|
-
"terminal.execute": "command"
|
|
168
|
-
},
|
|
169
|
-
rules: [
|
|
170
|
-
{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
|
|
171
|
-
]
|
|
396
|
+
toolInspection: { bash: "command", shell: "command" },
|
|
397
|
+
rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
|
|
172
398
|
},
|
|
173
399
|
environments: {}
|
|
174
400
|
};
|
|
175
401
|
var cachedConfig = null;
|
|
402
|
+
function _resetConfigCache() {
|
|
403
|
+
cachedConfig = null;
|
|
404
|
+
}
|
|
176
405
|
function getGlobalSettings() {
|
|
177
406
|
try {
|
|
178
407
|
const globalConfigPath = path.join(os.homedir(), ".node9", "config.json");
|
|
@@ -183,18 +412,19 @@ function getGlobalSettings() {
|
|
|
183
412
|
mode: settings.mode || "standard",
|
|
184
413
|
autoStartDaemon: settings.autoStartDaemon !== false,
|
|
185
414
|
slackEnabled: settings.slackEnabled !== false,
|
|
186
|
-
|
|
187
|
-
|
|
415
|
+
enableTrustSessions: settings.enableTrustSessions === true,
|
|
416
|
+
allowGlobalPause: settings.allowGlobalPause !== false
|
|
188
417
|
};
|
|
189
418
|
}
|
|
190
419
|
} catch {
|
|
191
420
|
}
|
|
192
|
-
return {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
421
|
+
return {
|
|
422
|
+
mode: "standard",
|
|
423
|
+
autoStartDaemon: true,
|
|
424
|
+
slackEnabled: true,
|
|
425
|
+
enableTrustSessions: false,
|
|
426
|
+
allowGlobalPause: true
|
|
427
|
+
};
|
|
198
428
|
}
|
|
199
429
|
function getInternalToken() {
|
|
200
430
|
try {
|
|
@@ -207,51 +437,83 @@ function getInternalToken() {
|
|
|
207
437
|
return null;
|
|
208
438
|
}
|
|
209
439
|
}
|
|
210
|
-
async function evaluatePolicy(toolName, args) {
|
|
440
|
+
async function evaluatePolicy(toolName, args, agent) {
|
|
211
441
|
const config = getConfig();
|
|
212
|
-
if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
|
|
442
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
|
|
443
|
+
let allTokens = [];
|
|
444
|
+
let actionTokens = [];
|
|
445
|
+
let pathTokens = [];
|
|
213
446
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
214
447
|
if (shellCommand) {
|
|
215
|
-
const
|
|
448
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
449
|
+
allTokens = analyzed.allTokens;
|
|
450
|
+
actionTokens = analyzed.actions;
|
|
451
|
+
pathTokens = analyzed.paths;
|
|
216
452
|
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
217
|
-
if (INLINE_EXEC_PATTERN.test(shellCommand.trim()))
|
|
218
|
-
|
|
219
|
-
const basename = action.includes("/") ? action.split("/").pop() : action;
|
|
220
|
-
const rule = config.policy.rules.find(
|
|
221
|
-
(r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
|
|
222
|
-
);
|
|
223
|
-
if (rule) {
|
|
224
|
-
if (paths.length > 0) {
|
|
225
|
-
const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
226
|
-
if (anyBlocked) return "review";
|
|
227
|
-
const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
228
|
-
if (allAllowed) return "allow";
|
|
229
|
-
}
|
|
230
|
-
return "review";
|
|
231
|
-
}
|
|
453
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
454
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
|
|
232
455
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
456
|
+
} else {
|
|
457
|
+
allTokens = tokenize(toolName);
|
|
458
|
+
actionTokens = [toolName];
|
|
459
|
+
}
|
|
460
|
+
const isManual = agent === "Terminal";
|
|
461
|
+
if (isManual) {
|
|
462
|
+
const NUCLEAR_COMMANDS = [
|
|
463
|
+
"drop",
|
|
464
|
+
"destroy",
|
|
465
|
+
"purge",
|
|
466
|
+
"rmdir",
|
|
467
|
+
"format",
|
|
468
|
+
"truncate",
|
|
469
|
+
"alter",
|
|
470
|
+
"grant",
|
|
471
|
+
"revoke",
|
|
472
|
+
"docker"
|
|
473
|
+
];
|
|
474
|
+
const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
|
|
475
|
+
if (!hasNuclear) return { decision: "allow" };
|
|
476
|
+
}
|
|
477
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
478
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
479
|
+
if (allInSandbox) return { decision: "allow" };
|
|
480
|
+
}
|
|
481
|
+
for (const action of actionTokens) {
|
|
482
|
+
const rule = config.policy.rules.find(
|
|
483
|
+
(r) => r.action === action || matchesPattern(action, r.action)
|
|
243
484
|
);
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
485
|
+
if (rule) {
|
|
486
|
+
if (pathTokens.length > 0) {
|
|
487
|
+
const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
488
|
+
if (anyBlocked)
|
|
489
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
|
|
490
|
+
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
491
|
+
if (allAllowed) return { decision: "allow" };
|
|
492
|
+
}
|
|
493
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const isDangerous = allTokens.some(
|
|
497
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
498
|
+
const w = word.toLowerCase();
|
|
499
|
+
if (token === w) return true;
|
|
500
|
+
try {
|
|
501
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
if (isDangerous) {
|
|
508
|
+
const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
|
|
509
|
+
return { decision: "review", blockedByLabel: label };
|
|
247
510
|
}
|
|
248
|
-
|
|
249
|
-
if (isDangerous || config.settings.mode === "strict") {
|
|
511
|
+
if (config.settings.mode === "strict") {
|
|
250
512
|
const envConfig = getActiveEnvironment(config);
|
|
251
|
-
if (envConfig?.requireApproval === false) return "allow";
|
|
252
|
-
return "review";
|
|
513
|
+
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
514
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
|
|
253
515
|
}
|
|
254
|
-
return "allow";
|
|
516
|
+
return { decision: "allow" };
|
|
255
517
|
}
|
|
256
518
|
function isIgnoredTool(toolName) {
|
|
257
519
|
const config = getConfig();
|
|
@@ -282,22 +544,40 @@ function getPersistentDecision(toolName) {
|
|
|
282
544
|
}
|
|
283
545
|
return null;
|
|
284
546
|
}
|
|
285
|
-
async function askDaemon(toolName, args, meta) {
|
|
547
|
+
async function askDaemon(toolName, args, meta, signal) {
|
|
286
548
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
549
|
+
const checkCtrl = new AbortController();
|
|
550
|
+
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
551
|
+
const onAbort = () => checkCtrl.abort();
|
|
552
|
+
if (signal) signal.addEventListener("abort", onAbort);
|
|
553
|
+
try {
|
|
554
|
+
const checkRes = await fetch(`${base}/check`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: { "Content-Type": "application/json" },
|
|
557
|
+
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
|
|
558
|
+
signal: checkCtrl.signal
|
|
559
|
+
});
|
|
560
|
+
if (!checkRes.ok) throw new Error("Daemon fail");
|
|
561
|
+
const { id } = await checkRes.json();
|
|
562
|
+
const waitCtrl = new AbortController();
|
|
563
|
+
const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
|
|
564
|
+
const onWaitAbort = () => waitCtrl.abort();
|
|
565
|
+
if (signal) signal.addEventListener("abort", onWaitAbort);
|
|
566
|
+
try {
|
|
567
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
568
|
+
if (!waitRes.ok) return "deny";
|
|
569
|
+
const { decision } = await waitRes.json();
|
|
570
|
+
if (decision === "allow") return "allow";
|
|
571
|
+
if (decision === "abandoned") return "abandoned";
|
|
572
|
+
return "deny";
|
|
573
|
+
} finally {
|
|
574
|
+
clearTimeout(waitTimer);
|
|
575
|
+
if (signal) signal.removeEventListener("abort", onWaitAbort);
|
|
576
|
+
}
|
|
577
|
+
} finally {
|
|
578
|
+
clearTimeout(checkTimer);
|
|
579
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
580
|
+
}
|
|
301
581
|
}
|
|
302
582
|
async function notifyDaemonViewer(toolName, args, meta) {
|
|
303
583
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
@@ -327,176 +607,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
|
|
|
327
607
|
});
|
|
328
608
|
}
|
|
329
609
|
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
610
|
+
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
611
|
+
const pauseState = checkPause();
|
|
612
|
+
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
613
|
+
const creds = getCredentials();
|
|
614
|
+
const config = getConfig();
|
|
615
|
+
const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
|
|
616
|
+
const approvers = {
|
|
617
|
+
...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
|
|
618
|
+
};
|
|
619
|
+
if (isTestEnv2) {
|
|
620
|
+
approvers.native = false;
|
|
621
|
+
approvers.browser = false;
|
|
622
|
+
approvers.terminal = false;
|
|
623
|
+
}
|
|
624
|
+
const isManual = meta?.agent === "Terminal";
|
|
625
|
+
let explainableLabel = "Local Config";
|
|
626
|
+
if (config.settings.mode === "audit") {
|
|
627
|
+
if (!isIgnoredTool(toolName)) {
|
|
628
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
629
|
+
if (policyResult.decision === "review") {
|
|
630
|
+
appendAuditModeEntry(toolName, args);
|
|
631
|
+
sendDesktopNotification(
|
|
632
|
+
"Node9 Audit Mode",
|
|
633
|
+
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { approved: true, checkedBy: "audit" };
|
|
638
|
+
}
|
|
639
|
+
if (!isIgnoredTool(toolName)) {
|
|
640
|
+
if (getActiveTrustSession(toolName)) {
|
|
641
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
642
|
+
return { approved: true, checkedBy: "trust" };
|
|
643
|
+
}
|
|
644
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
645
|
+
if (policyResult.decision === "allow") {
|
|
646
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
647
|
+
return { approved: true, checkedBy: "local-policy" };
|
|
648
|
+
}
|
|
649
|
+
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
336
650
|
const persistent = getPersistentDecision(toolName);
|
|
337
|
-
if (persistent === "allow")
|
|
338
|
-
|
|
651
|
+
if (persistent === "allow") {
|
|
652
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
653
|
+
return { approved: true, checkedBy: "persistent" };
|
|
654
|
+
}
|
|
655
|
+
if (persistent === "deny") {
|
|
339
656
|
return {
|
|
340
657
|
approved: false,
|
|
341
|
-
reason: `
|
|
658
|
+
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
342
659
|
blockedBy: "persistent-deny",
|
|
343
|
-
|
|
660
|
+
blockedByLabel: "Persistent User Rule"
|
|
344
661
|
};
|
|
345
|
-
}
|
|
346
|
-
if (cloudEnforced) {
|
|
347
|
-
const creds = getCredentials();
|
|
348
|
-
const envConfig = getActiveEnvironment(getConfig());
|
|
349
|
-
let viewerId = null;
|
|
350
|
-
const internalToken = getInternalToken();
|
|
351
|
-
if (isDaemonRunning() && internalToken) {
|
|
352
|
-
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
|
|
353
|
-
}
|
|
354
|
-
const approved = await callNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
|
|
355
|
-
if (viewerId && internalToken) {
|
|
356
|
-
resolveViaDaemon(viewerId, approved ? "allow" : "deny", internalToken).catch(() => null);
|
|
357
662
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
blockedBy: approved ? void 0 : "team-policy",
|
|
362
|
-
changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
|
|
363
|
-
};
|
|
663
|
+
} else {
|
|
664
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
|
|
665
|
+
return { approved: true };
|
|
364
666
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
667
|
+
let cloudRequestId = null;
|
|
668
|
+
let isRemoteLocked = false;
|
|
669
|
+
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
670
|
+
if (cloudEnforced) {
|
|
369
671
|
try {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
} else {
|
|
672
|
+
const envConfig = getActiveEnvironment(getConfig());
|
|
673
|
+
const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
|
|
674
|
+
if (!initResult.pending) {
|
|
374
675
|
return {
|
|
375
|
-
approved:
|
|
376
|
-
reason:
|
|
377
|
-
checkedBy:
|
|
378
|
-
blockedBy:
|
|
379
|
-
|
|
676
|
+
approved: !!initResult.approved,
|
|
677
|
+
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
678
|
+
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
679
|
+
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
680
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
380
681
|
};
|
|
381
682
|
}
|
|
382
|
-
|
|
683
|
+
cloudRequestId = initResult.requestId || null;
|
|
684
|
+
isRemoteLocked = !!initResult.remoteApprovalOnly;
|
|
685
|
+
explainableLabel = "Organization Policy (SaaS)";
|
|
686
|
+
} catch (err) {
|
|
687
|
+
const error = err;
|
|
688
|
+
const isAuthError = error.message.includes("401") || error.message.includes("403");
|
|
689
|
+
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
690
|
+
const reason = isAuthError ? "Invalid or missing API key. Run `node9 login` to generate a key (must start with n9_live_)." : isNetworkError ? "Could not reach the Node9 cloud. Check your network or API URL." : error.message;
|
|
691
|
+
console.error(
|
|
692
|
+
chalk2.yellow(`
|
|
693
|
+
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk2.dim(`
|
|
694
|
+
Falling back to local rules...
|
|
695
|
+
`)
|
|
696
|
+
);
|
|
383
697
|
}
|
|
384
698
|
}
|
|
385
|
-
if (
|
|
386
|
-
console.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
console.
|
|
390
|
-
|
|
391
|
-
|
|
699
|
+
if (cloudEnforced && cloudRequestId) {
|
|
700
|
+
console.error(
|
|
701
|
+
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
702
|
+
);
|
|
703
|
+
console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
|
|
704
|
+
} else if (!cloudEnforced) {
|
|
705
|
+
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
706
|
+
console.error(
|
|
707
|
+
chalk2.dim(`
|
|
708
|
+
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
709
|
+
`)
|
|
392
710
|
);
|
|
393
|
-
const controller = new AbortController();
|
|
394
|
-
const TIMEOUT_MS = 3e4;
|
|
395
|
-
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
396
|
-
try {
|
|
397
|
-
const approved = await confirm(
|
|
398
|
-
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
399
|
-
{ signal: controller.signal }
|
|
400
|
-
);
|
|
401
|
-
clearTimeout(timer);
|
|
402
|
-
return { approved };
|
|
403
|
-
} catch {
|
|
404
|
-
clearTimeout(timer);
|
|
405
|
-
console.error(chalk.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
|
|
406
|
-
return { approved: false };
|
|
407
|
-
}
|
|
408
711
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
712
|
+
const abortController = new AbortController();
|
|
713
|
+
const { signal } = abortController;
|
|
714
|
+
const racePromises = [];
|
|
715
|
+
let viewerId = null;
|
|
716
|
+
const internalToken = getInternalToken();
|
|
717
|
+
if (cloudEnforced && cloudRequestId) {
|
|
718
|
+
racePromises.push(
|
|
719
|
+
(async () => {
|
|
720
|
+
try {
|
|
721
|
+
if (isDaemonRunning() && internalToken) {
|
|
722
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
|
|
723
|
+
}
|
|
724
|
+
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
725
|
+
return {
|
|
726
|
+
approved: cloudResult.approved,
|
|
727
|
+
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
728
|
+
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
729
|
+
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
730
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
731
|
+
};
|
|
732
|
+
} catch (err) {
|
|
733
|
+
const error = err;
|
|
734
|
+
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
735
|
+
throw err;
|
|
736
|
+
}
|
|
737
|
+
})()
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
if (approvers.native && !isManual) {
|
|
741
|
+
racePromises.push(
|
|
742
|
+
(async () => {
|
|
743
|
+
const decision = await askNativePopup(
|
|
744
|
+
toolName,
|
|
745
|
+
args,
|
|
746
|
+
meta?.agent,
|
|
747
|
+
explainableLabel,
|
|
748
|
+
isRemoteLocked,
|
|
749
|
+
signal
|
|
750
|
+
);
|
|
751
|
+
if (decision === "always_allow") {
|
|
752
|
+
writeTrustSession(toolName, 36e5);
|
|
753
|
+
return { approved: true, checkedBy: "trust" };
|
|
754
|
+
}
|
|
755
|
+
const isApproved = decision === "allow";
|
|
756
|
+
return {
|
|
757
|
+
approved: isApproved,
|
|
758
|
+
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
759
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
760
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
761
|
+
blockedByLabel: "User Decision (Native)"
|
|
762
|
+
};
|
|
763
|
+
})()
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (approvers.browser && isDaemonRunning()) {
|
|
767
|
+
racePromises.push(
|
|
768
|
+
(async () => {
|
|
769
|
+
try {
|
|
770
|
+
if (!approvers.native && !cloudEnforced) {
|
|
771
|
+
console.error(
|
|
772
|
+
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
773
|
+
);
|
|
774
|
+
console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
775
|
+
`));
|
|
776
|
+
}
|
|
777
|
+
const daemonDecision = await askDaemon(toolName, args, meta, signal);
|
|
778
|
+
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
779
|
+
const isApproved = daemonDecision === "allow";
|
|
780
|
+
return {
|
|
781
|
+
approved: isApproved,
|
|
782
|
+
reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
|
|
783
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
784
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
785
|
+
blockedByLabel: "User Decision (Browser)"
|
|
786
|
+
};
|
|
787
|
+
} catch (err) {
|
|
788
|
+
throw err;
|
|
789
|
+
}
|
|
790
|
+
})()
|
|
791
|
+
);
|
|
425
792
|
}
|
|
426
|
-
|
|
793
|
+
if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
|
|
794
|
+
racePromises.push(
|
|
795
|
+
(async () => {
|
|
796
|
+
try {
|
|
797
|
+
console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
798
|
+
console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
|
|
799
|
+
console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
|
|
800
|
+
if (isRemoteLocked) {
|
|
801
|
+
console.log(chalk2.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
802
|
+
`));
|
|
803
|
+
await new Promise((_, reject) => {
|
|
804
|
+
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const TIMEOUT_MS = 6e4;
|
|
808
|
+
let timer;
|
|
809
|
+
const result = await new Promise((resolve, reject) => {
|
|
810
|
+
timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
|
|
811
|
+
confirm(
|
|
812
|
+
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
813
|
+
{ signal }
|
|
814
|
+
).then(resolve).catch(reject);
|
|
815
|
+
});
|
|
816
|
+
clearTimeout(timer);
|
|
817
|
+
return {
|
|
818
|
+
approved: result,
|
|
819
|
+
reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
|
|
820
|
+
checkedBy: result ? "terminal" : void 0,
|
|
821
|
+
blockedBy: result ? void 0 : "local-decision",
|
|
822
|
+
blockedByLabel: "User Decision (Terminal)"
|
|
823
|
+
};
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const error = err;
|
|
826
|
+
if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
|
|
827
|
+
throw err;
|
|
828
|
+
if (error.message === "Terminal Timeout") {
|
|
829
|
+
return {
|
|
830
|
+
approved: false,
|
|
831
|
+
reason: "The terminal prompt timed out without a human response.",
|
|
832
|
+
blockedBy: "local-decision"
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
throw err;
|
|
836
|
+
}
|
|
837
|
+
})()
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (racePromises.length === 0) {
|
|
841
|
+
return {
|
|
842
|
+
approved: false,
|
|
843
|
+
noApprovalMechanism: true,
|
|
844
|
+
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
845
|
+
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
846
|
+
blockedBy: "no-approval-mechanism",
|
|
847
|
+
blockedByLabel: explainableLabel
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const finalResult = await new Promise((resolve) => {
|
|
851
|
+
let resolved = false;
|
|
852
|
+
let failures = 0;
|
|
853
|
+
const total = racePromises.length;
|
|
854
|
+
const finish = (res) => {
|
|
855
|
+
if (!resolved) {
|
|
856
|
+
resolved = true;
|
|
857
|
+
abortController.abort();
|
|
858
|
+
if (viewerId && internalToken) {
|
|
859
|
+
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
860
|
+
() => null
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
resolve(res);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
for (const p of racePromises) {
|
|
867
|
+
p.then(finish).catch((err) => {
|
|
868
|
+
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
869
|
+
return;
|
|
870
|
+
if (err.message === "Abandoned") {
|
|
871
|
+
finish({
|
|
872
|
+
approved: false,
|
|
873
|
+
reason: "Browser dashboard closed without making a decision.",
|
|
874
|
+
blockedBy: "local-decision",
|
|
875
|
+
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
876
|
+
});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
failures++;
|
|
880
|
+
if (failures === total && !resolved) {
|
|
881
|
+
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
887
|
+
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
888
|
+
}
|
|
889
|
+
return finalResult;
|
|
427
890
|
}
|
|
428
891
|
function getConfig() {
|
|
429
892
|
if (cachedConfig) return cachedConfig;
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
893
|
+
const globalPath = path.join(os.homedir(), ".node9", "config.json");
|
|
894
|
+
const projectPath = path.join(process.cwd(), "node9.config.json");
|
|
895
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
896
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
897
|
+
const mergedSettings = {
|
|
898
|
+
...DEFAULT_CONFIG.settings,
|
|
899
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
900
|
+
};
|
|
901
|
+
const mergedPolicy = {
|
|
902
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
903
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
904
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
905
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
906
|
+
rules: [...DEFAULT_CONFIG.policy.rules]
|
|
907
|
+
};
|
|
908
|
+
const applyLayer = (source) => {
|
|
909
|
+
if (!source) return;
|
|
910
|
+
const s = source.settings || {};
|
|
911
|
+
const p = source.policy || {};
|
|
912
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
913
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
914
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
915
|
+
if (s.enableHookLogDebug !== void 0)
|
|
916
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
917
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
918
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
919
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
920
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
921
|
+
if (p.toolInspection)
|
|
922
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
923
|
+
if (p.rules) mergedPolicy.rules.push(...p.rules);
|
|
924
|
+
};
|
|
925
|
+
applyLayer(globalConfig);
|
|
926
|
+
applyLayer(projectConfig);
|
|
927
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
928
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
929
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
930
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
931
|
+
cachedConfig = {
|
|
932
|
+
settings: mergedSettings,
|
|
933
|
+
policy: mergedPolicy,
|
|
934
|
+
environments: {}
|
|
935
|
+
};
|
|
441
936
|
return cachedConfig;
|
|
442
937
|
}
|
|
443
938
|
function tryLoadConfig(filePath) {
|
|
444
939
|
if (!fs.existsSync(filePath)) return null;
|
|
445
940
|
try {
|
|
446
|
-
|
|
447
|
-
validateConfig(config, filePath);
|
|
448
|
-
return config;
|
|
941
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
449
942
|
} catch {
|
|
450
943
|
return null;
|
|
451
944
|
}
|
|
452
945
|
}
|
|
453
|
-
function validateConfig(config, path5) {
|
|
454
|
-
const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
|
|
455
|
-
Object.keys(config).forEach((key) => {
|
|
456
|
-
if (!allowedTopLevel.includes(key))
|
|
457
|
-
console.warn(chalk.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path5}`));
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
function buildConfig(parsed) {
|
|
461
|
-
const p = parsed.policy || {};
|
|
462
|
-
const s = parsed.settings || {};
|
|
463
|
-
return {
|
|
464
|
-
settings: {
|
|
465
|
-
mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
|
|
466
|
-
autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
|
|
467
|
-
},
|
|
468
|
-
policy: {
|
|
469
|
-
dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
|
|
470
|
-
ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
|
|
471
|
-
toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
|
|
472
|
-
rules: p.rules ?? DEFAULT_CONFIG.policy.rules
|
|
473
|
-
},
|
|
474
|
-
environments: parsed.environments || {}
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
946
|
function getActiveEnvironment(config) {
|
|
478
947
|
const env = process.env.NODE_ENV || "development";
|
|
479
948
|
return config.environments[env] ?? null;
|
|
480
949
|
}
|
|
481
950
|
function getCredentials() {
|
|
482
951
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
483
|
-
if (process.env.NODE9_API_KEY)
|
|
952
|
+
if (process.env.NODE9_API_KEY) {
|
|
484
953
|
return {
|
|
485
954
|
apiKey: process.env.NODE9_API_KEY,
|
|
486
955
|
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
487
956
|
};
|
|
488
|
-
try {
|
|
489
|
-
const projectConfigPath = path.join(process.cwd(), "node9.config.json");
|
|
490
|
-
if (fs.existsSync(projectConfigPath)) {
|
|
491
|
-
const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8"));
|
|
492
|
-
if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
|
|
493
|
-
return {
|
|
494
|
-
apiKey: projectConfig.apiKey,
|
|
495
|
-
apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
} catch {
|
|
500
957
|
}
|
|
501
958
|
try {
|
|
502
959
|
const credPath = path.join(os.homedir(), ".node9", "credentials.json");
|
|
@@ -521,14 +978,32 @@ function getCredentials() {
|
|
|
521
978
|
}
|
|
522
979
|
return null;
|
|
523
980
|
}
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
981
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
982
|
+
const controller = new AbortController();
|
|
983
|
+
setTimeout(() => controller.abort(), 5e3);
|
|
984
|
+
fetch(`${creds.apiUrl}/audit`, {
|
|
985
|
+
method: "POST",
|
|
986
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
987
|
+
body: JSON.stringify({
|
|
988
|
+
toolName,
|
|
989
|
+
args,
|
|
990
|
+
checkedBy,
|
|
991
|
+
context: {
|
|
992
|
+
agent: meta?.agent,
|
|
993
|
+
mcpServer: meta?.mcpServer,
|
|
994
|
+
hostname: os.hostname(),
|
|
995
|
+
cwd: process.cwd(),
|
|
996
|
+
platform: os.platform()
|
|
997
|
+
}
|
|
998
|
+
}),
|
|
999
|
+
signal: controller.signal
|
|
1000
|
+
}).catch(() => {
|
|
1001
|
+
});
|
|
527
1002
|
}
|
|
528
|
-
async function
|
|
1003
|
+
async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
1004
|
+
const controller = new AbortController();
|
|
1005
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
529
1006
|
try {
|
|
530
|
-
const controller = new AbortController();
|
|
531
|
-
const timeout = setTimeout(() => controller.abort(), 35e3);
|
|
532
1007
|
const response = await fetch(creds.apiUrl, {
|
|
533
1008
|
method: "POST",
|
|
534
1009
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
@@ -546,46 +1021,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
|
546
1021
|
}),
|
|
547
1022
|
signal: controller.signal
|
|
548
1023
|
});
|
|
1024
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1025
|
+
return await response.json();
|
|
1026
|
+
} finally {
|
|
549
1027
|
clearTimeout(timeout);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
return true;
|
|
577
|
-
}
|
|
578
|
-
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
579
|
-
console.error(chalk.red("\u274C Denied \u2014 action blocked.\n"));
|
|
580
|
-
return false;
|
|
581
|
-
}
|
|
582
|
-
} catch {
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function pollNode9SaaS(requestId, creds, signal) {
|
|
1031
|
+
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
1032
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1033
|
+
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
1034
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
1035
|
+
if (signal.aborted) throw new Error("Aborted");
|
|
1036
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1037
|
+
try {
|
|
1038
|
+
const pollCtrl = new AbortController();
|
|
1039
|
+
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
1040
|
+
const statusRes = await fetch(statusUrl, {
|
|
1041
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
1042
|
+
signal: pollCtrl.signal
|
|
1043
|
+
});
|
|
1044
|
+
clearTimeout(pollTimer);
|
|
1045
|
+
if (!statusRes.ok) continue;
|
|
1046
|
+
const { status, reason } = await statusRes.json();
|
|
1047
|
+
if (status === "APPROVED") {
|
|
1048
|
+
console.error(chalk2.green("\u2705 Approved via Cloud.\n"));
|
|
1049
|
+
return { approved: true, reason };
|
|
1050
|
+
}
|
|
1051
|
+
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1052
|
+
console.error(chalk2.red("\u274C Denied via Cloud.\n"));
|
|
1053
|
+
return { approved: false, reason };
|
|
583
1054
|
}
|
|
1055
|
+
} catch {
|
|
584
1056
|
}
|
|
585
|
-
|
|
586
|
-
|
|
1057
|
+
}
|
|
1058
|
+
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
1059
|
+
}
|
|
1060
|
+
async function resolveNode9SaaS(requestId, creds, approved) {
|
|
1061
|
+
try {
|
|
1062
|
+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
1063
|
+
const ctrl = new AbortController();
|
|
1064
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1065
|
+
await fetch(resolveUrl, {
|
|
1066
|
+
method: "PATCH",
|
|
1067
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1068
|
+
body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
|
|
1069
|
+
signal: ctrl.signal
|
|
1070
|
+
});
|
|
1071
|
+
clearTimeout(timer);
|
|
587
1072
|
} catch {
|
|
588
|
-
return false;
|
|
589
1073
|
}
|
|
590
1074
|
}
|
|
591
1075
|
|
|
@@ -593,11 +1077,11 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
|
593
1077
|
import fs2 from "fs";
|
|
594
1078
|
import path2 from "path";
|
|
595
1079
|
import os2 from "os";
|
|
596
|
-
import
|
|
1080
|
+
import chalk3 from "chalk";
|
|
597
1081
|
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
598
1082
|
function printDaemonTip() {
|
|
599
1083
|
console.log(
|
|
600
|
-
|
|
1084
|
+
chalk3.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + chalk3.white("\n To view your history or manage persistent rules, run:") + chalk3.green("\n node9 daemon --openui")
|
|
601
1085
|
);
|
|
602
1086
|
}
|
|
603
1087
|
function fullPathCommand(subcommand) {
|
|
@@ -638,7 +1122,7 @@ async function setupClaude() {
|
|
|
638
1122
|
matcher: ".*",
|
|
639
1123
|
hooks: [{ type: "command", command: fullPathCommand("check"), timeout: 60 }]
|
|
640
1124
|
});
|
|
641
|
-
console.log(
|
|
1125
|
+
console.log(chalk3.green(" \u2705 PreToolUse hook added \u2192 node9 check"));
|
|
642
1126
|
anythingChanged = true;
|
|
643
1127
|
}
|
|
644
1128
|
const hasPostHook = settings.hooks.PostToolUse?.some(
|
|
@@ -648,9 +1132,9 @@ async function setupClaude() {
|
|
|
648
1132
|
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
649
1133
|
settings.hooks.PostToolUse.push({
|
|
650
1134
|
matcher: ".*",
|
|
651
|
-
hooks: [{ type: "command", command: fullPathCommand("log") }]
|
|
1135
|
+
hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
|
|
652
1136
|
});
|
|
653
|
-
console.log(
|
|
1137
|
+
console.log(chalk3.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
|
|
654
1138
|
anythingChanged = true;
|
|
655
1139
|
}
|
|
656
1140
|
if (anythingChanged) {
|
|
@@ -664,10 +1148,10 @@ async function setupClaude() {
|
|
|
664
1148
|
serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
|
|
665
1149
|
}
|
|
666
1150
|
if (serversToWrap.length > 0) {
|
|
667
|
-
console.log(
|
|
668
|
-
console.log(
|
|
1151
|
+
console.log(chalk3.bold("The following existing entries will be modified:\n"));
|
|
1152
|
+
console.log(chalk3.white(` ${mcpPath}`));
|
|
669
1153
|
for (const { name, originalCmd } of serversToWrap) {
|
|
670
|
-
console.log(
|
|
1154
|
+
console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
|
|
671
1155
|
}
|
|
672
1156
|
console.log("");
|
|
673
1157
|
const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
|
|
@@ -677,22 +1161,22 @@ async function setupClaude() {
|
|
|
677
1161
|
}
|
|
678
1162
|
claudeConfig.mcpServers = servers;
|
|
679
1163
|
writeJson(mcpPath, claudeConfig);
|
|
680
|
-
console.log(
|
|
1164
|
+
console.log(chalk3.green(`
|
|
681
1165
|
\u2705 ${serversToWrap.length} MCP server(s) wrapped`));
|
|
682
1166
|
anythingChanged = true;
|
|
683
1167
|
} else {
|
|
684
|
-
console.log(
|
|
1168
|
+
console.log(chalk3.yellow(" Skipped MCP server wrapping."));
|
|
685
1169
|
}
|
|
686
1170
|
console.log("");
|
|
687
1171
|
}
|
|
688
1172
|
if (!anythingChanged && serversToWrap.length === 0) {
|
|
689
|
-
console.log(
|
|
1173
|
+
console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Claude Code."));
|
|
690
1174
|
printDaemonTip();
|
|
691
1175
|
return;
|
|
692
1176
|
}
|
|
693
1177
|
if (anythingChanged) {
|
|
694
|
-
console.log(
|
|
695
|
-
console.log(
|
|
1178
|
+
console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Claude Code!"));
|
|
1179
|
+
console.log(chalk3.gray(" Restart Claude Code for changes to take effect."));
|
|
696
1180
|
printDaemonTip();
|
|
697
1181
|
}
|
|
698
1182
|
}
|
|
@@ -712,10 +1196,15 @@ async function setupGemini() {
|
|
|
712
1196
|
settings.hooks.BeforeTool.push({
|
|
713
1197
|
matcher: ".*",
|
|
714
1198
|
hooks: [
|
|
715
|
-
{
|
|
1199
|
+
{
|
|
1200
|
+
name: "node9-check",
|
|
1201
|
+
type: "command",
|
|
1202
|
+
command: fullPathCommand("check"),
|
|
1203
|
+
timeout: 6e5
|
|
1204
|
+
}
|
|
716
1205
|
]
|
|
717
1206
|
});
|
|
718
|
-
console.log(
|
|
1207
|
+
console.log(chalk3.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
|
|
719
1208
|
anythingChanged = true;
|
|
720
1209
|
}
|
|
721
1210
|
const hasAfterHook = Array.isArray(settings.hooks.AfterTool) && settings.hooks.AfterTool.some(
|
|
@@ -728,7 +1217,7 @@ async function setupGemini() {
|
|
|
728
1217
|
matcher: ".*",
|
|
729
1218
|
hooks: [{ name: "node9-log", type: "command", command: fullPathCommand("log") }]
|
|
730
1219
|
});
|
|
731
|
-
console.log(
|
|
1220
|
+
console.log(chalk3.green(" \u2705 AfterTool hook added \u2192 node9 log"));
|
|
732
1221
|
anythingChanged = true;
|
|
733
1222
|
}
|
|
734
1223
|
if (anythingChanged) {
|
|
@@ -742,10 +1231,10 @@ async function setupGemini() {
|
|
|
742
1231
|
serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
|
|
743
1232
|
}
|
|
744
1233
|
if (serversToWrap.length > 0) {
|
|
745
|
-
console.log(
|
|
746
|
-
console.log(
|
|
1234
|
+
console.log(chalk3.bold("The following existing entries will be modified:\n"));
|
|
1235
|
+
console.log(chalk3.white(` ${settingsPath} (mcpServers)`));
|
|
747
1236
|
for (const { name, originalCmd } of serversToWrap) {
|
|
748
|
-
console.log(
|
|
1237
|
+
console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
|
|
749
1238
|
}
|
|
750
1239
|
console.log("");
|
|
751
1240
|
const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
|
|
@@ -755,22 +1244,22 @@ async function setupGemini() {
|
|
|
755
1244
|
}
|
|
756
1245
|
settings.mcpServers = servers;
|
|
757
1246
|
writeJson(settingsPath, settings);
|
|
758
|
-
console.log(
|
|
1247
|
+
console.log(chalk3.green(`
|
|
759
1248
|
\u2705 ${serversToWrap.length} MCP server(s) wrapped`));
|
|
760
1249
|
anythingChanged = true;
|
|
761
1250
|
} else {
|
|
762
|
-
console.log(
|
|
1251
|
+
console.log(chalk3.yellow(" Skipped MCP server wrapping."));
|
|
763
1252
|
}
|
|
764
1253
|
console.log("");
|
|
765
1254
|
}
|
|
766
1255
|
if (!anythingChanged && serversToWrap.length === 0) {
|
|
767
|
-
console.log(
|
|
1256
|
+
console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Gemini CLI."));
|
|
768
1257
|
printDaemonTip();
|
|
769
1258
|
return;
|
|
770
1259
|
}
|
|
771
1260
|
if (anythingChanged) {
|
|
772
|
-
console.log(
|
|
773
|
-
console.log(
|
|
1261
|
+
console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Gemini CLI!"));
|
|
1262
|
+
console.log(chalk3.gray(" Restart Gemini CLI for changes to take effect."));
|
|
774
1263
|
printDaemonTip();
|
|
775
1264
|
}
|
|
776
1265
|
}
|
|
@@ -789,7 +1278,7 @@ async function setupCursor() {
|
|
|
789
1278
|
if (!hasPreHook) {
|
|
790
1279
|
if (!hooksFile.hooks.preToolUse) hooksFile.hooks.preToolUse = [];
|
|
791
1280
|
hooksFile.hooks.preToolUse.push({ command: fullPathCommand("check") });
|
|
792
|
-
console.log(
|
|
1281
|
+
console.log(chalk3.green(" \u2705 preToolUse hook added \u2192 node9 check"));
|
|
793
1282
|
anythingChanged = true;
|
|
794
1283
|
}
|
|
795
1284
|
const hasPostHook = hooksFile.hooks.postToolUse?.some(
|
|
@@ -798,7 +1287,7 @@ async function setupCursor() {
|
|
|
798
1287
|
if (!hasPostHook) {
|
|
799
1288
|
if (!hooksFile.hooks.postToolUse) hooksFile.hooks.postToolUse = [];
|
|
800
1289
|
hooksFile.hooks.postToolUse.push({ command: fullPathCommand("log") });
|
|
801
|
-
console.log(
|
|
1290
|
+
console.log(chalk3.green(" \u2705 postToolUse hook added \u2192 node9 log"));
|
|
802
1291
|
anythingChanged = true;
|
|
803
1292
|
}
|
|
804
1293
|
if (anythingChanged) {
|
|
@@ -812,10 +1301,10 @@ async function setupCursor() {
|
|
|
812
1301
|
serversToWrap.push({ name, originalCmd: parts.join(" "), parts });
|
|
813
1302
|
}
|
|
814
1303
|
if (serversToWrap.length > 0) {
|
|
815
|
-
console.log(
|
|
816
|
-
console.log(
|
|
1304
|
+
console.log(chalk3.bold("The following existing entries will be modified:\n"));
|
|
1305
|
+
console.log(chalk3.white(` ${mcpPath}`));
|
|
817
1306
|
for (const { name, originalCmd } of serversToWrap) {
|
|
818
|
-
console.log(
|
|
1307
|
+
console.log(chalk3.gray(` \u2022 ${name}: "${originalCmd}" \u2192 node9 ${originalCmd}`));
|
|
819
1308
|
}
|
|
820
1309
|
console.log("");
|
|
821
1310
|
const proceed = await confirm2({ message: "Wrap these MCP servers?", default: true });
|
|
@@ -825,22 +1314,22 @@ async function setupCursor() {
|
|
|
825
1314
|
}
|
|
826
1315
|
mcpConfig.mcpServers = servers;
|
|
827
1316
|
writeJson(mcpPath, mcpConfig);
|
|
828
|
-
console.log(
|
|
1317
|
+
console.log(chalk3.green(`
|
|
829
1318
|
\u2705 ${serversToWrap.length} MCP server(s) wrapped`));
|
|
830
1319
|
anythingChanged = true;
|
|
831
1320
|
} else {
|
|
832
|
-
console.log(
|
|
1321
|
+
console.log(chalk3.yellow(" Skipped MCP server wrapping."));
|
|
833
1322
|
}
|
|
834
1323
|
console.log("");
|
|
835
1324
|
}
|
|
836
1325
|
if (!anythingChanged && serversToWrap.length === 0) {
|
|
837
|
-
console.log(
|
|
1326
|
+
console.log(chalk3.blue("\u2139\uFE0F Node9 is already fully configured for Cursor."));
|
|
838
1327
|
printDaemonTip();
|
|
839
1328
|
return;
|
|
840
1329
|
}
|
|
841
1330
|
if (anythingChanged) {
|
|
842
|
-
console.log(
|
|
843
|
-
console.log(
|
|
1331
|
+
console.log(chalk3.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Cursor!"));
|
|
1332
|
+
console.log(chalk3.gray(" Restart Cursor for changes to take effect."));
|
|
844
1333
|
printDaemonTip();
|
|
845
1334
|
}
|
|
846
1335
|
}
|
|
@@ -1109,6 +1598,27 @@ var ui_default = `<!doctype html>
|
|
|
1109
1598
|
font-size: 12px;
|
|
1110
1599
|
font-weight: 500;
|
|
1111
1600
|
}
|
|
1601
|
+
.btn-trust {
|
|
1602
|
+
background: rgba(240, 136, 62, 0.1);
|
|
1603
|
+
border: 1px solid rgba(240, 136, 62, 0.35);
|
|
1604
|
+
color: var(--primary);
|
|
1605
|
+
font-size: 12px;
|
|
1606
|
+
font-weight: 600;
|
|
1607
|
+
}
|
|
1608
|
+
.btn-trust:hover:not(:disabled) {
|
|
1609
|
+
background: rgba(240, 136, 62, 0.2);
|
|
1610
|
+
filter: none;
|
|
1611
|
+
transform: translateY(-1px);
|
|
1612
|
+
}
|
|
1613
|
+
.trust-row {
|
|
1614
|
+
display: none;
|
|
1615
|
+
grid-column: span 2;
|
|
1616
|
+
grid-template-columns: 1fr 1fr;
|
|
1617
|
+
gap: 8px;
|
|
1618
|
+
}
|
|
1619
|
+
.trust-row.show {
|
|
1620
|
+
display: grid;
|
|
1621
|
+
}
|
|
1112
1622
|
button:hover:not(:disabled) {
|
|
1113
1623
|
filter: brightness(1.15);
|
|
1114
1624
|
transform: translateY(-1px);
|
|
@@ -1412,15 +1922,31 @@ var ui_default = `<!doctype html>
|
|
|
1412
1922
|
<span class="slider"></span>
|
|
1413
1923
|
</label>
|
|
1414
1924
|
</div>
|
|
1925
|
+
<div class="setting-row">
|
|
1926
|
+
<div class="setting-text">
|
|
1927
|
+
<div class="setting-label">Trust Sessions</div>
|
|
1928
|
+
<div class="setting-desc">
|
|
1929
|
+
Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
|
|
1930
|
+
</div>
|
|
1931
|
+
</div>
|
|
1932
|
+
<label class="toggle">
|
|
1933
|
+
<input
|
|
1934
|
+
type="checkbox"
|
|
1935
|
+
id="trustSessionsToggle"
|
|
1936
|
+
onchange="onTrustToggle(this.checked)"
|
|
1937
|
+
/>
|
|
1938
|
+
<span class="slider"></span>
|
|
1939
|
+
</label>
|
|
1940
|
+
</div>
|
|
1415
1941
|
</div>
|
|
1416
1942
|
|
|
1417
1943
|
<div class="panel">
|
|
1418
|
-
<div class="panel-title">\u{1F4AC}
|
|
1944
|
+
<div class="panel-title">\u{1F4AC} Cloud Approvals</div>
|
|
1419
1945
|
<div class="setting-row">
|
|
1420
1946
|
<div class="setting-text">
|
|
1421
|
-
<div class="setting-label">Enable
|
|
1947
|
+
<div class="setting-label">Enable Cloud</div>
|
|
1422
1948
|
<div class="setting-desc">
|
|
1423
|
-
Use Slack as the approval authority when a key is saved.
|
|
1949
|
+
Use Cloud/Slack as the approval authority when a key is saved.
|
|
1424
1950
|
</div>
|
|
1425
1951
|
</div>
|
|
1426
1952
|
<label class="toggle">
|
|
@@ -1474,6 +2000,7 @@ var ui_default = `<!doctype html>
|
|
|
1474
2000
|
const requests = new Set();
|
|
1475
2001
|
let orgName = null;
|
|
1476
2002
|
let autoDenyMs = 120000;
|
|
2003
|
+
let trustEnabled = false;
|
|
1477
2004
|
|
|
1478
2005
|
function highlightSyntax(code) {
|
|
1479
2006
|
if (typeof code !== 'string') return esc(code);
|
|
@@ -1526,6 +2053,21 @@ var ui_default = `<!doctype html>
|
|
|
1526
2053
|
}, 200);
|
|
1527
2054
|
}
|
|
1528
2055
|
|
|
2056
|
+
function sendTrust(id, duration) {
|
|
2057
|
+
const card = document.getElementById('c-' + id);
|
|
2058
|
+
if (card) card.style.opacity = '0.5';
|
|
2059
|
+
fetch('/decision/' + id, {
|
|
2060
|
+
method: 'POST',
|
|
2061
|
+
headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
|
|
2062
|
+
body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
|
|
2063
|
+
});
|
|
2064
|
+
setTimeout(() => {
|
|
2065
|
+
card?.remove();
|
|
2066
|
+
requests.delete(id);
|
|
2067
|
+
refresh();
|
|
2068
|
+
}, 200);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1529
2071
|
function addCard(req) {
|
|
1530
2072
|
if (requests.has(req.id)) return;
|
|
1531
2073
|
requests.add(req.id);
|
|
@@ -1545,6 +2087,7 @@ var ui_default = `<!doctype html>
|
|
|
1545
2087
|
card.id = 'c-' + req.id;
|
|
1546
2088
|
const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
|
|
1547
2089
|
const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
|
|
2090
|
+
const dis = isSlack ? 'disabled' : '';
|
|
1548
2091
|
card.innerHTML = \`
|
|
1549
2092
|
<div class="source-row">
|
|
1550
2093
|
<span class="agent-badge">\${agentLabel}</span>
|
|
@@ -1554,11 +2097,15 @@ var ui_default = `<!doctype html>
|
|
|
1554
2097
|
\${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
|
|
1555
2098
|
<span class="label">Input Payload</span>
|
|
1556
2099
|
<pre>\${cmd}</pre>
|
|
1557
|
-
<div class="actions">
|
|
1558
|
-
<button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${
|
|
1559
|
-
<button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${
|
|
1560
|
-
<
|
|
1561
|
-
|
|
2100
|
+
<div class="actions" id="act-\${req.id}">
|
|
2101
|
+
<button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
|
|
2102
|
+
<button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
|
|
2103
|
+
<div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
|
|
2104
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
|
|
2105
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
|
|
2106
|
+
</div>
|
|
2107
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
|
|
2108
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
|
|
1562
2109
|
</div>
|
|
1563
2110
|
\`;
|
|
1564
2111
|
list.appendChild(card);
|
|
@@ -1590,8 +2137,10 @@ var ui_default = `<!doctype html>
|
|
|
1590
2137
|
autoDenyMs = data.autoDenyMs;
|
|
1591
2138
|
if (orgName) {
|
|
1592
2139
|
const b = document.getElementById('cloudBadge');
|
|
1593
|
-
b
|
|
1594
|
-
|
|
2140
|
+
if (b) {
|
|
2141
|
+
b.innerText = orgName;
|
|
2142
|
+
b.classList.add('online');
|
|
2143
|
+
}
|
|
1595
2144
|
}
|
|
1596
2145
|
data.requests.forEach(addCard);
|
|
1597
2146
|
});
|
|
@@ -1621,6 +2170,14 @@ var ui_default = `<!doctype html>
|
|
|
1621
2170
|
}).catch(() => {});
|
|
1622
2171
|
}
|
|
1623
2172
|
|
|
2173
|
+
function onTrustToggle(checked) {
|
|
2174
|
+
trustEnabled = checked;
|
|
2175
|
+
saveSetting('enableTrustSessions', checked);
|
|
2176
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2177
|
+
el.classList.toggle('show', checked);
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1624
2181
|
fetch('/settings')
|
|
1625
2182
|
.then((r) => r.json())
|
|
1626
2183
|
.then((s) => {
|
|
@@ -1633,6 +2190,13 @@ var ui_default = `<!doctype html>
|
|
|
1633
2190
|
if (!s.autoStartDaemon && !s.autoStarted) {
|
|
1634
2191
|
document.getElementById('warnBanner').classList.add('show');
|
|
1635
2192
|
}
|
|
2193
|
+
trustEnabled = !!s.enableTrustSessions;
|
|
2194
|
+
const trustTog = document.getElementById('trustSessionsToggle');
|
|
2195
|
+
if (trustTog) trustTog.checked = trustEnabled;
|
|
2196
|
+
// Show/hide trust rows on any cards already rendered
|
|
2197
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2198
|
+
el.classList.toggle('show', trustEnabled);
|
|
2199
|
+
});
|
|
1636
2200
|
})
|
|
1637
2201
|
.catch(() => {});
|
|
1638
2202
|
|
|
@@ -1742,9 +2306,9 @@ import http from "http";
|
|
|
1742
2306
|
import fs3 from "fs";
|
|
1743
2307
|
import path3 from "path";
|
|
1744
2308
|
import os3 from "os";
|
|
1745
|
-
import {
|
|
2309
|
+
import { spawn as spawn2 } from "child_process";
|
|
1746
2310
|
import { randomUUID } from "crypto";
|
|
1747
|
-
import
|
|
2311
|
+
import chalk4 from "chalk";
|
|
1748
2312
|
var DAEMON_PORT2 = 7391;
|
|
1749
2313
|
var DAEMON_HOST2 = "127.0.0.1";
|
|
1750
2314
|
var homeDir = os3.homedir();
|
|
@@ -1753,6 +2317,33 @@ var DECISIONS_FILE = path3.join(homeDir, ".node9", "decisions.json");
|
|
|
1753
2317
|
var GLOBAL_CONFIG_FILE = path3.join(homeDir, ".node9", "config.json");
|
|
1754
2318
|
var CREDENTIALS_FILE = path3.join(homeDir, ".node9", "credentials.json");
|
|
1755
2319
|
var AUDIT_LOG_FILE = path3.join(homeDir, ".node9", "audit.log");
|
|
2320
|
+
var TRUST_FILE2 = path3.join(homeDir, ".node9", "trust.json");
|
|
2321
|
+
function atomicWriteSync2(filePath, data, options) {
|
|
2322
|
+
const dir = path3.dirname(filePath);
|
|
2323
|
+
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
2324
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
2325
|
+
fs3.writeFileSync(tmpPath, data, options);
|
|
2326
|
+
fs3.renameSync(tmpPath, filePath);
|
|
2327
|
+
}
|
|
2328
|
+
function writeTrustEntry(toolName, durationMs) {
|
|
2329
|
+
try {
|
|
2330
|
+
let trust = { entries: [] };
|
|
2331
|
+
try {
|
|
2332
|
+
if (fs3.existsSync(TRUST_FILE2))
|
|
2333
|
+
trust = JSON.parse(fs3.readFileSync(TRUST_FILE2, "utf-8"));
|
|
2334
|
+
} catch {
|
|
2335
|
+
}
|
|
2336
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
|
|
2337
|
+
trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
|
|
2338
|
+
atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
|
|
2339
|
+
} catch {
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
var TRUST_DURATIONS = {
|
|
2343
|
+
"30m": 30 * 6e4,
|
|
2344
|
+
"1h": 60 * 6e4,
|
|
2345
|
+
"2h": 2 * 60 * 6e4
|
|
2346
|
+
};
|
|
1756
2347
|
var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
|
|
1757
2348
|
function redactArgs(value) {
|
|
1758
2349
|
if (!value || typeof value !== "object") return value;
|
|
@@ -1765,10 +2356,16 @@ function redactArgs(value) {
|
|
|
1765
2356
|
}
|
|
1766
2357
|
function appendAuditLog(data) {
|
|
1767
2358
|
try {
|
|
1768
|
-
const entry =
|
|
2359
|
+
const entry = {
|
|
2360
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2361
|
+
tool: data.toolName,
|
|
2362
|
+
args: redactArgs(data.args),
|
|
2363
|
+
decision: data.decision,
|
|
2364
|
+
source: "daemon"
|
|
2365
|
+
};
|
|
1769
2366
|
const dir = path3.dirname(AUDIT_LOG_FILE);
|
|
1770
2367
|
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
1771
|
-
fs3.appendFileSync(AUDIT_LOG_FILE, entry);
|
|
2368
|
+
fs3.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
|
|
1772
2369
|
} catch {
|
|
1773
2370
|
}
|
|
1774
2371
|
}
|
|
@@ -1793,21 +2390,6 @@ function getOrgName() {
|
|
|
1793
2390
|
return null;
|
|
1794
2391
|
}
|
|
1795
2392
|
var autoStarted = process.env.NODE9_AUTO_STARTED === "1";
|
|
1796
|
-
function readGlobalSettings() {
|
|
1797
|
-
try {
|
|
1798
|
-
if (fs3.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
1799
|
-
const config = JSON.parse(fs3.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
|
|
1800
|
-
const s = config?.settings ?? {};
|
|
1801
|
-
return {
|
|
1802
|
-
autoStartDaemon: s.autoStartDaemon !== false,
|
|
1803
|
-
slackEnabled: s.slackEnabled !== false,
|
|
1804
|
-
agentMode: s.agentMode === true
|
|
1805
|
-
};
|
|
1806
|
-
}
|
|
1807
|
-
} catch {
|
|
1808
|
-
}
|
|
1809
|
-
return { autoStartDaemon: true, slackEnabled: true, agentMode: false };
|
|
1810
|
-
}
|
|
1811
2393
|
function hasStoredSlackKey() {
|
|
1812
2394
|
return fs3.existsSync(CREDENTIALS_FILE);
|
|
1813
2395
|
}
|
|
@@ -1821,14 +2403,13 @@ function writeGlobalSetting(key, value) {
|
|
|
1821
2403
|
}
|
|
1822
2404
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
1823
2405
|
config.settings[key] = value;
|
|
1824
|
-
|
|
1825
|
-
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
1826
|
-
fs3.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2406
|
+
atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
1827
2407
|
}
|
|
1828
2408
|
var pending = /* @__PURE__ */ new Map();
|
|
1829
2409
|
var sseClients = /* @__PURE__ */ new Set();
|
|
1830
2410
|
var abandonTimer = null;
|
|
1831
2411
|
var daemonServer = null;
|
|
2412
|
+
var hadBrowserClient = false;
|
|
1832
2413
|
function abandonPending() {
|
|
1833
2414
|
abandonTimer = null;
|
|
1834
2415
|
pending.forEach((entry, id) => {
|
|
@@ -1864,10 +2445,8 @@ data: ${JSON.stringify(data)}
|
|
|
1864
2445
|
}
|
|
1865
2446
|
function openBrowser(url) {
|
|
1866
2447
|
try {
|
|
1867
|
-
const
|
|
1868
|
-
|
|
1869
|
-
else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
|
|
1870
|
-
else execSync(`xdg-open "${url}"`, opts);
|
|
2448
|
+
const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
2449
|
+
spawn2(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
|
|
1871
2450
|
} catch {
|
|
1872
2451
|
}
|
|
1873
2452
|
}
|
|
@@ -1889,11 +2468,9 @@ function readPersistentDecisions() {
|
|
|
1889
2468
|
}
|
|
1890
2469
|
function writePersistentDecision(toolName, decision) {
|
|
1891
2470
|
try {
|
|
1892
|
-
const dir = path3.dirname(DECISIONS_FILE);
|
|
1893
|
-
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
1894
2471
|
const decisions = readPersistentDecisions();
|
|
1895
2472
|
decisions[toolName] = decision;
|
|
1896
|
-
|
|
2473
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
1897
2474
|
broadcast("decisions", decisions);
|
|
1898
2475
|
} catch {
|
|
1899
2476
|
}
|
|
@@ -1903,6 +2480,22 @@ function startDaemon() {
|
|
|
1903
2480
|
const internalToken = randomUUID();
|
|
1904
2481
|
const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
|
|
1905
2482
|
const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
|
|
2483
|
+
const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
|
|
2484
|
+
let idleTimer;
|
|
2485
|
+
function resetIdleTimer() {
|
|
2486
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2487
|
+
idleTimer = setTimeout(() => {
|
|
2488
|
+
if (autoStarted) {
|
|
2489
|
+
try {
|
|
2490
|
+
fs3.unlinkSync(DAEMON_PID_FILE);
|
|
2491
|
+
} catch {
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
process.exit(0);
|
|
2495
|
+
}, IDLE_TIMEOUT_MS);
|
|
2496
|
+
idleTimer.unref();
|
|
2497
|
+
}
|
|
2498
|
+
resetIdleTimer();
|
|
1906
2499
|
const server = http.createServer(async (req, res) => {
|
|
1907
2500
|
const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1908
2501
|
if (req.method === "GET" && pathname === "/") {
|
|
@@ -1919,6 +2512,7 @@ function startDaemon() {
|
|
|
1919
2512
|
clearTimeout(abandonTimer);
|
|
1920
2513
|
abandonTimer = null;
|
|
1921
2514
|
}
|
|
2515
|
+
hadBrowserClient = true;
|
|
1922
2516
|
sseClients.add(res);
|
|
1923
2517
|
res.write(
|
|
1924
2518
|
`event: init
|
|
@@ -1945,12 +2539,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1945
2539
|
return req.on("close", () => {
|
|
1946
2540
|
sseClients.delete(res);
|
|
1947
2541
|
if (sseClients.size === 0 && pending.size > 0) {
|
|
1948
|
-
abandonTimer = setTimeout(abandonPending,
|
|
2542
|
+
abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
|
|
1949
2543
|
}
|
|
1950
2544
|
});
|
|
1951
2545
|
}
|
|
1952
2546
|
if (req.method === "POST" && pathname === "/check") {
|
|
1953
2547
|
try {
|
|
2548
|
+
resetIdleTimer();
|
|
1954
2549
|
const body = await readBody(req);
|
|
1955
2550
|
if (body.length > 65536) return res.writeHead(413).end();
|
|
1956
2551
|
const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
|
|
@@ -1971,8 +2566,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1971
2566
|
appendAuditLog({
|
|
1972
2567
|
toolName: e.toolName,
|
|
1973
2568
|
args: e.args,
|
|
1974
|
-
decision: "auto-deny"
|
|
1975
|
-
timestamp: Date.now()
|
|
2569
|
+
decision: "auto-deny"
|
|
1976
2570
|
});
|
|
1977
2571
|
if (e.waiter) e.waiter("deny");
|
|
1978
2572
|
else e.earlyDecision = "deny";
|
|
@@ -1990,7 +2584,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1990
2584
|
agent: entry.agent,
|
|
1991
2585
|
mcpServer: entry.mcpServer
|
|
1992
2586
|
});
|
|
1993
|
-
if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
2587
|
+
if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
1994
2588
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1995
2589
|
return res.end(JSON.stringify({ id }));
|
|
1996
2590
|
} catch {
|
|
@@ -2017,17 +2611,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2017
2611
|
const id = pathname.split("/").pop();
|
|
2018
2612
|
const entry = pending.get(id);
|
|
2019
2613
|
if (!entry) return res.writeHead(404).end();
|
|
2020
|
-
const { decision, persist } = JSON.parse(await readBody(req));
|
|
2021
|
-
if (
|
|
2614
|
+
const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
|
|
2615
|
+
if (decision === "trust" && trustDuration) {
|
|
2616
|
+
const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
|
|
2617
|
+
writeTrustEntry(entry.toolName, ms);
|
|
2618
|
+
appendAuditLog({
|
|
2619
|
+
toolName: entry.toolName,
|
|
2620
|
+
args: entry.args,
|
|
2621
|
+
decision: `trust:${trustDuration}`
|
|
2622
|
+
});
|
|
2623
|
+
clearTimeout(entry.timer);
|
|
2624
|
+
if (entry.waiter) entry.waiter("allow");
|
|
2625
|
+
else entry.earlyDecision = "allow";
|
|
2626
|
+
pending.delete(id);
|
|
2627
|
+
broadcast("remove", { id });
|
|
2628
|
+
res.writeHead(200);
|
|
2629
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
2630
|
+
}
|
|
2631
|
+
const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
|
|
2632
|
+
if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
|
|
2022
2633
|
appendAuditLog({
|
|
2023
2634
|
toolName: entry.toolName,
|
|
2024
2635
|
args: entry.args,
|
|
2025
|
-
decision
|
|
2026
|
-
timestamp: Date.now()
|
|
2636
|
+
decision: resolvedDecision
|
|
2027
2637
|
});
|
|
2028
2638
|
clearTimeout(entry.timer);
|
|
2029
|
-
if (entry.waiter) entry.waiter(
|
|
2030
|
-
else entry.earlyDecision =
|
|
2639
|
+
if (entry.waiter) entry.waiter(resolvedDecision);
|
|
2640
|
+
else entry.earlyDecision = resolvedDecision;
|
|
2031
2641
|
pending.delete(id);
|
|
2032
2642
|
broadcast("remove", { id });
|
|
2033
2643
|
res.writeHead(200);
|
|
@@ -2037,7 +2647,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2037
2647
|
}
|
|
2038
2648
|
}
|
|
2039
2649
|
if (req.method === "GET" && pathname === "/settings") {
|
|
2040
|
-
const s =
|
|
2650
|
+
const s = getGlobalSettings();
|
|
2041
2651
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2042
2652
|
return res.end(JSON.stringify({ ...s, autoStarted }));
|
|
2043
2653
|
}
|
|
@@ -2049,7 +2659,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2049
2659
|
if (data.autoStartDaemon !== void 0)
|
|
2050
2660
|
writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
|
|
2051
2661
|
if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
|
|
2052
|
-
if (data.
|
|
2662
|
+
if (data.enableTrustSessions !== void 0)
|
|
2663
|
+
writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
|
|
2664
|
+
if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
|
|
2665
|
+
if (data.enableHookLogDebug !== void 0)
|
|
2666
|
+
writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
|
|
2667
|
+
if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
|
|
2053
2668
|
res.writeHead(200);
|
|
2054
2669
|
return res.end(JSON.stringify({ ok: true }));
|
|
2055
2670
|
} catch {
|
|
@@ -2057,7 +2672,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2057
2672
|
}
|
|
2058
2673
|
}
|
|
2059
2674
|
if (req.method === "GET" && pathname === "/slack-status") {
|
|
2060
|
-
const s =
|
|
2675
|
+
const s = getGlobalSettings();
|
|
2061
2676
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2062
2677
|
return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
|
|
2063
2678
|
}
|
|
@@ -2065,14 +2680,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2065
2680
|
if (!validToken(req)) return res.writeHead(403).end();
|
|
2066
2681
|
try {
|
|
2067
2682
|
const { apiKey } = JSON.parse(await readBody(req));
|
|
2068
|
-
|
|
2069
|
-
fs3.mkdirSync(path3.dirname(CREDENTIALS_FILE), { recursive: true });
|
|
2070
|
-
fs3.writeFileSync(
|
|
2683
|
+
atomicWriteSync2(
|
|
2071
2684
|
CREDENTIALS_FILE,
|
|
2072
2685
|
JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
|
|
2073
2686
|
{ mode: 384 }
|
|
2074
2687
|
);
|
|
2075
|
-
broadcast("slack-status", { hasKey: true, enabled:
|
|
2688
|
+
broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
|
|
2076
2689
|
res.writeHead(200);
|
|
2077
2690
|
return res.end(JSON.stringify({ ok: true }));
|
|
2078
2691
|
} catch {
|
|
@@ -2085,7 +2698,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2085
2698
|
const toolName = decodeURIComponent(pathname.split("/").pop());
|
|
2086
2699
|
const decisions = readPersistentDecisions();
|
|
2087
2700
|
delete decisions[toolName];
|
|
2088
|
-
|
|
2701
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
2089
2702
|
broadcast("decisions", decisions);
|
|
2090
2703
|
res.writeHead(200);
|
|
2091
2704
|
return res.end(JSON.stringify({ ok: true }));
|
|
@@ -2104,8 +2717,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2104
2717
|
appendAuditLog({
|
|
2105
2718
|
toolName: entry.toolName,
|
|
2106
2719
|
args: entry.args,
|
|
2107
|
-
decision
|
|
2108
|
-
timestamp: Date.now()
|
|
2720
|
+
decision
|
|
2109
2721
|
});
|
|
2110
2722
|
clearTimeout(entry.timer);
|
|
2111
2723
|
if (entry.waiter) entry.waiter(decision);
|
|
@@ -2125,25 +2737,43 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2125
2737
|
res.writeHead(404).end();
|
|
2126
2738
|
});
|
|
2127
2739
|
daemonServer = server;
|
|
2740
|
+
server.on("error", (e) => {
|
|
2741
|
+
if (e.code === "EADDRINUSE") {
|
|
2742
|
+
try {
|
|
2743
|
+
if (fs3.existsSync(DAEMON_PID_FILE)) {
|
|
2744
|
+
const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
|
|
2745
|
+
process.kill(pid, 0);
|
|
2746
|
+
return process.exit(0);
|
|
2747
|
+
}
|
|
2748
|
+
} catch {
|
|
2749
|
+
try {
|
|
2750
|
+
fs3.unlinkSync(DAEMON_PID_FILE);
|
|
2751
|
+
} catch {
|
|
2752
|
+
}
|
|
2753
|
+
server.listen(DAEMON_PORT2, DAEMON_HOST2);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
console.error(chalk4.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
|
|
2758
|
+
process.exit(1);
|
|
2759
|
+
});
|
|
2128
2760
|
server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
|
|
2129
|
-
|
|
2130
|
-
fs3.mkdirSync(path3.dirname(DAEMON_PID_FILE), { recursive: true });
|
|
2131
|
-
fs3.writeFileSync(
|
|
2761
|
+
atomicWriteSync2(
|
|
2132
2762
|
DAEMON_PID_FILE,
|
|
2133
2763
|
JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
|
|
2134
2764
|
{ mode: 384 }
|
|
2135
2765
|
);
|
|
2136
|
-
console.log(
|
|
2766
|
+
console.log(chalk4.green(`\u{1F6E1}\uFE0F Node9 Guard LIVE: http://127.0.0.1:${DAEMON_PORT2}`));
|
|
2137
2767
|
});
|
|
2138
2768
|
}
|
|
2139
2769
|
function stopDaemon() {
|
|
2140
|
-
if (!fs3.existsSync(DAEMON_PID_FILE)) return console.log(
|
|
2770
|
+
if (!fs3.existsSync(DAEMON_PID_FILE)) return console.log(chalk4.yellow("Not running."));
|
|
2141
2771
|
try {
|
|
2142
2772
|
const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
|
|
2143
2773
|
process.kill(pid, "SIGTERM");
|
|
2144
|
-
console.log(
|
|
2774
|
+
console.log(chalk4.green("\u2705 Stopped."));
|
|
2145
2775
|
} catch {
|
|
2146
|
-
console.log(
|
|
2776
|
+
console.log(chalk4.gray("Cleaned up stale PID file."));
|
|
2147
2777
|
} finally {
|
|
2148
2778
|
try {
|
|
2149
2779
|
fs3.unlinkSync(DAEMON_PID_FILE);
|
|
@@ -2153,28 +2783,108 @@ function stopDaemon() {
|
|
|
2153
2783
|
}
|
|
2154
2784
|
function daemonStatus() {
|
|
2155
2785
|
if (!fs3.existsSync(DAEMON_PID_FILE))
|
|
2156
|
-
return console.log(
|
|
2786
|
+
return console.log(chalk4.yellow("Node9 daemon: not running"));
|
|
2157
2787
|
try {
|
|
2158
2788
|
const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
|
|
2159
2789
|
process.kill(pid, 0);
|
|
2160
|
-
console.log(
|
|
2790
|
+
console.log(chalk4.green("Node9 daemon: running"));
|
|
2161
2791
|
} catch {
|
|
2162
|
-
console.log(
|
|
2792
|
+
console.log(chalk4.yellow("Node9 daemon: not running (stale PID)"));
|
|
2163
2793
|
}
|
|
2164
2794
|
}
|
|
2165
2795
|
|
|
2166
2796
|
// src/cli.ts
|
|
2167
|
-
import { spawn, execSync
|
|
2797
|
+
import { spawn as spawn3, execSync } from "child_process";
|
|
2168
2798
|
import { parseCommandString } from "execa";
|
|
2169
2799
|
import { execa } from "execa";
|
|
2170
|
-
import
|
|
2800
|
+
import chalk5 from "chalk";
|
|
2171
2801
|
import readline from "readline";
|
|
2802
|
+
import fs5 from "fs";
|
|
2803
|
+
import path5 from "path";
|
|
2804
|
+
import os5 from "os";
|
|
2805
|
+
|
|
2806
|
+
// src/undo.ts
|
|
2807
|
+
import { spawnSync } from "child_process";
|
|
2172
2808
|
import fs4 from "fs";
|
|
2173
2809
|
import path4 from "path";
|
|
2174
2810
|
import os4 from "os";
|
|
2811
|
+
var UNDO_LATEST_PATH = path4.join(os4.homedir(), ".node9", "undo_latest.txt");
|
|
2812
|
+
async function createShadowSnapshot() {
|
|
2813
|
+
try {
|
|
2814
|
+
const cwd = process.cwd();
|
|
2815
|
+
if (!fs4.existsSync(path4.join(cwd, ".git"))) return null;
|
|
2816
|
+
const tempIndex = path4.join(cwd, ".git", `node9_index_${Date.now()}`);
|
|
2817
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
2818
|
+
spawnSync("git", ["add", "-A"], { env });
|
|
2819
|
+
const treeRes = spawnSync("git", ["write-tree"], { env });
|
|
2820
|
+
const treeHash = treeRes.stdout.toString().trim();
|
|
2821
|
+
if (fs4.existsSync(tempIndex)) fs4.unlinkSync(tempIndex);
|
|
2822
|
+
if (!treeHash || treeRes.status !== 0) return null;
|
|
2823
|
+
const commitRes = spawnSync("git", [
|
|
2824
|
+
"commit-tree",
|
|
2825
|
+
treeHash,
|
|
2826
|
+
"-m",
|
|
2827
|
+
`Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
|
|
2828
|
+
]);
|
|
2829
|
+
const commitHash = commitRes.stdout.toString().trim();
|
|
2830
|
+
if (commitHash && commitRes.status === 0) {
|
|
2831
|
+
const dir = path4.dirname(UNDO_LATEST_PATH);
|
|
2832
|
+
if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
|
|
2833
|
+
fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
|
|
2834
|
+
return commitHash;
|
|
2835
|
+
}
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
2838
|
+
console.error("[Node9 Undo Engine Error]:", err);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return null;
|
|
2842
|
+
}
|
|
2843
|
+
function applyUndo(hash) {
|
|
2844
|
+
try {
|
|
2845
|
+
const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
|
|
2846
|
+
if (restore.status !== 0) return false;
|
|
2847
|
+
const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash]);
|
|
2848
|
+
const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
|
|
2849
|
+
const tracked = spawnSync("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2850
|
+
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2851
|
+
for (const file of [...tracked, ...untracked]) {
|
|
2852
|
+
if (!snapshotFiles.has(file) && fs4.existsSync(file)) {
|
|
2853
|
+
fs4.unlinkSync(file);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
return true;
|
|
2857
|
+
} catch {
|
|
2858
|
+
return false;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
function getLatestSnapshotHash() {
|
|
2862
|
+
if (!fs4.existsSync(UNDO_LATEST_PATH)) return null;
|
|
2863
|
+
return fs4.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// src/cli.ts
|
|
2867
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2175
2868
|
var { version } = JSON.parse(
|
|
2176
|
-
|
|
2869
|
+
fs5.readFileSync(path5.join(__dirname, "../package.json"), "utf-8")
|
|
2177
2870
|
);
|
|
2871
|
+
function parseDuration(str) {
|
|
2872
|
+
const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
|
|
2873
|
+
if (!m) return null;
|
|
2874
|
+
const n = parseFloat(m[1]);
|
|
2875
|
+
switch ((m[2] ?? "m").toLowerCase()) {
|
|
2876
|
+
case "s":
|
|
2877
|
+
return Math.round(n * 1e3);
|
|
2878
|
+
case "m":
|
|
2879
|
+
return Math.round(n * 6e4);
|
|
2880
|
+
case "h":
|
|
2881
|
+
return Math.round(n * 36e5);
|
|
2882
|
+
case "d":
|
|
2883
|
+
return Math.round(n * 864e5);
|
|
2884
|
+
default:
|
|
2885
|
+
return null;
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2178
2888
|
function sanitize(value) {
|
|
2179
2889
|
return value.replace(/[\x00-\x1F\x7F]/g, "");
|
|
2180
2890
|
}
|
|
@@ -2182,23 +2892,33 @@ function openBrowserLocal() {
|
|
|
2182
2892
|
const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
|
|
2183
2893
|
try {
|
|
2184
2894
|
const opts = { stdio: "ignore" };
|
|
2185
|
-
if (process.platform === "darwin")
|
|
2186
|
-
else if (process.platform === "win32")
|
|
2187
|
-
else
|
|
2895
|
+
if (process.platform === "darwin") execSync(`open "${url}"`, opts);
|
|
2896
|
+
else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
|
|
2897
|
+
else execSync(`xdg-open "${url}"`, opts);
|
|
2188
2898
|
} catch {
|
|
2189
2899
|
}
|
|
2190
2900
|
}
|
|
2191
2901
|
async function autoStartDaemonAndWait() {
|
|
2192
2902
|
try {
|
|
2193
|
-
const child =
|
|
2903
|
+
const child = spawn3("node9", ["daemon"], {
|
|
2194
2904
|
detached: true,
|
|
2195
2905
|
stdio: "ignore",
|
|
2196
2906
|
env: { ...process.env, NODE9_AUTO_STARTED: "1" }
|
|
2197
2907
|
});
|
|
2198
2908
|
child.unref();
|
|
2199
|
-
for (let i = 0; i <
|
|
2909
|
+
for (let i = 0; i < 20; i++) {
|
|
2200
2910
|
await new Promise((r) => setTimeout(r, 250));
|
|
2201
|
-
if (isDaemonRunning())
|
|
2911
|
+
if (!isDaemonRunning()) continue;
|
|
2912
|
+
try {
|
|
2913
|
+
const res = await fetch("http://127.0.0.1:7391/settings", {
|
|
2914
|
+
signal: AbortSignal.timeout(500)
|
|
2915
|
+
});
|
|
2916
|
+
if (res.ok) {
|
|
2917
|
+
openBrowserLocal();
|
|
2918
|
+
return true;
|
|
2919
|
+
}
|
|
2920
|
+
} catch {
|
|
2921
|
+
}
|
|
2202
2922
|
}
|
|
2203
2923
|
} catch {
|
|
2204
2924
|
}
|
|
@@ -2216,48 +2936,72 @@ async function runProxy(targetCommand) {
|
|
|
2216
2936
|
if (stdout) executable = stdout.trim();
|
|
2217
2937
|
} catch {
|
|
2218
2938
|
}
|
|
2219
|
-
console.log(
|
|
2220
|
-
const child =
|
|
2939
|
+
console.log(chalk5.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
|
|
2940
|
+
const child = spawn3(executable, args, {
|
|
2221
2941
|
stdio: ["pipe", "pipe", "inherit"],
|
|
2942
|
+
// We control STDIN and STDOUT
|
|
2222
2943
|
shell: true,
|
|
2223
|
-
env: { ...process.env, FORCE_COLOR: "1"
|
|
2944
|
+
env: { ...process.env, FORCE_COLOR: "1" }
|
|
2224
2945
|
});
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2946
|
+
const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
|
|
2947
|
+
agentIn.on("line", async (line) => {
|
|
2948
|
+
let message;
|
|
2228
2949
|
try {
|
|
2229
|
-
|
|
2230
|
-
|
|
2950
|
+
message = JSON.parse(line);
|
|
2951
|
+
} catch {
|
|
2952
|
+
child.stdin.write(line + "\n");
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
|
|
2956
|
+
agentIn.pause();
|
|
2957
|
+
try {
|
|
2231
2958
|
const name = message.params?.name || message.params?.tool_name || "unknown";
|
|
2232
2959
|
const toolArgs = message.params?.arguments || message.params?.tool_input || {};
|
|
2233
|
-
const
|
|
2234
|
-
|
|
2960
|
+
const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
|
|
2961
|
+
agent: "Proxy/MCP"
|
|
2962
|
+
});
|
|
2963
|
+
if (!result.approved) {
|
|
2235
2964
|
const errorResponse = {
|
|
2236
2965
|
jsonrpc: "2.0",
|
|
2237
2966
|
id: message.id,
|
|
2238
|
-
error: {
|
|
2967
|
+
error: {
|
|
2968
|
+
code: -32e3,
|
|
2969
|
+
message: `Node9: Action denied. ${result.reason || ""}`
|
|
2970
|
+
}
|
|
2239
2971
|
};
|
|
2240
|
-
|
|
2972
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
2241
2973
|
return;
|
|
2242
2974
|
}
|
|
2975
|
+
} catch {
|
|
2976
|
+
const errorResponse = {
|
|
2977
|
+
jsonrpc: "2.0",
|
|
2978
|
+
id: message.id,
|
|
2979
|
+
error: {
|
|
2980
|
+
code: -32e3,
|
|
2981
|
+
message: `Node9: Security engine encountered an error. Action blocked for safety.`
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
2985
|
+
return;
|
|
2986
|
+
} finally {
|
|
2987
|
+
agentIn.resume();
|
|
2243
2988
|
}
|
|
2244
|
-
process.stdout.write(line + "\n");
|
|
2245
|
-
} catch {
|
|
2246
|
-
process.stdout.write(line + "\n");
|
|
2247
2989
|
}
|
|
2990
|
+
child.stdin.write(line + "\n");
|
|
2248
2991
|
});
|
|
2992
|
+
child.stdout.pipe(process.stdout);
|
|
2249
2993
|
child.on("exit", (code) => process.exit(code || 0));
|
|
2250
2994
|
}
|
|
2251
2995
|
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) => {
|
|
2252
2996
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
2253
|
-
const credPath =
|
|
2254
|
-
if (!
|
|
2255
|
-
|
|
2997
|
+
const credPath = path5.join(os5.homedir(), ".node9", "credentials.json");
|
|
2998
|
+
if (!fs5.existsSync(path5.dirname(credPath)))
|
|
2999
|
+
fs5.mkdirSync(path5.dirname(credPath), { recursive: true });
|
|
2256
3000
|
const profileName = options.profile || "default";
|
|
2257
3001
|
let existingCreds = {};
|
|
2258
3002
|
try {
|
|
2259
|
-
if (
|
|
2260
|
-
const raw = JSON.parse(
|
|
3003
|
+
if (fs5.existsSync(credPath)) {
|
|
3004
|
+
const raw = JSON.parse(fs5.readFileSync(credPath, "utf-8"));
|
|
2261
3005
|
if (raw.apiKey) {
|
|
2262
3006
|
existingCreds = {
|
|
2263
3007
|
default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
|
|
@@ -2269,60 +3013,65 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
|
|
|
2269
3013
|
} catch {
|
|
2270
3014
|
}
|
|
2271
3015
|
existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
|
|
2272
|
-
|
|
3016
|
+
fs5.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
|
|
2273
3017
|
if (profileName === "default") {
|
|
2274
|
-
const configPath =
|
|
3018
|
+
const configPath = path5.join(os5.homedir(), ".node9", "config.json");
|
|
2275
3019
|
let config = {};
|
|
2276
3020
|
try {
|
|
2277
|
-
if (
|
|
2278
|
-
config = JSON.parse(
|
|
3021
|
+
if (fs5.existsSync(configPath))
|
|
3022
|
+
config = JSON.parse(fs5.readFileSync(configPath, "utf-8"));
|
|
2279
3023
|
} catch {
|
|
2280
3024
|
}
|
|
2281
3025
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
3026
|
+
const s = config.settings;
|
|
3027
|
+
const approvers = s.approvers || {
|
|
3028
|
+
native: true,
|
|
3029
|
+
browser: true,
|
|
3030
|
+
cloud: true,
|
|
3031
|
+
terminal: true
|
|
3032
|
+
};
|
|
3033
|
+
approvers.cloud = !options.local;
|
|
3034
|
+
s.approvers = approvers;
|
|
3035
|
+
if (!fs5.existsSync(path5.dirname(configPath)))
|
|
3036
|
+
fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
|
|
3037
|
+
fs5.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2286
3038
|
}
|
|
2287
3039
|
if (options.profile && profileName !== "default") {
|
|
2288
|
-
console.log(
|
|
2289
|
-
console.log(
|
|
2290
|
-
console.log(
|
|
2291
|
-
chalk4.gray(
|
|
2292
|
-
` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
|
|
2293
|
-
)
|
|
2294
|
-
);
|
|
3040
|
+
console.log(chalk5.green(`\u2705 Profile "${profileName}" saved`));
|
|
3041
|
+
console.log(chalk5.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
|
|
2295
3042
|
} else if (options.local) {
|
|
2296
|
-
console.log(
|
|
2297
|
-
console.log(
|
|
2298
|
-
console.log(
|
|
2299
|
-
chalk4.gray(` No data is sent to the cloud. Local config is the only authority.`)
|
|
2300
|
-
);
|
|
2301
|
-
console.log(chalk4.gray(` To enable cloud enforcement: node9 login <apiKey>`));
|
|
3043
|
+
console.log(chalk5.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
|
|
3044
|
+
console.log(chalk5.gray(` All decisions stay on this machine.`));
|
|
2302
3045
|
} else {
|
|
2303
|
-
console.log(
|
|
2304
|
-
console.log(
|
|
2305
|
-
console.log(chalk4.gray(` To keep local control only: node9 login <apiKey> --local`));
|
|
3046
|
+
console.log(chalk5.green(`\u2705 Logged in \u2014 agent mode`));
|
|
3047
|
+
console.log(chalk5.gray(` Team policy enforced for all calls via Node9 cloud.`));
|
|
2306
3048
|
}
|
|
2307
3049
|
});
|
|
2308
3050
|
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) => {
|
|
2309
3051
|
if (target === "gemini") return await setupGemini();
|
|
2310
3052
|
if (target === "claude") return await setupClaude();
|
|
2311
3053
|
if (target === "cursor") return await setupCursor();
|
|
2312
|
-
console.error(
|
|
3054
|
+
console.error(chalk5.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
|
|
2313
3055
|
process.exit(1);
|
|
2314
3056
|
});
|
|
2315
3057
|
program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").action((options) => {
|
|
2316
|
-
const configPath =
|
|
2317
|
-
if (
|
|
2318
|
-
console.log(
|
|
2319
|
-
console.log(
|
|
3058
|
+
const configPath = path5.join(os5.homedir(), ".node9", "config.json");
|
|
3059
|
+
if (fs5.existsSync(configPath) && !options.force) {
|
|
3060
|
+
console.log(chalk5.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
|
|
3061
|
+
console.log(chalk5.gray(` Run with --force to overwrite.`));
|
|
2320
3062
|
return;
|
|
2321
3063
|
}
|
|
2322
3064
|
const defaultConfig = {
|
|
2323
3065
|
version: "1.0",
|
|
2324
|
-
settings: {
|
|
3066
|
+
settings: {
|
|
3067
|
+
mode: "standard",
|
|
3068
|
+
autoStartDaemon: true,
|
|
3069
|
+
enableUndo: true,
|
|
3070
|
+
enableHookLogDebug: false,
|
|
3071
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
3072
|
+
},
|
|
2325
3073
|
policy: {
|
|
3074
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
2326
3075
|
dangerousWords: DANGEROUS_WORDS,
|
|
2327
3076
|
ignoredTools: [
|
|
2328
3077
|
"list_*",
|
|
@@ -2332,161 +3081,136 @@ program.command("init").description("Create ~/.node9/config.json with default po
|
|
|
2332
3081
|
"read",
|
|
2333
3082
|
"write",
|
|
2334
3083
|
"edit",
|
|
2335
|
-
"multiedit",
|
|
2336
3084
|
"glob",
|
|
2337
3085
|
"grep",
|
|
2338
3086
|
"ls",
|
|
2339
3087
|
"notebookread",
|
|
2340
3088
|
"notebookedit",
|
|
2341
|
-
"todoread",
|
|
2342
|
-
"todowrite",
|
|
2343
3089
|
"webfetch",
|
|
2344
3090
|
"websearch",
|
|
2345
3091
|
"exitplanmode",
|
|
2346
|
-
"askuserquestion"
|
|
3092
|
+
"askuserquestion",
|
|
3093
|
+
"agent",
|
|
3094
|
+
"task*"
|
|
2347
3095
|
],
|
|
2348
3096
|
toolInspection: {
|
|
2349
3097
|
bash: "command",
|
|
2350
3098
|
shell: "command",
|
|
2351
3099
|
run_shell_command: "command",
|
|
2352
|
-
"terminal.execute": "command"
|
|
3100
|
+
"terminal.execute": "command",
|
|
3101
|
+
"postgres:query": "sql"
|
|
2353
3102
|
},
|
|
2354
3103
|
rules: [
|
|
2355
3104
|
{
|
|
2356
3105
|
action: "rm",
|
|
2357
|
-
allowPaths: [
|
|
3106
|
+
allowPaths: [
|
|
3107
|
+
"**/node_modules/**",
|
|
3108
|
+
"dist/**",
|
|
3109
|
+
"build/**",
|
|
3110
|
+
".next/**",
|
|
3111
|
+
"coverage/**",
|
|
3112
|
+
".cache/**",
|
|
3113
|
+
"tmp/**",
|
|
3114
|
+
"temp/**",
|
|
3115
|
+
".DS_Store"
|
|
3116
|
+
]
|
|
2358
3117
|
}
|
|
2359
3118
|
]
|
|
2360
3119
|
}
|
|
2361
3120
|
};
|
|
2362
|
-
if (!
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
console.log(
|
|
2366
|
-
console.log(
|
|
3121
|
+
if (!fs5.existsSync(path5.dirname(configPath)))
|
|
3122
|
+
fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
|
|
3123
|
+
fs5.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
3124
|
+
console.log(chalk5.green(`\u2705 Global config created: ${configPath}`));
|
|
3125
|
+
console.log(chalk5.gray(` Edit this file to add custom tool inspection or security rules.`));
|
|
2367
3126
|
});
|
|
2368
3127
|
program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
|
|
2369
3128
|
const creds = getCredentials();
|
|
2370
3129
|
const daemonRunning = isDaemonRunning();
|
|
2371
|
-
const
|
|
3130
|
+
const mergedConfig = getConfig();
|
|
3131
|
+
const settings = mergedConfig.settings;
|
|
2372
3132
|
console.log("");
|
|
2373
|
-
if (creds && settings.
|
|
2374
|
-
console.log(
|
|
2375
|
-
|
|
2376
|
-
console.log(chalk4.gray(" Switch to local control: node9 login <apiKey> --local"));
|
|
2377
|
-
} else if (creds && !settings.agentMode) {
|
|
3133
|
+
if (creds && settings.approvers.cloud) {
|
|
3134
|
+
console.log(chalk5.green(" \u25CF Agent mode") + chalk5.gray(" \u2014 cloud team policy enforced"));
|
|
3135
|
+
} else if (creds && !settings.approvers.cloud) {
|
|
2378
3136
|
console.log(
|
|
2379
|
-
|
|
3137
|
+
chalk5.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + chalk5.gray(" \u2014 all decisions stay on this machine")
|
|
2380
3138
|
);
|
|
3139
|
+
} else {
|
|
2381
3140
|
console.log(
|
|
2382
|
-
|
|
3141
|
+
chalk5.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk5.gray(" \u2014 no API key (Local rules only)")
|
|
2383
3142
|
);
|
|
2384
|
-
console.log(chalk4.gray(" Enable cloud enforcement: node9 login <apiKey>"));
|
|
2385
|
-
} else {
|
|
2386
|
-
console.log(chalk4.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 no API key"));
|
|
2387
|
-
console.log(chalk4.gray(" All decisions stay on this machine."));
|
|
2388
|
-
console.log(chalk4.gray(" Connect to your team: node9 login <apiKey>"));
|
|
2389
3143
|
}
|
|
2390
3144
|
console.log("");
|
|
2391
3145
|
if (daemonRunning) {
|
|
2392
3146
|
console.log(
|
|
2393
|
-
|
|
3147
|
+
chalk5.green(" \u25CF Daemon running") + chalk5.gray(` \u2192 http://127.0.0.1:${DAEMON_PORT2}/`)
|
|
2394
3148
|
);
|
|
2395
3149
|
} else {
|
|
2396
|
-
console.log(
|
|
2397
|
-
console.log(chalk4.gray(" Start: node9 daemon --background"));
|
|
3150
|
+
console.log(chalk5.gray(" \u25CB Daemon stopped"));
|
|
2398
3151
|
}
|
|
2399
|
-
|
|
2400
|
-
console.log(` Mode: ${chalk4.white(settings.mode)}`);
|
|
2401
|
-
const projectConfig = path4.join(process.cwd(), "node9.config.json");
|
|
2402
|
-
const globalConfig = path4.join(os4.homedir(), ".node9", "config.json");
|
|
2403
|
-
const configSource = fs4.existsSync(projectConfig) ? projectConfig : fs4.existsSync(globalConfig) ? globalConfig : chalk4.gray("none (built-in defaults)");
|
|
2404
|
-
console.log(` Config: ${chalk4.gray(configSource)}`);
|
|
2405
|
-
const profiles = listCredentialProfiles();
|
|
2406
|
-
if (profiles.length > 1) {
|
|
2407
|
-
const activeProfile = process.env.NODE9_PROFILE || "default";
|
|
2408
|
-
console.log("");
|
|
2409
|
-
console.log(` Active profile: ${chalk4.white(activeProfile)}`);
|
|
3152
|
+
if (settings.enableUndo) {
|
|
2410
3153
|
console.log(
|
|
2411
|
-
|
|
3154
|
+
chalk5.magenta(" \u25CF Undo Engine") + chalk5.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
|
|
2412
3155
|
);
|
|
2413
|
-
console.log(chalk4.gray(` Switch: NODE9_PROFILE=<name> claude`));
|
|
2414
3156
|
}
|
|
2415
|
-
const decisionsFile = path4.join(os4.homedir(), ".node9", "decisions.json");
|
|
2416
|
-
let decisions = {};
|
|
2417
|
-
try {
|
|
2418
|
-
if (fs4.existsSync(decisionsFile))
|
|
2419
|
-
decisions = JSON.parse(fs4.readFileSync(decisionsFile, "utf-8"));
|
|
2420
|
-
} catch {
|
|
2421
|
-
}
|
|
2422
|
-
const keys = Object.keys(decisions);
|
|
2423
3157
|
console.log("");
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
3158
|
+
const modeLabel = settings.mode === "audit" ? chalk5.blue("audit") : settings.mode === "strict" ? chalk5.red("strict") : chalk5.white("standard");
|
|
3159
|
+
console.log(` Mode: ${modeLabel}`);
|
|
3160
|
+
const projectConfig = path5.join(process.cwd(), "node9.config.json");
|
|
3161
|
+
const globalConfig = path5.join(os5.homedir(), ".node9", "config.json");
|
|
3162
|
+
console.log(
|
|
3163
|
+
` Local: ${fs5.existsSync(projectConfig) ? chalk5.green("Active (node9.config.json)") : chalk5.gray("Not present")}`
|
|
3164
|
+
);
|
|
3165
|
+
console.log(
|
|
3166
|
+
` Global: ${fs5.existsSync(globalConfig) ? chalk5.green("Active (~/.node9/config.json)") : chalk5.gray("Not present")}`
|
|
3167
|
+
);
|
|
3168
|
+
if (mergedConfig.policy.sandboxPaths.length > 0) {
|
|
3169
|
+
console.log(
|
|
3170
|
+
` Sandbox: ${chalk5.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
|
|
3171
|
+
);
|
|
2434
3172
|
}
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
);
|
|
2443
|
-
}
|
|
2444
|
-
} catch {
|
|
3173
|
+
const pauseState = checkPause();
|
|
3174
|
+
if (pauseState.paused) {
|
|
3175
|
+
const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
|
|
3176
|
+
console.log("");
|
|
3177
|
+
console.log(
|
|
3178
|
+
chalk5.yellow(` \u23F8 PAUSED until ${expiresAt}`) + chalk5.gray(" \u2014 all tool calls allowed")
|
|
3179
|
+
);
|
|
2445
3180
|
}
|
|
2446
3181
|
console.log("");
|
|
2447
3182
|
});
|
|
2448
|
-
program.command("daemon").description("Run the local approval server (
|
|
2449
|
-
"after",
|
|
2450
|
-
"\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"
|
|
2451
|
-
).argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option(
|
|
2452
|
-
"-o, --openui",
|
|
2453
|
-
"Start in background and open browser (or just open browser if already running)"
|
|
2454
|
-
).action(
|
|
3183
|
+
program.command("daemon").description("Run the local approval server").argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option("-o, --openui", "Start in background and open browser").action(
|
|
2455
3184
|
async (action, options) => {
|
|
2456
3185
|
const cmd = (action ?? "start").toLowerCase();
|
|
2457
3186
|
if (cmd === "stop") return stopDaemon();
|
|
2458
3187
|
if (cmd === "status") return daemonStatus();
|
|
2459
3188
|
if (cmd !== "start" && action !== void 0) {
|
|
2460
|
-
console.error(
|
|
3189
|
+
console.error(chalk5.red(`Unknown daemon action: "${action}". Use: start | stop | status`));
|
|
2461
3190
|
process.exit(1);
|
|
2462
3191
|
}
|
|
2463
3192
|
if (options.openui) {
|
|
2464
3193
|
if (isDaemonRunning()) {
|
|
2465
3194
|
openBrowserLocal();
|
|
2466
|
-
console.log(
|
|
3195
|
+
console.log(chalk5.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2467
3196
|
process.exit(0);
|
|
2468
3197
|
}
|
|
2469
|
-
const child =
|
|
3198
|
+
const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2470
3199
|
child.unref();
|
|
2471
3200
|
for (let i = 0; i < 12; i++) {
|
|
2472
3201
|
await new Promise((r) => setTimeout(r, 250));
|
|
2473
3202
|
if (isDaemonRunning()) break;
|
|
2474
3203
|
}
|
|
2475
3204
|
openBrowserLocal();
|
|
2476
|
-
console.log(
|
|
3205
|
+
console.log(chalk5.green(`
|
|
2477
3206
|
\u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
|
|
2478
|
-
console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2479
3207
|
process.exit(0);
|
|
2480
3208
|
}
|
|
2481
3209
|
if (options.background) {
|
|
2482
|
-
const child =
|
|
3210
|
+
const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2483
3211
|
child.unref();
|
|
2484
|
-
console.log(
|
|
3212
|
+
console.log(chalk5.green(`
|
|
2485
3213
|
\u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
|
|
2486
|
-
console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2487
|
-
console.log(chalk4.gray(` node9 daemon status \u2014 check if running`));
|
|
2488
|
-
console.log(chalk4.gray(` node9 daemon stop \u2014 stop it
|
|
2489
|
-
`));
|
|
2490
3214
|
process.exit(0);
|
|
2491
3215
|
}
|
|
2492
3216
|
startDaemon();
|
|
@@ -2496,53 +3220,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2496
3220
|
const processPayload = async (raw) => {
|
|
2497
3221
|
try {
|
|
2498
3222
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
3223
|
+
let payload = JSON.parse(raw);
|
|
3224
|
+
try {
|
|
3225
|
+
payload = JSON.parse(raw);
|
|
3226
|
+
} catch (err) {
|
|
3227
|
+
const tempConfig = getConfig();
|
|
3228
|
+
if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
|
|
3229
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
3230
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3231
|
+
fs5.appendFileSync(
|
|
3232
|
+
logPath,
|
|
3233
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
|
|
3234
|
+
RAW: ${raw}
|
|
2508
3235
|
`
|
|
2509
|
-
|
|
3236
|
+
);
|
|
3237
|
+
}
|
|
3238
|
+
process.exit(0);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (payload.cwd) {
|
|
3242
|
+
try {
|
|
3243
|
+
process.chdir(payload.cwd);
|
|
3244
|
+
_resetConfigCache();
|
|
3245
|
+
} catch {
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
const config = getConfig();
|
|
3249
|
+
if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
|
|
3250
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
3251
|
+
if (!fs5.existsSync(path5.dirname(logPath)))
|
|
3252
|
+
fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
|
|
3253
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
|
|
3254
|
+
`);
|
|
2510
3255
|
}
|
|
2511
|
-
const payload = JSON.parse(raw);
|
|
2512
3256
|
const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
|
|
2513
3257
|
const toolInput = payload.tool_input ?? payload.args ?? {};
|
|
2514
|
-
const agent = payload.
|
|
3258
|
+
const agent = payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : payload.tool_name !== void 0 || payload.name !== void 0 ? "Unknown Agent" : "Terminal";
|
|
2515
3259
|
const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
|
|
2516
3260
|
const mcpServer = mcpMatch?.[1];
|
|
2517
3261
|
const sendBlock = (msg, result2) => {
|
|
2518
|
-
const
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
"local-config": "your local config (dangerousWords / rules)",
|
|
2522
|
-
"local-decision": "you denied it in the browser",
|
|
2523
|
-
"no-approval-mechanism": "no approval method is configured"
|
|
2524
|
-
};
|
|
2525
|
-
console.error(chalk4.red(`
|
|
3262
|
+
const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
|
|
3263
|
+
const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
|
|
3264
|
+
console.error(chalk5.red(`
|
|
2526
3265
|
\u{1F6D1} Node9 blocked "${toolName}"`));
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
chalk4.gray(
|
|
2530
|
-
` Blocked by: ${BLOCKED_BY_LABELS[result2.blockedBy] ?? result2.blockedBy}`
|
|
2531
|
-
)
|
|
2532
|
-
);
|
|
2533
|
-
}
|
|
2534
|
-
if (result2?.changeHint) {
|
|
2535
|
-
console.error(chalk4.cyan(` To change: ${result2.changeHint}`));
|
|
2536
|
-
}
|
|
3266
|
+
console.error(chalk5.gray(` Triggered by: ${blockedByContext}`));
|
|
3267
|
+
if (result2?.changeHint) console.error(chalk5.cyan(` To change: ${result2.changeHint}`));
|
|
2537
3268
|
console.error("");
|
|
3269
|
+
let aiFeedbackMessage = "";
|
|
3270
|
+
if (isHumanDecision) {
|
|
3271
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
|
|
3272
|
+
REASON: ${msg || "No specific reason provided by user."}
|
|
3273
|
+
|
|
3274
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3275
|
+
- Do NOT retry this exact command immediately.
|
|
3276
|
+
- Explain to the user that you understand they blocked the action.
|
|
3277
|
+
- Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
|
|
3278
|
+
- If you believe this action is critical, explain your reasoning to the user and ask them to run 'node9 pause 15m' to allow you to proceed.`;
|
|
3279
|
+
} else {
|
|
3280
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
|
|
3281
|
+
REASON: ${msg}
|
|
3282
|
+
|
|
3283
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3284
|
+
- This command violates the current security configuration.
|
|
3285
|
+
- Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
|
|
3286
|
+
- Pivot to a non-destructive or read-only alternative.
|
|
3287
|
+
- Inform the user which security rule was triggered.`;
|
|
3288
|
+
}
|
|
2538
3289
|
process.stdout.write(
|
|
2539
3290
|
JSON.stringify({
|
|
2540
3291
|
decision: "block",
|
|
2541
|
-
reason:
|
|
3292
|
+
reason: aiFeedbackMessage,
|
|
3293
|
+
// This is the core instruction
|
|
2542
3294
|
hookSpecificOutput: {
|
|
2543
3295
|
hookEventName: "PreToolUse",
|
|
2544
3296
|
permissionDecision: "deny",
|
|
2545
|
-
permissionDecisionReason:
|
|
3297
|
+
permissionDecisionReason: aiFeedbackMessage
|
|
2546
3298
|
}
|
|
2547
3299
|
}) + "\n"
|
|
2548
3300
|
);
|
|
@@ -2553,36 +3305,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2553
3305
|
return;
|
|
2554
3306
|
}
|
|
2555
3307
|
const meta = { agent, mcpServer };
|
|
3308
|
+
const STATE_CHANGING_TOOLS_PRE = [
|
|
3309
|
+
"bash",
|
|
3310
|
+
"shell",
|
|
3311
|
+
"write_file",
|
|
3312
|
+
"edit_file",
|
|
3313
|
+
"replace",
|
|
3314
|
+
"terminal.execute",
|
|
3315
|
+
"str_replace_based_edit_tool",
|
|
3316
|
+
"create_file"
|
|
3317
|
+
];
|
|
3318
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
|
|
3319
|
+
await createShadowSnapshot();
|
|
3320
|
+
}
|
|
2556
3321
|
const result = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2557
3322
|
if (result.approved) {
|
|
2558
|
-
if (result.checkedBy)
|
|
3323
|
+
if (result.checkedBy)
|
|
2559
3324
|
process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
|
|
2560
3325
|
`);
|
|
2561
|
-
}
|
|
2562
3326
|
process.exit(0);
|
|
2563
3327
|
}
|
|
2564
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY &&
|
|
2565
|
-
console.error(
|
|
3328
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
|
|
3329
|
+
console.error(chalk5.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2566
3330
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2567
3331
|
if (daemonReady) {
|
|
2568
3332
|
const retry = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2569
3333
|
if (retry.approved) {
|
|
2570
|
-
if (retry.checkedBy)
|
|
3334
|
+
if (retry.checkedBy)
|
|
2571
3335
|
process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
|
|
2572
3336
|
`);
|
|
2573
|
-
}
|
|
2574
3337
|
process.exit(0);
|
|
2575
3338
|
}
|
|
2576
|
-
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`,
|
|
3339
|
+
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3340
|
+
...retry,
|
|
3341
|
+
blockedByLabel: retry.blockedByLabel
|
|
3342
|
+
});
|
|
2577
3343
|
return;
|
|
2578
3344
|
}
|
|
2579
3345
|
}
|
|
2580
|
-
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`,
|
|
3346
|
+
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3347
|
+
...result,
|
|
3348
|
+
blockedByLabel: result.blockedByLabel
|
|
3349
|
+
});
|
|
2581
3350
|
} catch (err) {
|
|
2582
3351
|
if (process.env.NODE9_DEBUG === "1") {
|
|
2583
|
-
const logPath =
|
|
3352
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
2584
3353
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2585
|
-
|
|
3354
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
|
|
2586
3355
|
`);
|
|
2587
3356
|
}
|
|
2588
3357
|
process.exit(0);
|
|
@@ -2593,48 +3362,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2593
3362
|
} else {
|
|
2594
3363
|
let raw = "";
|
|
2595
3364
|
let processed = false;
|
|
3365
|
+
let inactivityTimer = null;
|
|
2596
3366
|
const done = async () => {
|
|
2597
3367
|
if (processed) return;
|
|
2598
3368
|
processed = true;
|
|
3369
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
2599
3370
|
if (!raw.trim()) return process.exit(0);
|
|
2600
3371
|
await processPayload(raw);
|
|
2601
3372
|
};
|
|
2602
3373
|
process.stdin.setEncoding("utf-8");
|
|
2603
|
-
process.stdin.on("data", (chunk) =>
|
|
2604
|
-
|
|
2605
|
-
|
|
3374
|
+
process.stdin.on("data", (chunk) => {
|
|
3375
|
+
raw += chunk;
|
|
3376
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
3377
|
+
inactivityTimer = setTimeout(() => void done(), 2e3);
|
|
3378
|
+
});
|
|
3379
|
+
process.stdin.on("end", () => {
|
|
3380
|
+
void done();
|
|
3381
|
+
});
|
|
3382
|
+
inactivityTimer = setTimeout(() => void done(), 5e3);
|
|
2606
3383
|
}
|
|
2607
3384
|
});
|
|
2608
3385
|
program.command("log").description("PostToolUse hook \u2014 records executed tool calls").argument("[data]", "JSON string of the tool call").action(async (data) => {
|
|
2609
|
-
const logPayload = (raw) => {
|
|
3386
|
+
const logPayload = async (raw) => {
|
|
2610
3387
|
try {
|
|
2611
3388
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2612
3389
|
const payload = JSON.parse(raw);
|
|
3390
|
+
const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
|
|
3391
|
+
const rawInput = payload.tool_input ?? payload.args ?? {};
|
|
2613
3392
|
const entry = {
|
|
2614
3393
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2615
|
-
tool
|
|
2616
|
-
|
|
3394
|
+
tool,
|
|
3395
|
+
args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
|
|
3396
|
+
decision: "allowed",
|
|
3397
|
+
source: "post-hook"
|
|
2617
3398
|
};
|
|
2618
|
-
const logPath =
|
|
2619
|
-
if (!
|
|
2620
|
-
|
|
2621
|
-
|
|
3399
|
+
const logPath = path5.join(os5.homedir(), ".node9", "audit.log");
|
|
3400
|
+
if (!fs5.existsSync(path5.dirname(logPath)))
|
|
3401
|
+
fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
|
|
3402
|
+
fs5.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
3403
|
+
const config = getConfig();
|
|
3404
|
+
const STATE_CHANGING_TOOLS = [
|
|
3405
|
+
"bash",
|
|
3406
|
+
"shell",
|
|
3407
|
+
"write_file",
|
|
3408
|
+
"edit_file",
|
|
3409
|
+
"replace",
|
|
3410
|
+
"terminal.execute"
|
|
3411
|
+
];
|
|
3412
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
|
|
3413
|
+
await createShadowSnapshot();
|
|
3414
|
+
}
|
|
2622
3415
|
} catch {
|
|
2623
3416
|
}
|
|
2624
3417
|
process.exit(0);
|
|
2625
3418
|
};
|
|
2626
3419
|
if (data) {
|
|
2627
|
-
logPayload(data);
|
|
3420
|
+
await logPayload(data);
|
|
2628
3421
|
} else {
|
|
2629
3422
|
let raw = "";
|
|
2630
3423
|
process.stdin.setEncoding("utf-8");
|
|
2631
3424
|
process.stdin.on("data", (chunk) => raw += chunk);
|
|
2632
|
-
process.stdin.on("end", () =>
|
|
3425
|
+
process.stdin.on("end", () => {
|
|
3426
|
+
void logPayload(raw);
|
|
3427
|
+
});
|
|
2633
3428
|
setTimeout(() => {
|
|
2634
3429
|
if (!raw) process.exit(0);
|
|
2635
3430
|
}, 500);
|
|
2636
3431
|
}
|
|
2637
3432
|
});
|
|
3433
|
+
program.command("pause").description("Temporarily disable Node9 protection for a set duration").option("-d, --duration <duration>", "How long to pause (e.g. 15m, 1h, 30s)", "15m").action((options) => {
|
|
3434
|
+
const ms = parseDuration(options.duration);
|
|
3435
|
+
if (ms === null) {
|
|
3436
|
+
console.error(
|
|
3437
|
+
chalk5.red(`
|
|
3438
|
+
\u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
|
|
3439
|
+
`)
|
|
3440
|
+
);
|
|
3441
|
+
process.exit(1);
|
|
3442
|
+
}
|
|
3443
|
+
pauseNode9(ms, options.duration);
|
|
3444
|
+
const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
|
|
3445
|
+
console.log(chalk5.yellow(`
|
|
3446
|
+
\u23F8 Node9 paused until ${expiresAt}`));
|
|
3447
|
+
console.log(chalk5.gray(` All tool calls will be allowed without review.`));
|
|
3448
|
+
console.log(chalk5.gray(` Run "node9 resume" to re-enable early.
|
|
3449
|
+
`));
|
|
3450
|
+
});
|
|
3451
|
+
program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
|
|
3452
|
+
const { paused } = checkPause();
|
|
3453
|
+
if (!paused) {
|
|
3454
|
+
console.log(chalk5.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
resumeNode9();
|
|
3458
|
+
console.log(chalk5.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
|
|
3459
|
+
});
|
|
2638
3460
|
var HOOK_BASED_AGENTS = {
|
|
2639
3461
|
claude: "claude",
|
|
2640
3462
|
gemini: "gemini",
|
|
@@ -2646,39 +3468,21 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2646
3468
|
if (HOOK_BASED_AGENTS[firstArg] !== void 0) {
|
|
2647
3469
|
const target = HOOK_BASED_AGENTS[firstArg];
|
|
2648
3470
|
console.error(
|
|
2649
|
-
|
|
3471
|
+
chalk5.yellow(`
|
|
2650
3472
|
\u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
|
|
2651
3473
|
);
|
|
3474
|
+
console.error(chalk5.white(`
|
|
3475
|
+
"${target}" uses its own hook system. Use:`));
|
|
2652
3476
|
console.error(
|
|
2653
|
-
|
|
2654
|
-
"${target}" is an interactive terminal app \u2014 it needs a real`)
|
|
2655
|
-
);
|
|
2656
|
-
console.error(
|
|
2657
|
-
chalk4.white(` TTY and communicates via its own hook system, not JSON-RPC.
|
|
2658
|
-
`)
|
|
2659
|
-
);
|
|
2660
|
-
console.error(chalk4.bold(` Use the hook-based integration instead:
|
|
2661
|
-
`));
|
|
2662
|
-
console.error(
|
|
2663
|
-
chalk4.green(` node9 addto ${target} `) + chalk4.gray("# one-time setup")
|
|
2664
|
-
);
|
|
2665
|
-
console.error(
|
|
2666
|
-
chalk4.green(` ${target} `) + chalk4.gray("# run normally \u2014 Node9 hooks fire automatically")
|
|
2667
|
-
);
|
|
2668
|
-
console.error(chalk4.white(`
|
|
2669
|
-
For browser approval popups (no API key required):`));
|
|
2670
|
-
console.error(
|
|
2671
|
-
chalk4.green(` node9 daemon --background`) + chalk4.gray("# start (no second terminal needed)")
|
|
2672
|
-
);
|
|
2673
|
-
console.error(
|
|
2674
|
-
chalk4.green(` ${target} `) + chalk4.gray("# Node9 will open browser on dangerous actions\n")
|
|
3477
|
+
chalk5.green(` node9 addto ${target} `) + chalk5.gray("# one-time setup")
|
|
2675
3478
|
);
|
|
3479
|
+
console.error(chalk5.green(` ${target} `) + chalk5.gray("# run normally"));
|
|
2676
3480
|
process.exit(1);
|
|
2677
3481
|
}
|
|
2678
3482
|
const fullCommand = commandArgs.join(" ");
|
|
2679
3483
|
let result = await authorizeHeadless("shell", { command: fullCommand });
|
|
2680
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON &&
|
|
2681
|
-
console.error(
|
|
3484
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
|
|
3485
|
+
console.error(chalk5.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2682
3486
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2683
3487
|
if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
|
|
2684
3488
|
}
|
|
@@ -2687,39 +3491,44 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2687
3491
|
}
|
|
2688
3492
|
if (!result.approved) {
|
|
2689
3493
|
console.error(
|
|
2690
|
-
|
|
3494
|
+
chalk5.red(`
|
|
2691
3495
|
\u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
|
|
2692
3496
|
);
|
|
2693
|
-
if (result.blockedBy) {
|
|
2694
|
-
const BLOCKED_BY_LABELS = {
|
|
2695
|
-
"team-policy": "Team policy (Node9 cloud)",
|
|
2696
|
-
"persistent-deny": "Persistent deny rule",
|
|
2697
|
-
"local-config": "Local config",
|
|
2698
|
-
"local-decision": "Browser UI decision",
|
|
2699
|
-
"no-approval-mechanism": "No approval mechanism available"
|
|
2700
|
-
};
|
|
2701
|
-
console.error(
|
|
2702
|
-
chalk4.gray(` Blocked by: ${BLOCKED_BY_LABELS[result.blockedBy] ?? result.blockedBy}`)
|
|
2703
|
-
);
|
|
2704
|
-
}
|
|
2705
|
-
if (result.changeHint) {
|
|
2706
|
-
console.error(chalk4.cyan(` To change: ${result.changeHint}`));
|
|
2707
|
-
}
|
|
2708
3497
|
process.exit(1);
|
|
2709
3498
|
}
|
|
2710
|
-
console.error(
|
|
3499
|
+
console.error(chalk5.green("\n\u2705 Approved \u2014 running command...\n"));
|
|
2711
3500
|
await runProxy(fullCommand);
|
|
2712
3501
|
} else {
|
|
2713
3502
|
program.help();
|
|
2714
3503
|
}
|
|
2715
3504
|
});
|
|
3505
|
+
program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
|
|
3506
|
+
const hash = getLatestSnapshotHash();
|
|
3507
|
+
if (!hash) {
|
|
3508
|
+
console.log(chalk5.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
console.log(chalk5.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
|
|
3512
|
+
console.log(chalk5.white(`Target Snapshot: ${chalk5.gray(hash.slice(0, 7))}`));
|
|
3513
|
+
const proceed = await confirm3({
|
|
3514
|
+
message: "Revert all files to the state before the last AI action?",
|
|
3515
|
+
default: false
|
|
3516
|
+
});
|
|
3517
|
+
if (proceed) {
|
|
3518
|
+
if (applyUndo(hash)) {
|
|
3519
|
+
console.log(chalk5.green("\u2705 Project reverted successfully.\n"));
|
|
3520
|
+
} else {
|
|
3521
|
+
console.error(chalk5.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
});
|
|
2716
3525
|
process.on("unhandledRejection", (reason) => {
|
|
2717
3526
|
const isCheckHook = process.argv[2] === "check";
|
|
2718
3527
|
if (isCheckHook) {
|
|
2719
|
-
if (process.env.NODE9_DEBUG === "1") {
|
|
2720
|
-
const logPath =
|
|
3528
|
+
if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
|
|
3529
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
2721
3530
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
2722
|
-
|
|
3531
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
|
|
2723
3532
|
`);
|
|
2724
3533
|
}
|
|
2725
3534
|
process.exit(0);
|